diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 29c14045..f978c35e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -9,7 +9,6 @@ @using CodeBeam.UltimateAuth.Core.Domain @using CodeBeam.UltimateAuth.Core.Runtime @using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Cookies @using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @using CodeBeam.UltimateAuth.Server.Stores @@ -53,7 +52,6 @@ @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index 0ebd5cf8..d69732e0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -1,14 +1,14 @@  - net10.0 + net9.0 enable enable 0.0.1-preview - + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 259133b0..fc6ba3df 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -4,10 +4,10 @@ @using CodeBeam.UltimateAuth.Client.Authentication @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Runtime @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Cookies @using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @inject IUAuthStateManager StateManager @@ -20,7 +20,7 @@ @inject IHttpContextAccessor HttpContextAccessor @inject IUAuthClient UAuth @inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo +@inject IUAuthClientProductInfoProvider ClientProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics @inject IDeviceIdProvider DeviceIdProvider @@ -50,8 +50,8 @@ - @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() + @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version + Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 6b5ce18b..3e27d40a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -1,146 +1,144 @@ using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; -namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Home { - public partial class Home - { - private string? _username; - private string? _password; + private string? _username; + private string? _password; - private UALoginForm _form = null!; + private UALoginForm _form = null!; - private AuthenticationState _authState = null!; + private AuthenticationState _authState = null!; - protected override async Task OnInitializedAsync() - { - Diagnostics.Changed += OnDiagnosticsChanged; - } + protected override async Task OnInitializedAsync() + { + Diagnostics.Changed += OnDiagnosticsChanged; + } - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) { - if (firstRender) - { - await StateManager.EnsureAsync(); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - StateHasChanged(); - } + await StateManager.EnsureAsync(); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + StateHasChanged(); } + } - private void OnDiagnosticsChanged() - { - InvokeAsync(StateHasChanged); - } + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } - private async Task ProgrammaticLogin() + private async Task ProgrammaticLogin() + { + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + var request = new LoginRequest { - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - var request = new LoginRequest - { - Identifier = "admin", - Secret = "admin", - Device = DeviceContext.FromDeviceId(deviceId), - }; - await UAuth.Flows.LoginAsync(request); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - } + Identifier = "admin", + Secret = "admin", + Device = DeviceContext.FromDeviceId(deviceId), + }; + await UAuth.Flows.LoginAsync(request); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + } - private async Task ValidateAsync() - { - var result = await UAuth.Flows.ValidateAsync(); + private async Task ValidateAsync() + { + var result = await UAuth.Flows.ValidateAsync(); - Snackbar.Add( - result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", - result.IsValid ? Severity.Success : Severity.Error); - } + Snackbar.Add( + result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", + result.IsValid ? Severity.Success : Severity.Error); + } - private async Task LogoutAsync() + private async Task LogoutAsync() + { + await UAuth.Flows.LogoutAsync(); + Snackbar.Add("Logged out", Severity.Success); + } + + private async Task RefreshAsync() + { + await UAuth.Flows.RefreshAsync(); + } + + private async Task HandleGetMe() + { + var profileResult = await UAuth.Users.GetMeAsync(); + if (profileResult.Ok) { - await UAuth.Flows.LogoutAsync(); - Snackbar.Add("Logged out", Severity.Success); + var profile = profileResult.Value; + Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); } - - private async Task RefreshAsync() + else { - await UAuth.Flows.RefreshAsync(); + Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); } + } - private async Task HandleGetMe() + private async Task ChangeUserInactive() + { + ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest { - var profileResult = await UAuth.Users.GetMeAsync(); - if (profileResult.Ok) - { - var profile = profileResult.Value; - Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); - } - else - { - Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); - } + UserKey = UserKey.FromString("user"), + NewStatus = UserStatus.Disabled + }; + var result = await UAuth.Users.ChangeStatusAdminAsync(request); + if (result.Ok) + { + Snackbar.Add($"User is disabled.", Severity.Info); } - - private async Task ChangeUserInactive() + else { - ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest - { - UserKey = UserKey.FromString("user"), - NewStatus = UserStatus.Disabled - }; - var result = await UAuth.Users.ChangeStatusAdminAsync(request); - if (result.Ok) - { - Snackbar.Add($"User is disabled.", Severity.Info); - } - else - { - Snackbar.Add($"Failed to change user status.", Severity.Error); - } + Snackbar.Add($"Failed to change user status.", Severity.Error); } + } - protected override void OnAfterRender(bool firstRender) + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) { - if (firstRender) + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var error)) { - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("error", out var error)) - { - ShowLoginError(error.ToString()); - ClearQueryString(); - } + ShowLoginError(error.ToString()); + ClearQueryString(); } } + } - private void ShowLoginError(string code) + private void ShowLoginError(string code) + { + var message = code switch { - var message = code switch - { - "invalid" => "Invalid username or password.", - "locked" => "Your account is locked.", - "mfa" => "Multi-factor authentication required.", - _ => "Login failed." - }; + "invalid" => "Invalid username or password.", + "locked" => "Your account is locked.", + "mfa" => "Multi-factor authentication required.", + _ => "Login failed." + }; - Snackbar.Add(message, Severity.Error); - } - - private void ClearQueryString() - { - var uri = new Uri(Nav.Uri); - var clean = uri.GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); - } + Snackbar.Add(message, Severity.Error); + } - public void Dispose() - { - Diagnostics.Changed -= OnDiagnosticsChanged; - } + private void ClearQueryString() + { + var uri = new Uri(Nav.Uri); + var clean = uri.GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } + public void Dispose() + { + Diagnostics.Changed -= OnDiagnosticsChanged; } + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 6791f6d6..0d72839b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -15,6 +15,7 @@ using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; @@ -54,11 +55,13 @@ builder.Services.AddUltimateAuth(); -builder.Services.AddUltimateAuthServer(o => { +builder.Services.AddUltimateAuthServer(o => +{ o.Diagnostics.EnableRefreshHeaders = true; //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + o.AuthResponse.Login.AllowReturnUrlOverride = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor index da54578a..f9cd744d 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -12,7 +12,7 @@ @inject ISnackbar Snackbar @inject IUAuthClient UAuthClient @inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo +@inject IUAuthClientProductInfoProvider ClientProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics @inject IUAuthClientBootstrapper Bootstrapper @@ -41,8 +41,8 @@ - @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() + @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version + Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 98ba1830..1a438a17 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -15,7 +15,7 @@ builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthClient(o => { - o.Endpoints.Authority = "https://localhost:6110"; + o.Endpoints.BasePath = "https://localhost:6110"; }); //builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor index c7678bea..2d3cafbc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor @@ -7,7 +7,6 @@ @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options @inject IJSRuntime JS -@inject IOptions CoreOptions @inject IOptions Options @inject NavigationManager Navigation diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs index b747c717..8337cd2e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -115,7 +115,7 @@ public async Task SubmitAsync() await JS.InvokeVoidAsync("uauth.submitForm", _form); } - private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); + private string ClientProfileValue => Options.Value.ClientProfile.ToString(); private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce ? Options.Value.Endpoints.PkceComplete @@ -130,7 +130,7 @@ private string ResolvedEndpoint ? EffectiveEndpoint : Endpoint; - var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath); + var baseUrl = UAuthUrlBuilder.Build(Options.Value.Endpoints.BasePath, loginPath, Options.Value.MultiTenant); var returnUrl = EffectiveReturnUrl; if (string.IsNullOrWhiteSpace(returnUrl)) diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs new file mode 100644 index 00000000..6a13be2f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum TenantTransport +{ + None, + Header, + Route +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 0ae74a5e..451e8fc9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -12,7 +12,6 @@ using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -32,34 +31,19 @@ namespace CodeBeam.UltimateAuth.Client.Extensions; /// public static class ServiceCollectionExtensions { - /// - /// Registers UltimateAuth client services using configuration binding - /// (e.g. appsettings.json). - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, IConfiguration configurationSection) + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action? configure = null) { - services.Configure(configurationSection); - return services.AddUltimateAuthClientInternal(); - } + ArgumentNullException.ThrowIfNull(services); - /// - /// Registers UltimateAuth client services using programmatic configuration. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthClientInternal(); - } + services.AddOptions() + // Program.cs configuration (lowest precedence) + .Configure(options => + { + configure?.Invoke(options); + }) + // appsettings.json (highest precedence) + .BindConfiguration("UltimateAuth:Client"); - /// - /// Registers UltimateAuth client services with default (empty) configuration. - /// - /// Intended for advanced scenarios where configuration is fully controlled - /// by the hosting application or overridden later. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services) - { - services.Configure(_ => { }); return services.AddUltimateAuthClientInternal(); } @@ -75,26 +59,19 @@ public static IServiceCollection AddUltimateAuthClient(this IServiceCollection s /// private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCollection services) { - // Options validation can be added here later if needed - // services.AddSingleton, ...>(); + services.AddScoped(); + + services.AddOptions(); + services.AddSingleton, UAuthClientOptionsValidator>(); + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); services.AddSingleton(); - services.AddSingleton, UAuthOptionsPostConfigure>(); + services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); - //services.PostConfigure(o => - //{ - // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - // return; - - // using var sp = services.BuildServiceProvider(); - // var detector = sp.GetRequiredService(); - // o.ClientProfile = detector.Detect(sp); - //}); - services.PostConfigure(o => { - o.Refresh.Interval ??= TimeSpan.FromMinutes(5); + o.AutoRefresh.Interval ??= TimeSpan.FromMinutes(5); }); services.TryAddScoped(); @@ -107,9 +84,9 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddScoped(sp => { - var core = sp.GetRequiredService>().Value; + var options = sp.GetRequiredService>().Value; - return core.ClientProfile == UAuthClientProfile.BlazorServer + return options.ClientProfile == UAuthClientProfile.BlazorServer ? sp.GetRequiredService() : sp.GetRequiredService(); }); diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs index 0857f31b..d74959bf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -30,12 +30,15 @@ public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager nav public async Task StartAsync(CancellationToken cancellationToken = default) { + if (!_options.AutoRefresh.Enabled) + return; + if (_timer is not null) return; _diagnostics.MarkStarted(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var interval = _options.Refresh.Interval ?? TimeSpan.FromMinutes(5); + var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); _timer = new PeriodicTimer(interval); _ = RunAsync(_cts.Token); @@ -64,8 +67,8 @@ private async Task RunAsync(CancellationToken ct) case RefreshOutcome.ReauthRequired: switch (_options.Reauth.Behavior) { - case ReauthBehavior.RedirectToLogin: - _navigation.NavigateTo(_options.Reauth.LoginPath, forceLoad: true); + case ReauthBehavior.Redirect: + _navigation.NavigateTo(_options.Reauth.RedirectPath ?? _options.Endpoints.Login, forceLoad: true); break; case ReauthBehavior.RaiseEvent: diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs new file mode 100644 index 00000000..6b8138c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class ClientLoginCapabilities +{ + public static bool CanPostCredentials(UAuthClientProfile profile) + => profile switch + { + UAuthClientProfile.BlazorServer => true, + UAuthClientProfile.UAuthHub => true, + _ => false + }; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs index bedb3795..0f3ce69c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -10,12 +11,12 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal sealed class UAuthRequestClient : IUAuthRequestClient { private readonly IJSRuntime _js; - private UAuthOptions _coreOptions; + private UAuthClientOptions _options; - public UAuthRequestClient(IJSRuntime js, IOptions coreOptions) + public UAuthRequestClient(IJSRuntime js, IOptions options) { _js = js; - _coreOptions = coreOptions.Value; + _options = options.Value; } public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) @@ -27,7 +28,7 @@ public Task NavigateAsync(string endpoint, IDictionary? form = n url = endpoint, mode = "navigate", data = form, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }).AsTask(); } @@ -41,7 +42,7 @@ public async Task SendFormAsync(string endpoint, IDictiona mode = "fetch", expectJson = false, data = form, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); return result; @@ -59,7 +60,7 @@ public async Task SendFormForJsonAsync(string endpoint, ID mode = "fetch", expectJson = true, data = postData, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); } @@ -71,7 +72,7 @@ public async Task SendJsonAsync(string endpoint, object? p { url = endpoint, payload = payload, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 2d4cbb74..7137175b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -1,9 +1,29 @@ -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal static class UAuthUrlBuilder { - public static string Combine(string authority, string relative) + //public static string Combine(string authority, string relative) + //{ + // return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); + //} + + public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { - return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); + var baseAuthority = authority.TrimEnd('/'); + + if (tenant.Enabled && tenant.Transport == TenantTransport.Route) + { + if (string.IsNullOrWhiteSpace(tenant.Tenant)) + { + throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided."); + } + + baseAuthority = "/" + tenant.Tenant.Trim('/') + baseAuthority; + } + + return baseAuthority + "/" + relativePath.TrimStart('/'); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs new file mode 100644 index 00000000..eebd7e3f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Client.Options; + +/// +/// Controls automatic background refresh behavior. +/// This does NOT guarantee session continuity. +/// +public sealed class UAuthClientAutoRefreshOptions +{ + /// + /// Enables background refresh coordination. + /// Default: true for BlazorServer, false otherwise. + /// + public bool Enabled { get; set; } = true; + + /// + /// Interval for background refresh attempts. + /// This is a UX / keep-alive setting, NOT a security policy. + /// + public TimeSpan? Interval { get; set; } + + // TODO: Future enhancement: Add jitter to avoid synchronized refresh storms in multi-tab scenarios. + ///// + ///// Optional jitter to avoid synchronized refresh storms. + ///// + //public TimeSpan? Jitter { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs new file mode 100644 index 00000000..f1cd1281 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientEndpointOptions +{ + /// + /// Base URL of UAuthHub (e.g. https://localhost:6110) + /// + public string BasePath { get; set; } = "/auth"; + + public string Login { get; set; } = "/login"; + public string Logout { get; set; } = "/logout"; + public string Refresh { get; set; } = "/refresh"; + public string Reauth { get; set; } = "/reauth"; + public string Validate { get; set; } = "/validate"; + public string PkceAuthorize { get; set; } = "/pkce/authorize"; + public string PkceComplete { get; set; } = "/pkce/complete"; + public string HubLoginPath { get; set; } = "/uauthhub/login"; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs new file mode 100644 index 00000000..9cb1d376 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientLoginFlowOptions +{ + /// + /// Default return URL after a successful login flow. + /// If not set, global default return url or current location will be used. + /// + public string? ReturnUrl { get; set; } + + /// + /// Allows posting credentials (e.g. username/password) directly to the server. + /// + /// ⚠️ SECURITY WARNING: + /// This MUST NOT be enabled for public clients (e.g. Blazor WASM, SPA). + /// Public clients are required to use PKCE-based login flows. + /// + /// Enable this option ONLY for trusted server-hosted clients + /// such as Blazor Server or UAuthHub. + /// + /// This option may be temporarily enabled for debugging purposes, + /// but doing so is inherently insecure and MUST NOT be used in production. + /// + public bool AllowCredentialPost { get; set; } = false; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs new file mode 100644 index 00000000..c5d9caf6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Client.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientMultiTenantOptions +{ + /// + /// Enables tenant propagation from client to server. + /// + public bool Enabled { get; set; } + + /// + /// Tenant identifier to propagate. + /// Client does NOT resolve tenant, only carries it. + /// + public string? Tenant { get; set; } + + /// + /// Transport mechanism for tenant propagation. + /// + public TenantTransport Transport { get; set; } = TenantTransport.None; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 0aabb867..dab4589d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -1,74 +1,26 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Client.Options; public sealed class UAuthClientOptions { - public AuthEndpointOptions Endpoints { get; set; } = new(); - public LoginOptions Login { get; set; } = new(); - public UAuthClientRefreshOptions Refresh { get; set; } = new(); - public ReauthOptions Reauth { get; init; } = new(); -} + public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + public bool AutoDetectClientProfile { get; set; } = true; -public sealed class AuthEndpointOptions -{ /// - /// Base URL of UAuthHub (e.g. https://localhost:6110) - /// - public string Authority { get; set; } = "/auth"; - - public string Login { get; set; } = "/login"; - public string Logout { get; set; } = "/logout"; - public string Refresh { get; set; } = "/refresh"; - public string Reauth { get; set; } = "/reauth"; - public string Validate { get; set; } = "/validate"; - public string PkceAuthorize { get; set; } = "/pkce/authorize"; - public string PkceComplete { get; set; } = "/pkce/complete"; - public string HubLoginPath { get; set; } = "/uauthhub/login"; -} - -public sealed class LoginOptions -{ - /// - /// Default return URL after a successful login flow. - /// If not set, current location will be used. + /// Global fallback return URL used by interactive authentication flows + /// when no flow-specific return URL is provided. /// public string? DefaultReturnUrl { get; set; } - /// - /// Options related to PKCE-based login flows. - /// - public PkceLoginOptions Pkce { get; set; } = new(); - - /// - /// Enables or disables direct credential-based login. - /// - public bool AllowDirectLogin { get; set; } = true; -} - -public sealed class UAuthClientRefreshOptions -{ - /// - /// Enables background refresh coordination. - /// Default: true for BlazorServer, false otherwise. - /// - public bool Enabled { get; set; } = true; + public UAuthClientEndpointOptions Endpoints { get; set; } = new(); + public UAuthClientLoginFlowOptions Login { get; set; } = new(); /// - /// Interval for background refresh attempts. - /// This is a UX / keep-alive setting, NOT a security policy. - /// - public TimeSpan? Interval { get; set; } - - /// - /// Optional jitter to avoid synchronized refresh storms. + /// Options related to PKCE-based login flows. /// - public TimeSpan? Jitter { get; set; } -} - -// TODO: Add ClearCookieOnReauth -public sealed class ReauthOptions -{ - public ReauthBehavior Behavior { get; set; } = ReauthBehavior.RedirectToLogin; - public string LoginPath { get; set; } = "/login"; + public UAuthClientPkceLoginFlowOptions Pkce { get; set; } = new(); + public UAuthClientAutoRefreshOptions AutoRefresh { get; set; } = new(); + public UAuthClientReauthOptions Reauth { get; init; } = new(); + public UAuthClientMultiTenantOptions MultiTenant { get; set; } = new(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs rename to src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs index 2f82cd8f..6052ba7c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Client.Options; -public sealed class PkceLoginOptions +public sealed class UAuthClientPkceLoginFlowOptions { /// /// Enables PKCE login support. diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs new file mode 100644 index 00000000..3cb6796f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Options; + +// TODO: Add ClearCookieOnReauth +public sealed class UAuthClientReauthOptions +{ + public ReauthBehavior Behavior { get; set; } = ReauthBehavior.Redirect; + public string? RedirectPath { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs index 4c2aa91c..d725091e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs @@ -1,20 +1,21 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Infrastructure; -internal sealed class UAuthOptionsPostConfigure : IPostConfigureOptions +internal sealed class UAuthClientOptionsPostConfigure : IPostConfigureOptions { private readonly IClientProfileDetector _detector; private readonly IServiceProvider _services; - public UAuthOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) + public UAuthClientOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) { _detector = detector; _services = services; } - public void PostConfigure(string? name, UAuthOptions options) + public void PostConfigure(string? name, UAuthClientOptions options) { if (!options.AutoDetectClientProfile) return; diff --git a/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs new file mode 100644 index 00000000..d00b4f2e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientEndpointOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) + { + var e = options.Endpoints; + + if (string.IsNullOrWhiteSpace(e.BasePath)) + { + return ValidateOptionsResult.Fail("Endpoints.BasePath must be specified."); + } + + if (string.IsNullOrWhiteSpace(e.Login) || + string.IsNullOrWhiteSpace(e.Logout) || + string.IsNullOrWhiteSpace(e.Refresh) || + string.IsNullOrWhiteSpace(e.Validate)) + { + return ValidateOptionsResult.Fail("One or more required endpoint paths are missing in UAuthClientEndpointOptions."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs new file mode 100644 index 00000000..98e2ec3c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) + { + if (options.ClientProfile == UAuthClientProfile.NotSpecified && options.AutoDetectClientProfile == false) + { + return ValidateOptionsResult.Fail("ClientProfile is NotSpecified while AutoDetectClientProfile is disabled. " + + "Either specify a ClientProfile or enable auto-detection."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs deleted file mode 100644 index 50f66883..00000000 --- a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Runtime; - -namespace CodeBeam.UltimateAuth.Client.Runtime; - -public sealed class UAuthClientProductInfo -{ - public string ProductName { get; init; } = "UltimateAuthClient"; - public UAuthProductInfo Core { get; init; } = default!; -} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..d240224a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Runtime; + +public interface IUAuthClientProductInfoProvider +{ + UAuthClientProductInfo Get(); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs new file mode 100644 index 00000000..fb7e4874 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Runtime; + +public sealed class UAuthClientProductInfo +{ + public string ProductName { get; init; } = "UltimateAuth Client"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } + + public UAuthClientProfile ClientProfile { get; init; } = default!; + + public DateTimeOffset StartedAt { get; init; } + public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..696163b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Client.Options; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Client.Runtime; + +internal sealed class UAuthClientProductInfoProvider : IUAuthClientProductInfoProvider +{ + private readonly UAuthClientProductInfo _info; + + public UAuthClientProductInfoProvider(IOptions options) + { + var asm = typeof(UAuthClientProductInfoProvider).Assembly; + + _info = new UAuthClientProductInfo + { + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + StartedAt = DateTimeOffset.UtcNow, + ClientProfile = options.Value.ClientProfile + }; + } + + public UAuthClientProductInfo Get() => _info; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs index c99d1456..3d85ecf6 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IFlowClient { - Task LoginAsync(LoginRequest request); + Task LoginAsync(LoginRequest request, string? returnUrl = null); Task LogoutAsync(); Task RefreshAsync(bool isAuto = false); Task ReauthAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index d4ed5fa7..fd79168e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -18,31 +18,29 @@ public UAuthAuthorizationClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task> CheckAsync(AuthorizationCheckRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/authorization/check"), request); return UAuthResultMapper.FromJson(raw); } public async Task> GetMyRolesAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/authorization/users/me/roles/get")); return UAuthResultMapper.FromJson(raw); } public async Task> GetUserRolesAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); return UAuthResultMapper.FromJson(raw); } public async Task AssignRoleAsync(UserKey userKey, string role) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/post"), new AssignRoleRequest { Role = role }); @@ -52,9 +50,7 @@ public async Task AssignRoleAsync(UserKey userKey, string role) public async Task RemoveRoleAsync(UserKey userKey, string role) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); - - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/delete"), new AssignRoleRequest { Role = role }); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 55b1fcf4..1d1d1be2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -18,7 +18,7 @@ public UAuthCredentialClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); public async Task> GetMyAsync() { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 128cd83d..2cea3e7c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -6,7 +6,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Security.Cryptography; @@ -19,33 +18,50 @@ internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; private readonly UAuthClientOptions _options; - private readonly UAuthOptions _coreOptions; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient( - IUAuthRequestClient post, - IOptions options, - IOptions coreOptions, - UAuthClientDiagnostics diagnostics, - NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; _options = options.Value; - _coreOptions = coreOptions.Value; _diagnostics = diagnostics; _nav = nav; } - public async Task LoginAsync(LoginRequest request) + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task LoginAsync(LoginRequest request, string? returnUrl = null) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); - await _post.NavigateAsync(url, request.ToDictionary()); + var canPost = ClientLoginCapabilities.CanPostCredentials(_options.ClientProfile); + + if (!_options.Login.AllowCredentialPost && !canPost) + { + throw new InvalidOperationException("Direct credential posting is disabled for this client profile. " + + "Public clients (e.g. Blazor WASM) MUST use PKCE-based login flows. " + + "If this is a trusted server-hosted client, you may explicitly enable " + + "Login.AllowCredentialPost, but doing so is insecure for public clients."); + } + + var resolvedReturnUrl = + returnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl; + + var payload = request.ToDictionary(); + + if (!string.IsNullOrWhiteSpace(resolvedReturnUrl)) + { + payload["return_url"] = resolvedReturnUrl; + } + + var url = Url(_options.Endpoints.Login); + await _post.NavigateAsync(url, payload); } public async Task LogoutAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); + var url = Url(_options.Endpoints.Logout); await _post.NavigateAsync(url); } @@ -56,7 +72,7 @@ public async Task RefreshAsync(bool isAuto = false) _diagnostics.MarkManualRefresh(); } - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); + var url = Url(_options.Endpoints.Refresh); var result = await _post.SendFormAsync(url); var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); switch (refreshOutcome) @@ -85,13 +101,13 @@ public async Task RefreshAsync(bool isAuto = false) public async Task ReauthAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); + var url = Url(_options.Endpoints.Reauth); await _post.NavigateAsync(_options.Endpoints.Reauth); } public async Task ValidateAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); + var url = Url(_options.Endpoints.Validate); var raw = await _post.SendFormForJsonAsync(url); if (!raw.Ok || raw.Body is null) @@ -118,7 +134,7 @@ public async Task ValidateAsync() public async Task BeginPkceAsync(string? returnUrl = null) { - var pkce = _options.Login.Pkce; + var pkce = _options.Pkce; if (!pkce.Enabled) throw new InvalidOperationException("PKCE login is disabled by configuration."); @@ -126,7 +142,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) var verifier = CreateVerifier(); var challenge = CreateChallenge(verifier); - var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); + var authorizeUrl = Url(_options.Endpoints.PkceAuthorize); var raw = await _post.SendFormForJsonAsync( authorizeUrl, @@ -150,7 +166,8 @@ public async Task BeginPkceAsync(string? returnUrl = null) var resolvedReturnUrl = returnUrl ?? pkce.ReturnUrl - ?? _options.Login.DefaultReturnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl ?? _nav.Uri; if (pkce.AutoRedirect) @@ -164,7 +181,13 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) if (request is null) throw new ArgumentNullException(nameof(request)); - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceComplete); + if (!_options.Pkce.Enabled) + { + throw new InvalidOperationException("PKCE login is disabled by configuration, but a PKCE completion was attempted. " + + "This usually indicates a misconfiguration or an unexpected redirect flow."); + } + + var url = Url(_options.Endpoints.PkceComplete); var payload = new Dictionary { @@ -181,14 +204,14 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) { - var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); + var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); var data = new Dictionary { ["authorization_code"] = authorizationCode, ["code_verifier"] = codeVerifier, ["return_url"] = returnUrl, - ["client_profile"] = _coreOptions.ClientProfile.ToString() + ["client_profile"] = _options.ClientProfile.ToString() }; return _post.NavigateAsync(hubLoginUrl, data); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index e13a3f8b..250b4733 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -18,59 +18,53 @@ public UAuthUserClient(IUAuthRequestClient request, IOptions _options = options.Value; } + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task> GetMeAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/users/me/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task> CreateAsync(CreateUserRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/create"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/create"), request); return UAuthResultMapper.FromJson(raw); } public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/status"), request); return UAuthResultMapper.FromJson(raw); } public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{request.UserKey.Value}/status"), request); return UAuthResultMapper.FromJson(raw); } public async Task> DeleteAsync(DeleteUserRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/delete")); return UAuthResultMapper.FromJson(raw); } public async Task> GetProfileAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/profile/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/profile/update"), request); return UAuthResultMapper.FromStatus(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 6aeeedca..96c3460b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -18,102 +18,89 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task>> GetMyIdentifiersAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/users/me/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); return UAuthResultMapper.FromStatus(raw); } public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); return UAuthResultMapper.FromStatus(raw); } public async Task>> GetUserIdentifiersAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/add"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/set-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/unset-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/verify"), request); return UAuthResultMapper.FromStatus(raw); } public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/delete"), request); return UAuthResultMapper.FromStatus(raw); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs index 2fe227d1..e8e11ca7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -2,6 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; +// TODO: Add ClockSkewInvariant to handle cases where client and server clocks are not synchronized, which can lead to valid tokens being rejected due to "not valid yet" or "expired" errors. This invariant would check the token's "nbf" (not before) and "exp" (expiration) claims against the current time, allowing for a configurable clock skew (e.g., 5 minutes) to accommodate minor discrepancies in system clocks. public interface IAuthorityInvariant { AccessDecisionResult Decide(AuthContext context); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs index a5332d1f..63e53b5c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs @@ -6,5 +6,6 @@ /// public interface IOpaqueTokenGenerator { - string Generate(int byteLength = 32); + string Generate(); + string GenerateJwtId(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index 3e8bca20..f78e4806 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -10,7 +10,7 @@ public interface ISessionIssuer Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); - Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); + Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 05148ed1..09e130c8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -5,10 +5,11 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionStoreKernel { Task ExecuteAsync(Func action, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); Task GetSessionAsync(AuthSessionId sessionId); Task SaveSessionAsync(UAuthSession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); Task GetChainAsync(SessionChainId chainId); Task SaveChainAsync(UAuthSessionChain chain); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index ca4b8406..320faef7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -14,23 +14,47 @@ public sealed class AccessContext // Target public string? Resource { get; init; } - public string? ResourceId { get; init; } + public UserKey? TargetUserKey { get; init; } public TenantKey ResourceTenant { get; init; } public string Action { get; init; } = default!; public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; public bool IsCrossTenant => !string.Equals(ActorTenant, ResourceTenant, StringComparison.Ordinal); - public bool IsSelfAction => ActorUserKey != null && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); + public bool IsSelfAction => ActorUserKey != null && TargetUserKey != null && string.Equals(ActorUserKey.Value, TargetUserKey.Value, StringComparison.Ordinal); public bool HasActor => ActorUserKey != null; - public bool HasTarget => ResourceId != null; + public bool HasTarget => TargetUserKey != null; public UserKey GetTargetUserKey() { - if (ResourceId is null) - throw new InvalidOperationException("Target user is not specified."); + if (TargetUserKey is not UserKey targetUserKey) + throw new InvalidOperationException("Target user is not found."); - return UserKey.Parse(ResourceId, null); + return targetUserKey; + } + + internal AccessContext( + UserKey? actorUserKey, + TenantKey actorTenant, + bool isAuthenticated, + bool isSystemActor, + string resource, + UserKey? targetUserKey, + TenantKey resourceTenant, + string action, + IReadOnlyDictionary attributes) + { + ActorUserKey = actorUserKey; + ActorTenant = actorTenant; + IsAuthenticated = isAuthenticated; + IsSystemActor = isSystemActor; + + Resource = resource; + TargetUserKey = targetUserKey; + ResourceTenant = resourceTenant; + + Action = action; + Attributes = attributes; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index 65eeadaf..0424ad16 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -1,10 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record AuthContext { + public UAuthClientProfile ClientProfile { get; set; } + public TenantKey Tenant { get; init; } public AuthOperation Operation { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs new file mode 100644 index 00000000..c53276d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LogoutReason +{ + Explicit, + SessionExpired, + SecurityPolicy, + AdminForced, + TenantDisabled +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs index 4a83eb93..070c1551 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -15,6 +15,7 @@ public sealed class AuthenticatedSessionContext public DateTimeOffset Now { get; init; } public ClaimsSnapshot? Claims { get; init; } public required SessionMetadata Metadata { get; init; } + public required UAuthMode Mode { get; init; } /// /// Optional chain identifier. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 223176fe..3be8b529 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -12,4 +12,5 @@ public sealed record SessionRotationContext public required DeviceContext Device { get; init; } public ClaimsSnapshot? Claims { get; init; } public required SessionMetadata Metadata { get; init; } = SessionMetadata.Empty; + public required UAuthMode Mode { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs index 3d6c99a0..f85e5c9a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs @@ -22,7 +22,7 @@ public HubFlowArtifact( string? returnUrl, HubFlowPayload payload, DateTimeOffset expiresAt) - : base(AuthArtifactType.HubFlow, expiresAt, maxAttempts: 1) + : base(AuthArtifactType.HubFlow, expiresAt) { HubSessionId = hubSessionId; FlowType = flowType; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs index a0c69a46..1210c167 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs @@ -2,26 +2,21 @@ public abstract class AuthArtifact { - protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt, int maxAttempts) + protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt) { Type = type; ExpiresAt = expiresAt; - MaxAttempts = maxAttempts; } public AuthArtifactType Type { get; } public DateTimeOffset ExpiresAt { get; internal set; } - public int MaxAttempts { get; } - public int AttemptCount { get; private set; } public bool IsCompleted { get; private set; } public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; - public bool CanAttempt() => AttemptCount < MaxAttempts; - public void RegisterAttempt() { AttemptCount++; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs index 02751dcb..be3f758b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs @@ -8,9 +8,8 @@ public sealed class HubLoginArtifact : AuthArtifact public HubLoginArtifact( string authorizationCode, string codeVerifier, - DateTimeOffset expiresAt, - int maxAttempts = 3) - : base(AuthArtifactType.HubLogin, expiresAt, maxAttempts) + DateTimeOffset expiresAt) + : base(AuthArtifactType.HubLogin, expiresAt) { AuthorizationCode = authorizationCode; CodeVerifier = codeVerifier; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs index 315337c0..44262fe4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -2,7 +2,7 @@ public enum ReauthBehavior { - RedirectToLogin, + Redirect, None, RaiseEvent } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs deleted file mode 100644 index 341acbdc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when a new authentication session is created. -/// -/// This event is published immediately after a successful login or initial session -/// creation within a session chain. It provides the essential identifiers required -/// for auditing, monitoring, analytics, and external integrations. -/// -/// Handlers should treat this event as notification-only; modifying session state -/// or performing security-critical actions is not recommended unless explicitly intended. -/// -public sealed class SessionCreatedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user for whom the new session was created. - /// - public TUserId UserId { get; } - - /// - /// Gets the unique identifier of the newly created session. - /// - public AuthSessionId SessionId { get; } - - /// - /// Gets the identifier of the session chain to which this session belongs. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp on which the session was created. - /// - public DateTimeOffset CreatedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - CreatedAt = createdAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs deleted file mode 100644 index d3d4b0f3..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs +++ /dev/null @@ -1,59 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when an authentication session is refreshed. -/// -/// This event occurs whenever a valid session performs a rotation — typically during -/// a refresh-token exchange or session renewal flow. The old session becomes inactive, -/// and a new session inherits updated expiration and security metadata. -/// -/// This event is primarily used for analytics, auditing, security monitoring, and -/// external workflow triggers (e.g., notifying users of new logins, updating dashboards, -/// or tracking device activity). -/// -public sealed class SessionRefreshedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user whose session was refreshed. - /// - public TUserId UserId { get; } - - /// - /// Gets the identifier of the session that was replaced during the refresh operation. - /// - public AuthSessionId OldSessionId { get; } - - /// - /// Gets the identifier of the newly created session that replaces the old session. - /// - public AuthSessionId NewSessionId { get; } - - /// - /// Gets the identifier of the session chain to which both sessions belong. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp at which the refresh occurred. - /// - public DateTimeOffset RefreshedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionRefreshedContext( - TUserId userId, - AuthSessionId oldSessionId, - AuthSessionId newSessionId, - SessionChainId chainId, - DateTimeOffset refreshedAt) - { - UserId = userId; - OldSessionId = oldSessionId; - NewSessionId = newSessionId; - ChainId = chainId; - RefreshedAt = refreshedAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs deleted file mode 100644 index 04f0040d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs +++ /dev/null @@ -1,55 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when an individual session is revoked. -/// -/// This event is triggered when a specific session is invalidated — either due to -/// explicit logout, administrator action, security enforcement, or anomaly detection. -/// Only the targeted session is revoked; other sessions in the same chain or root -/// may continue to remain active unless broader revocation policies apply. -/// -/// Typical use cases include: -/// - Auditing and compliance logs -/// - User notifications (e.g., “Your session on device X was logged out”) -/// - Security automations (SIEM integration, monitoring suspicious activity) -/// - Application workflows that must respond to session termination -/// -public sealed class SessionRevokedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user to whom the revoked session belongs. - /// - public TUserId UserId { get; } - - /// - /// Gets the identifier of the session that has been revoked. - /// - public AuthSessionId SessionId { get; } - - /// - /// Gets the identifier of the session chain containing the revoked session. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp at which the session revocation occurred. - /// - public DateTimeOffset RevokedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionRevokedContext( - TUserId userId, - AuthSessionId sessionId, - SessionChainId chainId, - DateTimeOffset revokedAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - RevokedAt = revokedAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs index c7d08746..a442a7d9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs @@ -16,27 +16,12 @@ public async Task DispatchAsync(IAuthEventContext context) switch (context) { - case SessionCreatedContext c: - if (_events.OnSessionCreated != null) - await SafeInvoke(() => _events.OnSessionCreated(c)); - break; - - case SessionRefreshedContext c: - if (_events.OnSessionRefreshed != null) - await SafeInvoke(() => _events.OnSessionRefreshed(c)); - break; - - case SessionRevokedContext c: - if (_events.OnSessionRevoked != null) - await SafeInvoke(() => _events.OnSessionRevoked(c)); - break; - - case UserLoggedInContext c: + case UserLoggedInContext c: if (_events.OnUserLoggedIn != null) await SafeInvoke(() => _events.OnUserLoggedIn(c)); break; - case UserLoggedOutContext c: + case UserLoggedOutContext c: if (_events.OnUserLoggedOut != null) await SafeInvoke(() => _events.OnUserLoggedOut(c)); break; @@ -48,5 +33,4 @@ private static async Task SafeInvoke(Func func) try { await func(); } catch { /* swallow → event hook must not break auth flow */ } } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs index fadd610d..9162c9fb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Events; /// /// Provides an optional, application-wide event hook system for UltimateAuth. @@ -28,29 +30,21 @@ public class UAuthEvents /// public Func? OnAnyEvent { get; set; } - /// - /// Fired when a new session is created (login or device bootstrap). - /// - public Func, Task>? OnSessionCreated { get; set; } - - /// - /// Fired when an existing session is refreshed and rotated. - /// - public Func, Task>? OnSessionRefreshed { get; set; } - - /// - /// Fired when a specific session is revoked. - /// - public Func, Task>? OnSessionRevoked { get; set; } - /// /// Fired when a user successfully completes the login process. /// Note: separate from SessionCreated; this is a higher-level event. /// - public Func, Task>? OnUserLoggedIn { get; set; } + public Func? OnUserLoggedIn { get; set; } /// /// Fired when a user logs out or all sessions for the user are revoked. /// - public Func, Task>? OnUserLoggedOut { get; set; } + public Func? OnUserLoggedOut { get; set; } + + internal UAuthEvents Clone() => new() + { + OnAnyEvent = OnAnyEvent, + OnUserLoggedIn = OnUserLoggedIn, + OnUserLoggedOut = OnUserLoggedOut + }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs index 54ef844b..e882818a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -1,4 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Events; /// /// Represents contextual data emitted when a user successfully completes the login process. @@ -14,28 +17,30 @@ /// - integrating with SIEM or monitoring systems /// /// NOTE: -/// This event is distinct from . +/// This event is distinct from session create. /// A user may log in without creating a new session (e.g., external SSO), /// or multiple sessions may be created after a single login depending on client application flows. /// -public sealed class UserLoggedInContext : IAuthEventContext +public sealed class UserLoggedInContext : IAuthEventContext { - /// - /// Gets the identifier of the user who has logged in. - /// - public TUserId UserId { get; } - - /// - /// Gets the timestamp at which the login event occurred. - /// + public TenantKey Tenant { get; } + public UserKey UserKey { get; } public DateTimeOffset LoggedInAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedInContext(TUserId userId, DateTimeOffset at) + public DeviceContext? Device { get; } + public AuthSessionId? SessionId { get; } + + public UserLoggedInContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + DeviceContext? device = null, + AuthSessionId? sessionId = null) { - UserId = userId; + Tenant = tenant; + UserKey = userKey; LoggedInAt = at; + Device = device; + SessionId = sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs index 85278192..1d7f46ac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -1,14 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Events; /// /// Represents contextual data emitted when a user logs out of the system. /// -/// This event is triggered when a logout operation is executed — either by explicit -/// user action, automatic revocation, administrative force-logout, or tenant-level -/// security policies. +/// This event is triggered when a logout operation is executed — either by explicit user action, automatic revocation, +/// administrative force-logout, or tenant-level security policies. /// -/// Unlike , which targets a specific -/// session, this event reflects a higher-level “user has logged out” state and may +/// Unlike session revoke which targets a specific session, this event reflects a higher-level “user has logged out” state and may /// represent logout from a single session or all sessions depending on the workflow. /// /// Typical use cases include: @@ -17,24 +19,26 @@ /// - triggering notifications (e.g., “You have logged out from device X”) /// - integrating with analytics or SIEM systems /// -public sealed class UserLoggedOutContext : IAuthEventContext +public sealed class UserLoggedOutContext : IAuthEventContext { - /// - /// Gets the identifier of the user who has logged out. - /// - public TUserId UserId { get; } - - /// - /// Gets the timestamp at which the logout occurred. - /// + public TenantKey Tenant { get; } + public UserKey UserKey { get; } public DateTimeOffset LoggedOutAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedOutContext(TUserId userId, DateTimeOffset at) + public AuthSessionId? SessionId { get; } + public LogoutReason Reason { get; } + + public UserLoggedOutContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + LogoutReason reason, + AuthSessionId? sessionId = null) { - UserId = userId; + Tenant = tenant; + UserKey = userKey; LoggedOutAt = at; + Reason = reason; + SessionId = sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index d54703ec..ffc2f0cb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -19,6 +19,25 @@ public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, st return new ClaimsPrincipal(identity); } + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, UserKey? userKey, string authenticationType) + { + if (snapshot == null) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(v => new Claim(kv.Key, v))).ToList(); + + if (userKey is not null) + { + var value = userKey.Value.ToString(); + claims.Add(new Claim(ClaimTypes.Name, value)); + claims.Add(new Claim(ClaimTypes.NameIdentifier, value)); + } + + var identity = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(identity); + } + + /// /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs index 17204ca2..ea8e2fea 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs @@ -9,53 +9,42 @@ namespace CodeBeam.UltimateAuth.Core.Extensions; -// TODO: Check it before stable release /// -/// Provides extension methods for registering UltimateAuth core services into -/// the application's dependency injection container. +/// Provides extension methods for registering UltimateAuth core services into the application's dependency injection container. /// -/// These methods configure options, validators, converters, and factories required -/// for the authentication subsystem. +/// These methods configure options, validators, converters, and factories required for the authentication subsystem. /// /// IMPORTANT: -/// This extension registers only CORE services — session stores, token factories, -/// PKCE handlers, and any server-specific logic must be added from the Server package -/// (e.g., AddUltimateAuthServer()). +/// This extension registers only CORE services — session stores, token factories, PKCE handlers, and any server-specific +/// logic must be added from the Server package (e.g., AddUltimateAuthServer()). /// public static class ServiceCollectionExtensions { - /// - /// Registers UltimateAuth services using configuration binding (e.g., appsettings.json). - /// - /// The provided configuration section must contain valid UltimateAuthOptions and nested - /// Session, Token, PKCE, and MultiTenant configuration sections. Validation occurs - /// at application startup via IValidateOptions. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action? configure = null) { - services.Configure(configurationSection); - return services.AddUltimateAuthInternal(); - } + ArgumentNullException.ThrowIfNull(services); - /// - /// Registers UltimateAuth services using programmatic configuration. - /// This is useful when settings are derived dynamically or are not stored - /// in appsettings.json. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthInternal(); - } + var optionsBuilder = services.AddOptions(); + + if (configure is not null) + { + optionsBuilder.Configure((options, marker) => + { + marker.MarkConfigured(); + configure(options); + }); + } + + optionsBuilder.BindConfiguration("UltimateAuth:Core"); + + services.TryAddSingleton>(sp => + { + var marker = sp.GetRequiredService(); + var config = sp.GetService(); + + return new CoreConfigurationIntentDetector(marker, config); + }); - /// - /// Registers UltimateAuth services using default empty configuration. - /// Intended for advanced or fully manual scenarios where options will be - /// configured later or overridden by the server layer. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services) - { - services.Configure(_ => { }); return services.AddUltimateAuthInternal(); } @@ -65,24 +54,24 @@ public static IServiceCollection AddUltimateAuth(this IServiceCollection service /// Core-level invariant validation. /// Server layer may add additional validators. /// NOTE: - /// This method does NOT register session stores or server-side services. + /// This method does not register session stores or server-side services. /// A server project must explicitly call: - /// - /// services.AddUltimateAuthSessionStore'TStore'(); - /// + /// "services.AddUltimateAuthSessionStore'TStore'();" /// to provide a concrete ISessionStore implementation. /// private static IServiceCollection AddUltimateAuthInternal(this IServiceCollection services) { + services.TryAddSingleton(); + services.AddSingleton, UAuthOptionsPostConfigureGuard>(); + + services.AddSingleton, UAuthOptionsValidator>(); services.AddSingleton, UAuthSessionOptionsValidator>(); services.AddSingleton, UAuthTokenOptionsValidator>(); + services.AddSingleton, UAuthLoginOptionsValidator>(); services.AddSingleton, UAuthPkceOptionsValidator>(); services.AddSingleton, UAuthMultiTenantOptionsValidator>(); - // Nested options are bound automatically by the options binder. - // Server layer may override or extend these settings. - services.AddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs new file mode 100644 index 00000000..d854cf74 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DeviceRequiredInvariant : IAuthorityInvariant +{ + public AccessDecisionResult Decide(AuthContext context) + { + if (!RequiresDevice(context)) + return AccessDecisionResult.Allow(); + + if (!context.Device.HasDeviceId) + return AccessDecisionResult.Deny("DeviceId is required for this operation."); + + return AccessDecisionResult.Allow(); + } + + private static bool RequiresDevice(AuthContext context) + { + if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) + { + return context.ClientProfile switch + { + UAuthClientProfile.BlazorWasm => true, + UAuthClientProfile.BlazorServer => true, + UAuthClientProfile.Maui => true, + _ => false // service, system, internal + }; + } + + return false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs index b9498b81..7f8b6be3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs @@ -3,14 +3,13 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; -public sealed class DevicePresenceInvariant : IAuthorityInvariant +public sealed class TenantResolvedInvariant : IAuthorityInvariant { public AccessDecisionResult Decide(AuthContext context) { - if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) + if (context.Tenant.IsUnresolved) { - if (context.Device is null) - return AccessDecisionResult.Deny("Device information is required."); + return AccessDecisionResult.Deny("Tenant is not resolved."); } return AccessDecisionResult.Allow(); diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs index db6570c5..0a4da433 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; +// NOTE: +// UserKey is the canonical domain identity type. +// JSON serialization/deserialization is intentionally direct and does not use IUserIdConverterResolver. public sealed class UserKeyJsonConverter : JsonConverter { public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs index c04cafce..820f43ca 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -1,22 +1,7 @@ namespace CodeBeam.UltimateAuth.Core.MultiTenancy; -/// -/// Resolves the tenant id from the request path. -/// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. -/// public sealed class PathTenantResolver : ITenantIdResolver { - private readonly string _prefix; - - /// - /// Creates a resolver that looks for tenant ids under a specific URL prefix. - /// Default prefix is "t", meaning URLs like /t/foo/api will resolve "foo". - /// - public PathTenantResolver(string prefix = "t") - { - _prefix = prefix; - } - /// /// Extracts the tenant id from the request path, if present. /// Returns null when the prefix is not matched or the path is insufficient. @@ -29,11 +14,10 @@ public PathTenantResolver(string prefix = "t") var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - // Format: /{prefix}/{tenantId}/... - if (segments.Length >= 2 && segments[0] == _prefix) - return Task.FromResult(segments[1]); + // Format: /{tenant}/... + if (segments.Length >= 1) + return Task.FromResult(segments[0]); return Task.FromResult(null); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs new file mode 100644 index 00000000..3a8e1265 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class CoreConfigurationIntentDetector : IPostConfigureOptions +{ + private readonly DirectCoreConfigurationMarker _marker; + private readonly IConfiguration? _configuration; + + public CoreConfigurationIntentDetector(DirectCoreConfigurationMarker marker, IConfiguration? configuration) + { + _marker = marker; + _configuration = configuration; + } + + public void PostConfigure(string? name, UAuthOptions options) + { + if (_configuration is null) + return; + + var coreSection = _configuration.GetSection("UltimateAuth:Core"); + if (coreSection.Exists()) + { + _marker.MarkConfigured(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 0912e65a..60621b08 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -1,20 +1,26 @@ -namespace CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Options; /// -/// Configuration settings related to interactive user login behavior, -/// including lockout policies and failed-attempt thresholds. +/// Configuration settings related to interactive user login behavior, including lockout policies and failed-attempt thresholds. /// public sealed class UAuthLoginOptions { /// - /// Maximum number of consecutive failed login attempts allowed - /// before the user is temporarily locked out. + /// Maximum number of consecutive failed login attempts allowed before the user is locked out. + /// Set to 0 to disable lockout entirely. /// - public int MaxFailedAttempts { get; set; } = 5; + public int MaxFailedAttempts { get; set; } = 10; /// - /// Duration (in minutes) for which the user is locked out - /// after exceeding . + /// Duration (in minutes) for which the user is locked out after exceeding . /// public int LockoutMinutes { get; set; } = 15; + + internal UAuthLoginOptions Clone() => new() + { + MaxFailedAttempts = MaxFailedAttempts, + LockoutMinutes = LockoutMinutes + }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs index 2d76b691..ee0b0409 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -1,5 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Options; +// TODO: Add Tenant registration /// /// Multi-tenancy configuration for UltimateAuth. /// Controls whether tenants are required, how they are resolved, @@ -13,18 +14,11 @@ public sealed class UAuthMultiTenantOptions /// public bool Enabled { get; set; } = false; - /// - /// If true, tenant resolution MUST succeed for external requests. - /// If false, unresolved tenants fall back to single-tenant behavior. - /// - public bool RequireTenant { get; set; } = false; - - /// - /// If true, a tenant id returned by resolver does NOT need to be known beforehand. - /// If false, unknown tenants must be explicitly registered. - /// (Useful for multi-tenant SaaS with dynamic tenant provisioning) - /// - public bool AllowUnknownTenants { get; set; } = true; + ///// + ///// If true, tenant resolution MUST succeed for external requests. + ///// If false, unresolved tenants fall back to single-tenant behavior. + ///// + //public bool RequireTenant { get; set; } = false; /// /// If true, tenant identifiers are normalized to lowercase. @@ -32,12 +26,11 @@ public sealed class UAuthMultiTenantOptions /// public bool NormalizeToLowercase { get; set; } = true; - /// /// Enables tenant resolution from the URL path and /// exposes auth endpoints under /{tenant}/{routePrefix}/... /// - public bool EnableRoute { get; set; } = true; + public bool EnableRoute { get; set; } = false; public bool EnableHeader { get; set; } = false; public bool EnableDomain { get; set; } = false; @@ -47,13 +40,10 @@ public sealed class UAuthMultiTenantOptions internal UAuthMultiTenantOptions Clone() => new() { Enabled = Enabled, - RequireTenant = RequireTenant, - AllowUnknownTenants = AllowUnknownTenants, NormalizeToLowercase = NormalizeToLowercase, EnableRoute = EnableRoute, EnableHeader = EnableHeader, EnableDomain = EnableDomain, HeaderName = HeaderName }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs deleted file mode 100644 index ec416dfc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.Options; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Options; - -internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) - { - if (!options.Enabled) - { - if (options.RequireTenant) - { - return ValidateOptionsResult.Fail("RequireTenant cannot be true when multi-tenancy is disabled."); - } - - return ValidateOptionsResult.Success; - } - - if (!options.EnableRoute && - !options.EnableHeader && - !options.EnableDomain) - { - return ValidateOptionsResult.Fail( - "Multi-tenancy is enabled but no tenant resolver is active " + - "(route, header, or domain)."); - } - - return ValidateOptionsResult.Success; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index a65a1d51..ac273a50 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -1,18 +1,17 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Events; namespace CodeBeam.UltimateAuth.Core.Options; /// /// Top-level configuration container for all UltimateAuth features. -/// Combines login policies, session lifecycle rules, token behavior, -/// PKCE settings, multi-tenancy behavior, and user-id normalization. +/// Combines login policies, session lifecycle rules, token behavior, PKCE settings, multi-tenancy behavior, and user-id normalization. /// -/// All sub-options are resolved from configuration (appsettings.json) -/// or through inline setup in AddUltimateAuth(). +/// All sub-options are resolved from configuration (appsettings.json) or through inline setup in AddUltimateAuth(). /// public sealed class UAuthOptions { + public bool AllowDirectCoreConfiguration { get; set; } = false; + /// /// Configuration settings for interactive login flows, /// including lockout thresholds and failed-attempt policies. @@ -41,20 +40,11 @@ public sealed class UAuthOptions /// Event hooks raised during authentication lifecycle events /// such as login, logout, session creation, refresh, or revocation. /// - public UAuthEvents UAuthEvents { get; set; } = new(); + public UAuthEvents Events { get; set; } = new(); /// /// Multi-tenancy configuration controlling how tenants are resolved, /// validated, and optionally enforced. /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); - - /// - /// Provides converters used to normalize and serialize TUserId - /// across the system (sessions, stores, tokens, logging). - /// - public IUserIdConverterResolver? UserIdConverters { get; set; } - - public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; - public bool AutoDetectClientProfile { get; set; } = true; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs new file mode 100644 index 00000000..c6951f8d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthOptionsPostConfigureGuard : IPostConfigureOptions +{ + private readonly DirectCoreConfigurationMarker _directConfigMarker; + private readonly IEnumerable _runtimeMarkers; + + public UAuthOptionsPostConfigureGuard(DirectCoreConfigurationMarker directConfigMarker, IEnumerable runtimeMarkers) + { + _directConfigMarker = directConfigMarker; + _runtimeMarkers = runtimeMarkers; + } + + public void PostConfigure(string? name, UAuthOptions options) + { + var hasServerRuntime = _runtimeMarkers.Any(); + + if (!_directConfigMarker.IsConfigured) + { + return; + } + + if (hasServerRuntime) + { + throw new InvalidOperationException("Direct core configuration is not allowed in server-hosted applications. " + + "Configure authentication policies via AddUltimateAuthServer instead."); + } + + if (!options.AllowDirectCoreConfiguration) + { + throw new InvalidOperationException("Direct core configuration is not allowed. " + + "Set AllowDirectCoreConfiguration = true only for advanced, non-server scenarios."); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs index ab13f894..6549acc4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -14,12 +14,8 @@ public sealed class UAuthPkceOptions /// public int AuthorizationCodeLifetimeSeconds { get; set; } = 120; - public int MaxVerificationAttempts { get; set; } = 5; - internal UAuthPkceOptions Clone() => new() { - AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds, - MaxVerificationAttempts = MaxVerificationAttempts, + AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 35fd2631..007cad76 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -4,14 +4,13 @@ namespace CodeBeam.UltimateAuth.Core.Options; // TODO: Add rotate on refresh (especially for Hybrid). Default behavior should be single session in chain for Hybrid, but can be configured. // And add RotateAsync method. +// Implement all options. /// -/// Defines configuration settings that control the lifecycle, -/// security behavior, and device constraints of UltimateAuth +/// Defines configuration settings that control the lifecycle, security behavior, and device constraints of UltimateAuth /// session management. /// -/// These values influence how sessions are created, refreshed, -/// expired, revoked, and grouped into device chains. +/// These values influence how sessions are created, refreshed, expired, revoked, and grouped into device chains. /// public sealed class UAuthSessionOptions { @@ -50,45 +49,78 @@ public sealed class UAuthSessionOptions /// /// Maximum number of device session chains a single user may have. /// Set to zero to indicate no user-level chain limit. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public int MaxChainsPerUser { get; set; } = 0; /// /// Maximum number of session rotations within a single chain. /// Used for cleanup, replay protection, and analytics. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public int MaxSessionsPerChain { get; set; } = 100; /// /// Optional limit on the number of session chains allowed per platform /// (e.g. "web" = 1, "mobile" = 1). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public Dictionary? MaxChainsPerPlatform { get; set; } /// /// Defines platform categories that map multiple platforms /// into a single abstract group (e.g. mobile: [ "ios", "android", "tablet" ]). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public Dictionary? PlatformCategories { get; set; } /// /// Limits how many session chains can exist per platform category /// (e.g. mobile = 1, desktop = 2). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public Dictionary? MaxChainsPerCategory { get; set; } /// /// Enables binding sessions to the user's IP address. /// When enabled, IP mismatches can invalidate a session. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public bool EnableIpBinding { get; set; } = false; /// /// Enables binding sessions to the user's User-Agent header. /// When enabled, UA mismatches can invalidate a session. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// public bool EnableUserAgentBinding { get; set; } = false; + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// public DeviceMismatchBehavior DeviceMismatchBehavior { get; set; } = DeviceMismatchBehavior.Reject; internal UAuthSessionOptions Clone() => new() @@ -107,5 +139,4 @@ public sealed class UAuthSessionOptions EnableIpBinding = EnableIpBinding, EnableUserAgentBinding = EnableUserAgentBinding }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs deleted file mode 100644 index c757772b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Core.Options; - -internal sealed class UAuthSessionOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) - { - var errors = new List(); - - if (options.Lifetime <= TimeSpan.Zero) - errors.Add("Session.Lifetime must be greater than zero."); - - if (options.MaxLifetime < options.Lifetime) - errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); - - if (options.IdleTimeout.HasValue && options.IdleTimeout < TimeSpan.Zero) - errors.Add("Session.IdleTimeout cannot be negative."); - - if (options.IdleTimeout.HasValue && - options.IdleTimeout > TimeSpan.Zero && - options.IdleTimeout > options.MaxLifetime) - { - errors.Add("Session.IdleTimeout cannot exceed Session.MaxLifetime."); - } - - if (options.MaxChainsPerUser <= 0) - errors.Add("Session.MaxChainsPerUser must be at least 1."); - - if (options.MaxSessionsPerChain <= 0) - errors.Add("Session.MaxSessionsPerChain must be at least 1."); - - if (options.MaxChainsPerPlatform != null) - { - foreach (var kv in options.MaxChainsPerPlatform) - { - if (string.IsNullOrWhiteSpace(kv.Key)) - errors.Add("Session.MaxChainsPerPlatform contains an empty platform key."); - - if (kv.Value <= 0) - errors.Add($"Session.MaxChainsPerPlatform['{kv.Key}'] must be >= 1."); - } - } - - if (options.PlatformCategories != null) - { - foreach (var cat in options.PlatformCategories) - { - var categoryName = cat.Key; - var platforms = cat.Value; - - if (string.IsNullOrWhiteSpace(categoryName)) - errors.Add("Session.PlatformCategories contains an empty category name."); - - if (platforms == null || platforms.Length == 0) - errors.Add($"Session.PlatformCategories['{categoryName}'] must contain at least one platform."); - - var duplicates = platforms? - .GroupBy(p => p) - .Where(g => g.Count() > 1) - .Select(g => g.Key); - if (duplicates?.Any() == true) - { - errors.Add($"Session.PlatformCategories['{categoryName}'] contains duplicate platforms: {string.Join(", ", duplicates)}"); - } - } - } - - if (options.MaxChainsPerCategory != null) - { - foreach (var kv in options.MaxChainsPerCategory) - { - if (string.IsNullOrWhiteSpace(kv.Key)) - errors.Add("Session.MaxChainsPerCategory contains an empty category key."); - - if (kv.Value <= 0) - errors.Add($"Session.MaxChainsPerCategory['{kv.Key}'] must be >= 1."); - } - } - - if (options.PlatformCategories != null && options.MaxChainsPerCategory != null) - { - foreach (var category in options.PlatformCategories.Keys) - { - if (!options.MaxChainsPerCategory.ContainsKey(category)) - { - errors.Add( - $"Session.MaxChainsPerCategory must define a limit for category '{category}' " + - "because it exists in Session.PlatformCategories."); - } - } - } - - if (errors.Count == 0) - return ValidateOptionsResult.Success; - - return ValidateOptionsResult.Fail(errors); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 9afd48d0..7d9aeacd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -75,5 +75,4 @@ public sealed class UAuthTokenOptions AddJwtIdClaim = AddJwtIdClaim, KeyId = KeyId }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs new file mode 100644 index 00000000..f4cb1f97 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthLoginOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthLoginOptions options) + { + var errors = new List(); + + if (options.MaxFailedAttempts < 0) + errors.Add("Login.MaxFailedAttempts cannot be negative."); + + if (options.MaxFailedAttempts > 100) + errors.Add("Login.MaxFailedAttempts cannot exceed 100. Use 0 to disable lockout."); + + if (options.MaxFailedAttempts > 0 && options.LockoutMinutes <= 0) + errors.Add("Login.LockoutMinutes must be greater than zero when lockout is enabled."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs new file mode 100644 index 00000000..762564d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) + { + var errors = new List(); + + if (!options.Enabled) + { + if (options.EnableRoute || options.EnableHeader || options.EnableDomain) + { + errors.Add("Multi-tenancy is disabled, but one or more tenant resolvers are enabled. " + + "Either enable multi-tenancy or disable all tenant resolvers."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + if (!options.EnableRoute && !options.EnableHeader && !options.EnableDomain) + { + errors.Add("Multi-tenancy is enabled but no tenant resolver is active. " + + "Enable at least one of: route, header or domain."); + } + + if (options.EnableHeader) + { + if (string.IsNullOrWhiteSpace(options.HeaderName)) + { + errors.Add("MultiTenant.HeaderName must be specified when header-based tenant resolution is enabled."); + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs index aa7dff30..12119bb6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs @@ -21,6 +21,12 @@ public ValidateOptionsResult Validate(string? name, UAuthOptions options) if (options.Pkce is null) errors.Add("UltimateAuth.Pkce configuration section is missing."); + if (options.Events is null) + errors.Add("UltimateAuth.Events configuration section is missing."); + + if (options.MultiTenant is null) + errors.Add("UltimateAuth.MultiTenant configuration section is missing."); + if (errors.Count > 0) return ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs new file mode 100644 index 00000000..657def36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthSessionOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) + { + var errors = new List(); + + if (options.Lifetime <= TimeSpan.Zero) + errors.Add("Session.Lifetime must be greater than zero."); + + if (options.MaxLifetime < options.Lifetime) + errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); + + if (options.IdleTimeout.HasValue && options.IdleTimeout < TimeSpan.Zero) + errors.Add("Session.IdleTimeout cannot be negative."); + + if (options.IdleTimeout.HasValue && options.IdleTimeout > TimeSpan.Zero && options.IdleTimeout > options.MaxLifetime) + { + errors.Add("Session.IdleTimeout cannot exceed Session.MaxLifetime."); + } + + //if (options.MaxChainsPerUser <= 0) + // errors.Add("Session.MaxChainsPerUser must be at least 1."); + + //if (options.MaxSessionsPerChain <= 0) + // errors.Add("Session.MaxSessionsPerChain must be at least 1."); + + //if (options.MaxChainsPerPlatform != null) + //{ + // foreach (var kv in options.MaxChainsPerPlatform) + // { + // if (string.IsNullOrWhiteSpace(kv.Key)) + // errors.Add("Session.MaxChainsPerPlatform contains an empty platform key."); + + // if (kv.Value <= 0) + // errors.Add($"Session.MaxChainsPerPlatform['{kv.Key}'] must be >= 1."); + // } + //} + + //if (options.PlatformCategories != null) + //{ + // foreach (var cat in options.PlatformCategories) + // { + // var categoryName = cat.Key; + // var platforms = cat.Value; + + // if (string.IsNullOrWhiteSpace(categoryName)) + // errors.Add("Session.PlatformCategories contains an empty category name."); + + // if (platforms == null || platforms.Length == 0) + // errors.Add($"Session.PlatformCategories['{categoryName}'] must contain at least one platform."); + + // var duplicates = platforms? + // .GroupBy(p => p) + // .Where(g => g.Count() > 1) + // .Select(g => g.Key); + // if (duplicates?.Any() == true) + // { + // errors.Add($"Session.PlatformCategories['{categoryName}'] contains duplicate platforms: {string.Join(", ", duplicates)}"); + // } + // } + //} + + //if (options.MaxChainsPerCategory != null) + //{ + // foreach (var kv in options.MaxChainsPerCategory) + // { + // if (string.IsNullOrWhiteSpace(kv.Key)) + // errors.Add("Session.MaxChainsPerCategory contains an empty category key."); + + // if (kv.Value <= 0) + // errors.Add($"Session.MaxChainsPerCategory['{kv.Key}'] must be >= 1."); + // } + //} + + //if (options.PlatformCategories != null && options.MaxChainsPerCategory != null) + //{ + // foreach (var category in options.PlatformCategories.Keys) + // { + // if (!options.MaxChainsPerCategory.ContainsKey(category)) + // { + // errors.Add( + // $"Session.MaxChainsPerCategory must define a limit for category '{category}' " + + // "because it exists in Session.PlatformCategories."); + // } + // } + //} + + if (errors.Count == 0) + return ValidateOptionsResult.Success; + + return ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs similarity index 50% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 7d374cb0..33881151 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -14,33 +14,33 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) if (options.AccessTokenLifetime <= TimeSpan.Zero) errors.Add("Token.AccessTokenLifetime must be greater than zero."); - if (options.RefreshTokenLifetime <= TimeSpan.Zero) - errors.Add("Token.RefreshTokenLifetime must be greater than zero."); + if (options.IssueRefresh) + { + if (options.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero when IssueRefresh is enabled."); - if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) - errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + } if (options.IssueJwt) { - if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars - errors.Add("Token.Issuer must not be empty when IssueJwt = true."); + if (string.IsNullOrWhiteSpace(options.Issuer) || options.Issuer.Trim().Length < 3) + errors.Add("Token.Issuer must be at least 3 characters when IssueJwt is enabled."); - if (string.IsNullOrWhiteSpace(options.Audience)) - errors.Add("Token.Audience must not be empty when IssueJwt = true."); + if (string.IsNullOrWhiteSpace(options.Audience) || options.Audience.Trim().Length < 3) + errors.Add("Token.Audience must be at least 3 characters when IssueJwt is enabled."); } if (options.IssueOpaque) { if (options.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 (128-bit entropy)."); - } + errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); - if (options.IssueRefresh && options.RefreshTokenLifetime <= TimeSpan.Zero) - { - errors.Add("RefreshTokenLifetime must be set when IssueRefresh is enabled."); + if (options.OpaqueIdBytes > 128) + errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); } - return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs new file mode 100644 index 00000000..c65c789d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Internal marker indicating that UAuthOptions +/// were configured directly by the application. +/// +internal sealed class DirectCoreConfigurationMarker +{ + public bool IsConfigured { get; private set; } + + public void MarkConfigured() + { + IsConfigured = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs new file mode 100644 index 00000000..9e90c8d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Marker interface indicating that UltimateAuth is running in a specific runtime context (e.g. server-hosted). +/// Implementations must be provided by integration layers such as UltimateAuth.Server. +/// +public interface IUAuthRuntimeMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs index 629c25a9..7e4b3841 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Core.Runtime; +namespace CodeBeam.UltimateAuth.Core.Runtime; public sealed class UAuthProductInfo { @@ -8,9 +6,6 @@ public sealed class UAuthProductInfo public string Version { get; init; } = default!; public string? InformationalVersion { get; init; } - public UAuthClientProfile ClientProfile { get; init; } - public bool ClientProfileAutoDetected { get; init; } - public DateTimeOffset StartedAt { get; init; } public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs index 90de44f8..99f027c2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs @@ -16,9 +16,6 @@ public UAuthProductInfoProvider(IOptions options) { Version = asm.GetName().Version?.ToString(3) ?? "unknown", InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, - - ClientProfile = options.Value.ClientProfile, - ClientProfileAutoDetected = options.Value.AutoDetectClientProfile, StartedAt = DateTimeOffset.UtcNow }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index 484c38f5..cd5c4e55 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.ObjectModel; @@ -8,10 +10,12 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class AccessContextFactory : IAccessContextFactory { private readonly IUserRoleStore _roleStore; + private readonly IUserIdConverterResolver _converterResolver; - public AccessContextFactory(IUserRoleStore roleStore) + public AccessContextFactory(IUserRoleStore roleStore, IUserIdConverterResolver converterResolver) { _roleStore = roleStore; + _converterResolver = converterResolver; } public async Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default) @@ -45,22 +49,31 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, attrs["roles"] = roles; } - return new AccessContext + UserKey? targetUserKey = null; + + if (!string.IsNullOrWhiteSpace(resourceId)) { - ActorUserKey = authFlow.UserKey, - ActorTenant = authFlow.Tenant, - IsAuthenticated = authFlow.IsAuthenticated, - IsSystemActor = authFlow.Tenant.IsSystem, + var converter = _converterResolver.GetConverter(null); + + if (!converter.TryFromString(resourceId, out var parsed)) + throw new InvalidOperationException("Invalid resource user id."); - Resource = resource, - ResourceId = resourceId, - ResourceTenant = resourceTenant, + var canonical = converter.ToCanonicalString(parsed); + targetUserKey = UserKey.FromString(canonical); + } - Action = action, - Attributes = attrs.Count > 0 + return new AccessContext( + actorUserKey: authFlow.UserKey, + actorTenant: authFlow.Tenant, + isAuthenticated: authFlow.IsAuthenticated, + isSystemActor: authFlow.Tenant.IsSystem, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: resourceTenant, + action: action, + attributes: attrs.Count > 0 ? new ReadOnlyDictionary(attrs) : EmptyAttributes.Instance - }; + ); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index f68bee45..50bd4e05 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; namespace CodeBeam.UltimateAuth.Server.Auth; @@ -24,6 +25,8 @@ public sealed class AuthFlowContext public EffectiveUAuthServerOptions EffectiveOptions { get; } public EffectiveAuthResponse Response { get; } + public ReturnUrlInfo ReturnUrlInfo { get; } + public PrimaryTokenKind PrimaryTokenKind { get; } // Helpers @@ -46,7 +49,8 @@ internal AuthFlowContext( UAuthServerOptions originalOptions, EffectiveUAuthServerOptions effectiveOptions, EffectiveAuthResponse response, - PrimaryTokenKind primaryTokenKind) + PrimaryTokenKind primaryTokenKind, + ReturnUrlInfo returnUrlInfo) { if (tenantKey.IsUnresolved) throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); @@ -66,5 +70,7 @@ internal AuthFlowContext( Response = response; PrimaryTokenKind = primaryTokenKind; + + ReturnUrlInfo = returnUrlInfo; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 3521182d..bbc26679 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -50,13 +51,20 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var originalOptions = _serverOptionsProvider.GetOriginal(ctx); var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); + var allowedModes = originalOptions.AllowedModes; + + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + { + throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + } + var effectiveMode = effectiveOptions.Mode; var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); - var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); - var deviceInfo = _deviceResolver.Resolve(ctx); var deviceContext = _deviceContextFactory.Create(deviceInfo); + var returnUrl = ctx.GetReturnUrl(); + var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); SessionSecurityContext? sessionSecurityContext = null; @@ -93,8 +101,48 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp originalOptions, effectiveOptions, response, - primaryTokenKind + primaryTokenKind, + returnUrlInfo ); } + public async ValueTask RecreateWithClientProfileAsync(AuthFlowContext existing, UAuthClientProfile overriddenProfile, CancellationToken ct = default) + { + var flowType = existing.FlowType; + var tenant = existing.Tenant; + + var originalOptions = existing.OriginalOptions; + var effectiveOptions = _serverOptionsProvider.GetEffective(tenant, flowType, overriddenProfile); + + var allowedModes = originalOptions.AllowedModes; + + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + { + throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + } + + var effectiveMode = effectiveOptions.Mode; + var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, overriddenProfile, effectiveOptions); + + var returnUrlInfo = existing.ReturnUrlInfo; + var deviceContext = existing.Device; + var session = existing.Session; + + return new AuthFlowContext( + flowType, + overriddenProfile, + effectiveMode, + deviceContext, + tenant, + existing.IsAuthenticated, + existing.UserKey, + session, + originalOptions, + effectiveOptions, + response, + primaryTokenKind, + returnUrlInfo + ); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs index 2aff1465..d1679c40 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Auth; @@ -6,4 +7,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth; public interface IAuthFlowContextFactory { ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default); + ValueTask RecreateWithClientProfileAsync(AuthFlowContext existing, UAuthClientProfile overriddenProfile, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs index 5f93b4c0..4d3052ca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs @@ -1,6 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -23,13 +25,18 @@ public UAuthServerOptions GetOriginal(HttpContext context) } public EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile) + { + var tenant = context.GetTenant(); + return GetEffective(tenant, flowType, clientProfile); + } + + public EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile) { var original = _baseOptions.Value; - var effectiveMode = _modeResolver.Resolve(original.Mode, clientProfile, flowType); + var effectiveMode = _modeResolver.Resolve(clientProfile, flowType); var options = original.Clone(); - options.Mode = effectiveMode; - ConfigureDefaults.ApplyModeDefaults(options); + ConfigureDefaults.ApplyModeDefaults(effectiveMode, options); if (original.ModeConfigurations.TryGetValue(effectiveMode, out var configure)) { @@ -42,5 +49,4 @@ public EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowTyp Options = options }; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs index 22cf6f84..f17a63a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -12,5 +12,5 @@ public sealed class EffectiveUAuthServerOptions /// public UAuthServerOptions Options { get; init; } = default!; - public AuthResponseOptions AuthResponse => Options.AuthResponse; + public UAuthResponseOptions AuthResponse => Options.AuthResponse; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs index 8eae3aba..cc3da3d8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class AuthResponseOptionsModeTemplateResolver { - public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) + public UAuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) { return mode switch { @@ -20,7 +20,7 @@ public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) }; } - private static AuthResponseOptions PureOpaque(AuthFlowType flow) + private static UAuthResponseOptions PureOpaque(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -44,11 +44,19 @@ private static AuthResponseOptions PureOpaque(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions Hybrid(AuthFlowType flow) + private static UAuthResponseOptions Hybrid(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -72,11 +80,19 @@ private static AuthResponseOptions Hybrid(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions SemiHybrid(AuthFlowType flow) + private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -100,11 +116,19 @@ private static AuthResponseOptions SemiHybrid(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions PureJwt(AuthFlowType flow) + private static UAuthResponseOptions PureJwt(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -128,7 +152,15 @@ private static AuthResponseOptions PureJwt(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 8306b0e3..8ae5fcbd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -25,31 +25,19 @@ public EffectiveAuthResponse Resolve(UAuthMode effectiveMode, AuthFlowType flowT // TODO: This is currently implicit Validate(bound); + var redirect = ResolveRedirect(flowType, bound); + return new EffectiveAuthResponse( bound.SessionIdDelivery, bound.AccessTokenDelivery, bound.RefreshTokenDelivery, - - new EffectiveLoginRedirectResponse( - bound.Login.RedirectEnabled, - bound.Login.SuccessRedirect, - bound.Login.FailureRedirect, - bound.Login.FailureQueryKey, - bound.Login.CodeQueryKey, - bound.Login.FailureCodes - ), - - new EffectiveLogoutRedirectResponse( - bound.Logout.RedirectEnabled, - bound.Logout.RedirectUrl, - bound.Logout.AllowReturnUrlOverride - ) + redirect ); } - private static AuthResponseOptions BindCookies(AuthResponseOptions response, UAuthServerOptions server) + private static UAuthResponseOptions BindCookies(UAuthResponseOptions response, UAuthServerOptions server) { - return new AuthResponseOptions + return new UAuthResponseOptions { SessionIdDelivery = Bind(response.SessionIdDelivery, server), AccessTokenDelivery = Bind(response.AccessTokenDelivery, server), @@ -75,7 +63,7 @@ private static CredentialResponseOptions Bind(CredentialResponseOptions delivery return delivery.WithCookie(cookie); } - private static void Validate(AuthResponseOptions response) + private static void Validate(UAuthResponseOptions response) { ValidateDelivery(response.SessionIdDelivery); ValidateDelivery(response.AccessTokenDelivery); @@ -90,4 +78,17 @@ private static void ValidateDelivery(CredentialResponseOptions delivery) } } + private static EffectiveRedirectResponse ResolveRedirect(AuthFlowType flowType, UAuthResponseOptions bound) + { + return flowType switch + { + AuthFlowType.Login or AuthFlowType.Reauthentication + => EffectiveRedirectResponse.FromLogin(bound.Login), + + AuthFlowType.Logout + => EffectiveRedirectResponse.FromLogout(bound.Logout), + + _ => EffectiveRedirectResponse.Disabled + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index 16e75fdb..a9726933 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -8,16 +8,18 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class ClientProfileAuthResponseAdapter { - public AuthResponseOptions Adapt(AuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) + public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) { - return new AuthResponseOptions + var configured = effectiveOptions.Options.AuthResponse; + + return new UAuthResponseOptions { SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), - Login = template.Login, - Logout = template.Logout + Login = MergeLogin(template.Login, configured.Login), + Logout = MergeLogout(template.Logout, configured.Logout) }; } @@ -51,4 +53,28 @@ private static CredentialResponseOptions ToHeader(CredentialResponseOptions orig }; } + private static LoginRedirectOptions MergeLogin(LoginRedirectOptions template, LoginRedirectOptions configured) + { + return new LoginRedirectOptions + { + RedirectEnabled = configured.RedirectEnabled, + SuccessRedirect = configured.SuccessRedirect ?? template.SuccessRedirect, + FailureRedirect = configured.FailureRedirect ?? template.FailureRedirect, + FailureQueryKey = configured.FailureQueryKey ?? template.FailureQueryKey, + FailureCodes = configured.FailureCodes.Count > 0 + ? new Dictionary(configured.FailureCodes) + : new Dictionary(template.FailureCodes), + AllowReturnUrlOverride = configured.AllowReturnUrlOverride + }; + } + + private static LogoutRedirectOptions MergeLogout(LogoutRedirectOptions template, LogoutRedirectOptions configured) + { + return new LogoutRedirectOptions + { + RedirectEnabled = configured.RedirectEnabled, + RedirectUrl = configured.RedirectUrl ?? template.RedirectUrl, + AllowReturnUrlOverride = configured.AllowReturnUrlOverride + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs index 35422857..90865473 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs @@ -6,11 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class EffectiveAuthModeResolver : IEffectiveAuthModeResolver { - public UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType) + public UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType) { - if (configuredMode.HasValue) - return configuredMode.Value; - return clientProfile switch { UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs index b0280bed..7a82917d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs @@ -2,10 +2,22 @@ namespace CodeBeam.UltimateAuth.Server.Auth; -public sealed record EffectiveAuthResponse( - CredentialResponseOptions SessionIdDelivery, - CredentialResponseOptions AccessTokenDelivery, - CredentialResponseOptions RefreshTokenDelivery, - EffectiveLoginRedirectResponse Login, - EffectiveLogoutRedirectResponse Logout -); +public sealed class EffectiveAuthResponse +{ + public CredentialResponseOptions SessionIdDelivery { get; } + public CredentialResponseOptions AccessTokenDelivery { get; } + public CredentialResponseOptions RefreshTokenDelivery { get; } + public EffectiveRedirectResponse Redirect { get; } + + public EffectiveAuthResponse( + CredentialResponseOptions sessionIdDelivery, + CredentialResponseOptions accessTokenDelivery, + CredentialResponseOptions refreshTokenDelivery, + EffectiveRedirectResponse redirect) + { + SessionIdDelivery = sessionIdDelivery; + AccessTokenDelivery = accessTokenDelivery; + RefreshTokenDelivery = refreshTokenDelivery; + Redirect = redirect; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs deleted file mode 100644 index 8408cadd..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Auth; - -public sealed record EffectiveLoginRedirectResponse -( - bool RedirectEnabled, - string SuccessPath, - string FailurePath, - string FailureQueryKey, - string CodeQueryKey, - IReadOnlyDictionary FailureCodes -); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs new file mode 100644 index 00000000..c81d6aa6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class EffectiveRedirectResponse +{ + public bool Enabled { get; } + public string? SuccessPath { get; } + public string? FailurePath { get; } + public string? FailureQueryKey { get; } + public IReadOnlyDictionary? FailureCodes { get; } + public bool AllowReturnUrlOverride { get; } + + public EffectiveRedirectResponse( + bool enabled, + string? successPath, + string? failurePath, + string? failureQueryKey, + IReadOnlyDictionary? failureCodes, + bool allowReturnUrlOverride) + { + Enabled = enabled; + SuccessPath = successPath; + FailurePath = failurePath; + FailureQueryKey = failureQueryKey; + FailureCodes = failureCodes; + AllowReturnUrlOverride = allowReturnUrlOverride; + } + + public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, null, false); + + public static EffectiveRedirectResponse FromLogin(LoginRedirectOptions login) + => new( + login.RedirectEnabled, + login.SuccessRedirect, + login.FailureRedirect, + login.FailureQueryKey, + login.FailureCodes, + login.AllowReturnUrlOverride + ); + + public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout) + => new( + logout.RedirectEnabled, + logout.RedirectUrl, + null, + null, + null, + logout.AllowReturnUrlOverride + ); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs index 2477a12d..71969091 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -6,5 +6,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth; public interface IEffectiveAuthModeResolver { - UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType); + UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index e2f56a2c..98add804 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -58,7 +58,7 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); - var principal = result.Claims.ToClaimsPrincipal(UAuthCookieDefaults.AuthenticationScheme); + var principal = result.Claims.ToClaimsPrincipal(result.UserKey, UAuthCookieDefaults.AuthenticationScheme); return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); } } \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs index 5259492a..91617a35 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs @@ -1,20 +1,20 @@ -using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +//using CodeBeam.UltimateAuth.Core.Extensions; +//using CodeBeam.UltimateAuth.Server.Extensions; +//using CodeBeam.UltimateAuth.Server.Options; +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; +//namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; -public static class AddUltimateAuthServerExtensions -{ - public static UltimateAuthServerBuilder AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) - { - services.AddUltimateAuth(configuration); // Core - services.AddUAuthServerInfrastructure(); // issuer, flow, endpoints +//public static class AddUltimateAuthServerExtensions +//{ +// public static UltimateAuthServerBuilder AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) +// { +// services.AddUltimateAuth(configuration); // Core +// services.AddUAuthServerInfrastructure(); // issuer, flow, endpoints - services.Configure(configuration.GetSection("UltimateAuth:Server")); +// services.Configure(configuration.GetSection("UltimateAuth:Server")); - return new UltimateAuthServerBuilder(services); - } -} +// return new UltimateAuthServerBuilder(services); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 5b5361e9..ab0c4530 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -17,14 +17,14 @@ public sealed class LoginEndpointHandler : ILoginEndpointHandler private readonly IUAuthFlowService _flowService; private readonly IClock _clock; private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; public LoginEndpointHandler( IAuthFlowContextAccessor authFlow, IUAuthFlowService flowService, IClock clock, ICredentialResponseWriter credentialResponseWriter, - AuthRedirectResolver redirectResolver) + IAuthRedirectResolver redirectResolver) { _authFlow = authFlow; _flowService = flowService; @@ -37,10 +37,6 @@ public async Task LoginAsync(HttpContext ctx) { var authFlow = _authFlow.Current; - var shouldIssueTokens = - authFlow.Response.AccessTokenDelivery.Mode != TokenResponseMode.None || - authFlow.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; - if (!ctx.Request.HasFormContentType) return Results.BadRequest("Invalid content type."); @@ -50,7 +46,13 @@ public async Task LoginAsync(HttpContext ctx) var secret = form["Secret"].ToString(); if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) - return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions); + { + var decisionFailureInvalid = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials); + + return decisionFailureInvalid.Enabled + ? Results.Redirect(decisionFailureInvalid.TargetUrl!) + : Results.Unauthorized(); + } var flowRequest = new LoginRequest { @@ -59,13 +61,19 @@ public async Task LoginAsync(HttpContext ctx) Tenant = authFlow.Tenant, At = _clock.UtcNow, Device = authFlow.Device, - RequestTokens = shouldIssueTokens + RequestTokens = authFlow.AllowsTokenIssuance }; var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted); if (!result.IsSuccess) - return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, authFlow.OriginalOptions); + { + var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown); + + return decisionFailure.Enabled + ? Results.Redirect(decisionFailure.TargetUrl!) + : Results.Unauthorized(); + } if (result.SessionId is AuthSessionId sessionId) { @@ -82,33 +90,10 @@ public async Task LoginAsync(HttpContext ctx) _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - if (authFlow.Response.Login.RedirectEnabled) - { - var redirectUrl = _redirectResolver.ResolveRedirect(ctx, authFlow.Response.Login.SuccessPath); - return Results.Redirect(redirectUrl); - } - - return Results.Ok(); - } + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); - private IResult RedirectFailure(HttpContext ctx, AuthFailureReason reason, UAuthServerOptions options) - { - var login = options.AuthResponse.Login; - - var code = - login.FailureCodes != null && - login.FailureCodes.TryGetValue(reason, out var mapped) - ? mapped - : "failed"; - - var redirectUrl = _redirectResolver.ResolveRedirect( - ctx, - login.FailureRedirect, - new Dictionary - { - [login.FailureQueryKey] = code - }); - - return Results.Redirect(redirectUrl); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index d99f9e1e..6106dce2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -2,7 +2,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; @@ -16,9 +15,9 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler private readonly IUAuthFlowService _flow; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, AuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; @@ -29,13 +28,13 @@ public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowSer public async Task LogoutAsync(HttpContext ctx) { - var auth = _authContext.Current; + var authFlow = _authContext.Current; - if (auth.Session is SessionSecurityContext session) + if (authFlow.Session is SessionSecurityContext session) { var request = new LogoutRequest { - Tenant = auth.Tenant, + Tenant = authFlow.Tenant, SessionId = session.SessionId, At = _clock.UtcNow, }; @@ -43,20 +42,15 @@ public async Task LogoutAsync(HttpContext ctx) await _flow.LogoutAsync(request, ctx.RequestAborted); } - DeleteIfCookie(ctx, auth.Response.SessionIdDelivery); - DeleteIfCookie(ctx, auth.Response.RefreshTokenDelivery); - DeleteIfCookie(ctx, auth.Response.AccessTokenDelivery); + DeleteIfCookie(ctx, authFlow.Response.SessionIdDelivery); + DeleteIfCookie(ctx, authFlow.Response.RefreshTokenDelivery); + DeleteIfCookie(ctx, authFlow.Response.AccessTokenDelivery); - if (auth.Response.Logout.RedirectEnabled) - { - var redirectUrl = _redirectResolver.ResolveRedirect(ctx, auth.Response.Logout.RedirectPath); - return Results.Redirect(redirectUrl); - } + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); - return Results.Ok(new LogoutResponse - { - Success = true - }); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(new LogoutResponse { Success = true }); } private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) @@ -69,5 +63,4 @@ private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) _cookieManager.Delete(ctx, delivery.Cookie.Name); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index f53a6bb2..c54c0328 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using Microsoft.AspNetCore.Http; @@ -20,9 +20,9 @@ internal sealed class PkceEndpointHandler : IPkceEndpointHandler private readonly IAuthStore _authStore; private readonly IPkceAuthorizationValidator _validator; private readonly IClock _clock; - private readonly UAuthPkceOptions _pkceOptions; + private readonly UAuthServerOptions _options; private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; public PkceEndpointHandler( IAuthFlowContextAccessor authContext, @@ -30,16 +30,16 @@ public PkceEndpointHandler( IAuthStore authStore, IPkceAuthorizationValidator validator, IClock clock, - IOptions pkceOptions, + IOptions options, ICredentialResponseWriter credentialResponseWriter, - AuthRedirectResolver redirectResolver) + IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; _authStore = authStore; _validator = validator; _clock = clock; - _pkceOptions = pkceOptions.Value; + _options = options.Value; _credentialResponseWriter = credentialResponseWriter; _redirectResolver = redirectResolver; } @@ -70,14 +70,13 @@ public async Task AuthorizeAsync(HttpContext ctx) deviceId: string.Empty // TODO: Fix here with device binding ); - var expiresAt = _clock.UtcNow.AddSeconds(_pkceOptions.AuthorizationCodeLifetimeSeconds); + var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); var artifact = new PkceAuthorizationArtifact( authorizationCode: authorizationCode, codeChallenge: request.CodeChallenge, challengeMethod: PkceChallengeMethod.S256, expiresAt: expiresAt, - maxAttempts: _pkceOptions.MaxVerificationAttempts, context: snapshot ); @@ -86,7 +85,7 @@ public async Task AuthorizeAsync(HttpContext ctx) return Results.Ok(new PkceAuthorizeResponse { AuthorizationCode = authorizationCode.Value, - ExpiresIn = _pkceOptions.AuthorizationCodeLifetimeSeconds + ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds }); } @@ -159,13 +158,11 @@ public async Task CompleteAsync(HttpContext ctx) _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - if (authContext.Response.Login.RedirectEnabled) - { - var redirectUrl = request.ReturnUrl ?? _redirectResolver.ResolveRedirect(ctx, authContext.Response.Login.SuccessPath); - return Results.Redirect(redirectUrl); - } + var decision = _redirectResolver.ResolveSuccess(authContext, ctx); - return Results.Ok(); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); } private static async Task ReadPkceAuthorizeRequestAsync(HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 546358de..929cd276 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -12,10 +12,14 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; // TODO: Add endpoint based guards public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { + + // NOTE: + // All endpoints intentionally use POST to avoid caching, + // CSRF ambiguity, and credential leakage via query strings. public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) { // Default base: /auth - string basePrefix = options.RoutePrefix.TrimStart('/'); + string basePrefix = options.Endpoints.BasePath.TrimStart('/'); bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; RouteGroupBuilder group = useRouteTenant @@ -24,7 +28,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); - if (options.EnableLoginEndpoints != false) + if (options.Endpoints.Login != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); @@ -42,7 +46,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); } - if (options.EnablePkceEndpoints != false) + if (options.Endpoints.Pkce != false) { var pkce = group.MapGroup("/pkce"); @@ -53,7 +57,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); } - if (options.EnableTokenEndpoints != false) + if (options.Endpoints.Token != false) { var token = group.MapGroup(""); @@ -70,7 +74,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); } - if (options.EnableSessionEndpoints != false) + if (options.Endpoints.Session != false) { var session = group.MapGroup("/session"); @@ -87,7 +91,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } - var user = group.MapGroup(""); + //var user = group.MapGroup(""); var users = group.MapGroup("/users"); var adminUsers = group.MapGroup("/admin/users"); @@ -103,7 +107,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); //} - if (options.EnableUserLifecycleEndpoints != false) + if (options.Endpoints.UserLifecycle != false) { users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); @@ -119,7 +123,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); } - if (options.EnableUserProfileEndpoints != false) + if (options.Endpoints.UserProfile != false) { users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); @@ -134,7 +138,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } - if (options.EnableUserIdentifierEndpoints != false) + if (options.Endpoints.UserIdentifier != false) { users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); @@ -180,7 +184,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); } - if (options.EnableCredentialsEndpoints != false) + if (options.Endpoints.Credentials != false) { var credentials = group.MapGroup("/credentials"); var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); @@ -226,7 +230,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } - if (options.EnableAuthorizationEndpoints != false) + if (options.Endpoints.Authorization != false) { var authz = group.MapGroup("/authorization"); var adminAuthz = group.MapGroup("/admin/authorization"); @@ -248,5 +252,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); } + // IMPORTANT: + // Escape hatch is invoked AFTER all UltimateAuth endpoints are registered. + // Developers may add metadata, filters, authorization, rate limits, etc. + // Removing or remapping UltimateAuth endpoints is unsupported. + options.OnConfigureEndpoints?.Invoke(rootGroup); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs index e48c09d3..12ff84cf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -10,6 +9,7 @@ public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffse { return new AuthContext { + ClientProfile = flow.ClientProfile, Tenant = flow.Tenant, Operation = flow.FlowType.ToAuthOperation(), Mode = flow.EffectiveMode, @@ -18,21 +18,4 @@ public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffse Session = flow.Session }; } - - public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile) - { - return new AuthFlowContext( - flow.FlowType, - profile, - flow.EffectiveMode, - flow.Device, - flow.Tenant, - flow.IsAuthenticated, - flow.UserKey, - flow.Session, - flow.OriginalOptions, - flow.EffectiveOptions, - flow.Response, - flow.PrimaryTokenKind); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index f27f3828..c4023a5e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -11,15 +11,16 @@ public static class EndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) { - using var scope = endpoints.ServiceProvider.CreateScope(); - var registrar = scope.ServiceProvider.GetRequiredService(); - var options = scope.ServiceProvider.GetRequiredService>().Value; - - // Root group ("/") + var registrar = endpoints.ServiceProvider.GetRequiredService(); + var options = endpoints.ServiceProvider.GetRequiredService>().Value; var rootGroup = endpoints.MapGroup(""); - registrar.MapEndpoints(rootGroup, options); + if (endpoints is WebApplication app) + { + options.OnConfigureEndpoints?.Invoke(app); + } + return endpoints; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs new file mode 100644 index 00000000..175864d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +internal static class HttpContextReturnUrlExtensions +{ + public static string? GetReturnUrl(this HttpContext ctx) + { + if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue("return_url", out var form)) + { + return form.ToString(); + } + + if (ctx.Request.Query.TryGetValue("return_url", out var query)) + { + return query.ToString(); + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index dc44adab..7a37e3a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -2,10 +2,12 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; @@ -13,15 +15,14 @@ using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Runtime; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -31,59 +32,74 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null) { + ArgumentNullException.ThrowIfNull(services); services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddAuthorizationInternal(services); - AddUltimateAuthPolicies(services); - return services.AddUltimateAuthServerInternal(); - } - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) - { - services.AddUltimateAuth(configuration); AddUsersInternal(services); AddCredentialsInternal(services); AddAuthorizationInternal(services); AddUltimateAuthPolicies(services); - services.Configure(configuration.GetSection("UltimateAuth:Server")); - return services.AddUltimateAuthServerInternal(); - } + services.AddOptions() + // Program.cs configuration (lowest precedence) + .Configure(options => + { + configure?.Invoke(options); + }) + // appsettings.json (highest precedence) + .BindConfiguration("UltimateAuth:Server") + .PostConfigure(options => + { + // Add any default values or adjustments here if needed + }); - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) - { - services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddAuthorizationInternal(services); - AddUltimateAuthPolicies(services); - services.Configure(configure); + services.AddUltimateAuthServerInternal(); - return services.AddUltimateAuthServerInternal(); + return services; } private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { + services.AddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(sp => + services.TryAddScoped(sp => { var keyProvider = sp.GetRequiredService(); var key = keyProvider.Resolve(null); - return new HmacSha256TokenHasher(((SymmetricSecurityKey)key.Key).Key); }); - // ----------------------------- // OPTIONS VALIDATION // ----------------------------- - services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); + //services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); + services.AddSingleton, UAuthServerOptionsValidator>(); + services.AddSingleton, UAuthServerLoginOptionsValidator>(); + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + // EVENTS + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return options.Events.Clone(); + }); + + services.AddSingleton(); // Tenant Resolution services.TryAddSingleton(sp => @@ -114,9 +130,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddScoped(); services.AddScoped(); - // Public resolver services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); services.TryAddScoped(); @@ -124,17 +138,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); - // TODO: Allow custom cookie manager via options - //services.AddSingleton(); - //if (options.CustomCookieManagerType is not null) - //{ - // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); - //} - //else - //{ - // services.AddSingleton(); - //} - services.TryAddScoped(); services.TryAddScoped(); @@ -156,6 +159,13 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -166,7 +176,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -184,9 +194,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -247,8 +257,6 @@ internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollecti return new UAuthAccessAuthority(invariants, globalPolicies); }); - services.TryAddScoped(); - return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index defffe30..141c1628 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -1,4 +1,8 @@ -namespace CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Flows; /// /// Default implementation of the login authority. @@ -6,13 +10,15 @@ /// public sealed class LoginAuthority : ILoginAuthority { - public LoginDecision Decide(LoginDecisionContext context) + private readonly UAuthLoginOptions _options; + + public LoginAuthority(IOptions options) { - if (!context.CredentialsValid) - { - return LoginDecision.Deny("Invalid credentials."); - } + _options = options.Value.Login; + } + public LoginDecision Decide(LoginDecisionContext context) + { if (!context.UserExists || context.UserKey is null) { return LoginDecision.Deny("Invalid credentials."); @@ -28,6 +34,11 @@ public LoginDecision Decide(LoginDecisionContext context) return LoginDecision.Challenge("reauth_required"); } + if (!context.CredentialsValid) + { + return LoginDecision.Deny("Invalid credentials."); + } + return LoginDecision.Allow(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 40e008dc..77488ee0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -2,12 +2,16 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -16,33 +20,42 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ICredentialStore _credentialStore; // authentication private readonly ICredentialValidator _credentialValidator; private readonly IUserRuntimeStateProvider _users; // eligible - private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; private readonly IUserIdConverterResolver _userIdConverterResolver; + private readonly IUserSecurityStateWriter _securityWriter; + private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly UAuthEventDispatcher _events; + private readonly UAuthServerOptions _options; public LoginOrchestrator( ICredentialStore credentialStore, ICredentialValidator credentialValidator, IUserRuntimeStateProvider users, - IUserSecurityStateProvider userSecurityStateProvider, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserIdConverterResolver userIdConverterResolver) + IUserIdConverterResolver userIdConverterResolver, + IUserSecurityStateWriter securityWriter, + IUserSecurityStateProvider securityStateProvider, + UAuthEventDispatcher events, + IOptions options) { _credentialStore = credentialStore; _credentialValidator = credentialValidator; _users = users; - _userSecurityStateProvider = userSecurityStateProvider; _authority = authority; _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; _userIdConverterResolver = userIdConverterResolver; + _securityWriter = securityWriter; + _securityStateProvider = securityStateProvider; + _events = events; + _options = options.Value; } public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) @@ -50,49 +63,56 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req ct.ThrowIfCancellationRequested(); var now = request.At ?? DateTimeOffset.UtcNow; - var credentials = await _credentialStore.FindByLoginAsync(request.Tenant, request.Identifier, ct); - var orderedCredentials = credentials - .OfType() - .Where(c => c.Security.IsUsable(now)) - .Cast>() - .ToList(); + bool hasCandidateUser = false; + TUserId candidateUserId = default!; TUserId validatedUserId = default!; bool credentialsValid = false; - foreach (var credential in orderedCredentials) + foreach (var credential in credentials.OfType()) { - var result = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); + if (!credential.Security.IsUsable(now)) + continue; + + var typed = (ICredential)credential; + + if (!hasCandidateUser) + { + candidateUserId = typed.UserId; + hasCandidateUser = true; + } + + var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); if (result.IsValid) { - validatedUserId = credential.UserId; + validatedUserId = ((ICredential)credential).UserId; credentialsValid = true; break; } } - bool userExists = credentialsValid; - + bool userExists = false; IUserSecurityState? securityState = null; UserKey? userKey = null; - if (credentialsValid) + if (candidateUserId is not null) { - securityState = await _userSecurityStateProvider.GetAsync(request.Tenant, validatedUserId, ct); + securityState = await _securityStateProvider.GetAsync(request.Tenant, candidateUserId, ct); var converter = _userIdConverterResolver.GetConverter(); - userKey = UserKey.FromString(converter.ToCanonicalString(validatedUserId)); - } - - var user = userKey is not null - ? await _users.GetAsync(request.Tenant, userKey.Value, ct) - : null; + var canonicalUserId = converter.ToCanonicalString(candidateUserId); - if (user is null || user.IsDeleted || !user.IsActive) - { - // Deliberately vague - return LoginResult.Failed(); + if (!string.IsNullOrWhiteSpace(canonicalUserId)) + { + var tempUserKey = UserKey.FromString(canonicalUserId); + var user = await _users.GetAsync(request.Tenant, tempUserKey, ct); + if (user is not null && user.IsActive && !user.IsDeleted) + { + userKey = tempUserKey; + userExists = true; + } + } } var decisionContext = new LoginDecisionContext @@ -108,6 +128,33 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var decision = _authority.Decide(decisionContext); + if (candidateUserId is not null) + { + if (decision.Kind == LoginDecisionKind.Allow) + { + await _securityWriter.ResetFailuresAsync(request.Tenant, candidateUserId, ct); + } + else + { + var isCurrentlyLocked = securityState?.IsLocked == true && securityState?.LockedUntil is DateTimeOffset until && until > now; + + if (!isCurrentlyLocked) + { + await _securityWriter.RecordFailedLoginAsync(request.Tenant, candidateUserId, now, ct); + + var currentFailures = securityState?.FailedLoginAttempts ?? 0; + var nextCount = currentFailures + 1; + + if (_options.Login.MaxFailedAttempts > 0 && nextCount >= _options.Login.MaxFailedAttempts) + { + var lockedUntil = now.AddMinutes(_options.Login.LockoutMinutes); + await _securityWriter.LockUntilAsync(request.Tenant, candidateUserId, lockedUntil, ct); + } + } + + } + } + if (decision.Kind == LoginDecisionKind.Deny) return LoginResult.Failed(); @@ -120,10 +167,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }); } - if (userKey is not UserKey validUserKey) - { + if (validatedUserId is null || userKey is not UserKey validUserKey) return LoginResult.Failed(); - } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, validUserKey, ct); @@ -135,7 +180,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Device = request.Device, Claims = claims, ChainId = request.ChainId, - Metadata = SessionMetadata.Empty + Metadata = SessionMetadata.Empty, + Mode = flow.EffectiveMode }; var authContext = flow.ToAuthContext(now); @@ -161,6 +207,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }; } + await _events.DispatchAsync(new UserLoggedInContext(request.Tenant, validUserKey, now, request.Device, issuedSession.Session.SessionId)); + return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs index 3f1fdc72..b9702d77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs @@ -15,9 +15,8 @@ public PkceAuthorizationArtifact( string codeChallenge, PkceChallengeMethod challengeMethod, DateTimeOffset expiresAt, - int maxAttempts, PkceContextSnapshot context) - : base(AuthArtifactType.PkceAuthorizationCode, expiresAt, maxAttempts) + : base(AuthArtifactType.PkceAuthorizationCode, expiresAt) { AuthorizationCode = authorizationCode; CodeChallenge = codeChallenge; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs index 0265ce7f..1f1f22b2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -8,23 +8,15 @@ internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator { public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now) { - // 1️⃣ Expiration if (artifact.IsExpired(now)) return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); - // 2️⃣ Attempt limit - if (!artifact.CanAttempt()) - return PkceValidationResult.Fail(PkceValidationFailureReason.MaxAttemptsExceeded); - - // 3️⃣ Context consistency //if (!IsContextValid(artifact.Context, completionContext)) //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); - // 4️⃣ Challenge method if (artifact.ChallengeMethod != PkceChallengeMethod.S256) return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); - // 5️⃣ Verifier check if (!IsVerifierValid(codeVerifier, artifact.CodeChallenge)) return PkceValidationResult.Fail(PkceValidationFailureReason.InvalidVerifier); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs deleted file mode 100644 index 54667497..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs +++ /dev/null @@ -1,63 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class AuthRedirectResolver -{ - private readonly UAuthServerOptions _options; - - public AuthRedirectResolver(IOptions options) - { - _options = options.Value; - } - - // TODO: Add allowed origins validation - public string ResolveClientBase(HttpContext ctx) - { - if (ctx.Request.Query.TryGetValue("returnUrl", out var returnUrl) && - Uri.TryCreate(returnUrl!, UriKind.Absolute, out var ru)) - { - return ru.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Origin", out var origin) && - Uri.TryCreate(origin!, UriKind.Absolute, out var originUri)) - { - return originUri.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Referer", out var referer) && - Uri.TryCreate(referer!, UriKind.Absolute, out var refUri)) - { - return refUri.GetLeftPart(UriPartial.Authority); - } - - if (!string.IsNullOrWhiteSpace(_options.Hub.ClientBaseAddress)) - return _options.Hub.ClientBaseAddress; - - return $"{ctx.Request.Scheme}://{ctx.Request.Host}"; - } - - public string ResolveRedirect(HttpContext ctx, string path, IDictionary? query = null) - { - var url = Combine(ResolveClientBase(ctx), path); - - if (query is null || query.Count == 0) - return url; - - var qs = string.Join("&", query - .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) - .Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value!)}")); - - return string.IsNullOrWhiteSpace(qs) - ? url - : $"{url}?{qs}"; - } - - private static string Combine(string baseUri, string path) - { - return baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs index 440f14a8..13582524 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookieManager { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs index 8f445d73..18d3f160 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookiePolicyBuilder { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs similarity index 90% rename from src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs index 0bc8e1fc..42067c60 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookieManager : IUAuthCookieManager { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 92523b04..9d75487a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -4,7 +4,7 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { @@ -65,8 +65,8 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { CredentialKind.Session => ResolveSessionLifetime(context), - CredentialKind.RefreshToken => context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime, - CredentialKind.AccessToken => context.EffectiveOptions.Options.Tokens.AccessTokenLifetime, + CredentialKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + CredentialKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, _ => null }; } @@ -74,7 +74,7 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context) { var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout; - var refresh = context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime; + var refresh = context.EffectiveOptions.Options.Token.RefreshTokenLifetime; return context.EffectiveMode switch { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs index 71cfcff3..2a23d8b3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs @@ -65,7 +65,7 @@ private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCre private static bool TryFromCookies( HttpContext ctx, - UAuthCookieSetOptions cookieSet, + UAuthCookiePolicyOptions cookieSet, out TransportCredential credential) { credential = default!; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 0334dd82..add44279 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -3,7 +3,6 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs index 975f6c1f..dde39b84 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs @@ -26,7 +26,6 @@ public DevelopmentJwtSigningKeyProvider() public JwtSigningKey Resolve(string? keyId) { - // signing veya verify için tek key return _key; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 709aea0c..4975109f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -15,10 +15,7 @@ public sealed class UAuthSessionIssuer : ISessionIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer( - ISessionStoreKernelFactory kernelFactory, - IOpaqueTokenGenerator opaqueGenerator, - IOptions options) + public UAuthSessionIssuer(ISessionStoreKernelFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { _kernelFactory = kernelFactory; _opaqueGenerator = opaqueGenerator; @@ -28,7 +25,7 @@ public UAuthSessionIssuer( public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority - if (_options.Mode == UAuthMode.PureJwt) + if (context.Mode == UAuthMode.PureJwt) { throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode."); } @@ -63,7 +60,7 @@ public async Task IssueLoginSessionAsync(AuthenticatedSessionCont { Session = session, OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid }; var kernel = _kernelFactory.Create(context.Tenant); @@ -135,7 +132,7 @@ public async Task RotateSessionAsync(SessionRotationContext conte metadata: context.Metadata ), OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid }; await kernel.ExecuteAsync(async _ => @@ -159,10 +156,10 @@ await kernel.ExecuteAsync(async _ => return issued; } - public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); + return await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); } public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index a30b4062..16cd8025 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -19,22 +19,20 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; private readonly IRefreshTokenStore _refreshTokenStore; - private readonly IUserIdConverterResolver _converterResolver; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; _refreshTokenStore = refreshTokenStore; - _converterResolver = converterResolver; _clock = clock; } public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) { - var tokens = flow.OriginalOptions.Tokens; + var tokens = flow.OriginalOptions.Token; var now = _clock.UtcNow; var expires = now.Add(tokens.AccessTokenLifetime); @@ -48,8 +46,7 @@ UAuthMode.SemiHybrid or UAuthMode.PureJwt => Task.FromResult(IssueJwtAccessToken(context, tokens, expires)), - _ => throw new InvalidOperationException( - $"Unsupported auth mode: {flow.EffectiveMode}") + _ => throw new InvalidOperationException($"Unsupported auth mode: {flow.EffectiveMode}") }; } @@ -58,7 +55,7 @@ UAuthMode.SemiHybrid or if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); + var expires = _clock.UtcNow.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); @@ -107,7 +104,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken { var claims = new Dictionary { - ["sub"] = context.UserKey, + ["sub"] = context.UserKey.Value, ["tenant"] = context.Tenant }; @@ -118,7 +115,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken claims["sid"] = context.SessionId!; if (tokens.AddJwtIdClaim) - claims["jti"] = _opaqueGenerator.Generate(16); + claims["jti"] = _opaqueGenerator.GenerateJwtId(); var descriptor = new UAuthJwtTokenDescriptor { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs index 5de0cf3d..ba7d89f8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs @@ -1,8 +1,21 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class OpaqueTokenGenerator : IOpaqueTokenGenerator { - public string Generate(int bytes) => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(bytes)); + private readonly UAuthTokenOptions _options; + + public OpaqueTokenGenerator(IOptions options) + { + _options = options.Value.Token; + } + + public string Generate() => GenerateBytes(_options.OpaqueIdBytes); + public string GenerateJwtId() => GenerateBytes(16); + private static string GenerateBytes(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs index 0bd2ee32..a2aa8f20 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -4,11 +4,10 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand +internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand { - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); - return Unit.Value; + return await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs new file mode 100644 index 00000000..20c98728 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class AuthRedirectResolver : IAuthRedirectResolver +{ + private readonly ClientBaseAddressResolver _baseAddressResolver; + + public AuthRedirectResolver(ClientBaseAddressResolver baseAddressResolver) + { + _baseAddressResolver = baseAddressResolver; + } + + public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) + => Resolve(flow, ctx, flow.Response.Redirect.SuccessPath, null); + + public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason) + => Resolve(flow, ctx, flow.Response.Redirect.FailurePath, reason); + + private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? fallbackPath, AuthFailureReason? failureReason) + { + var redirect = flow.Response.Redirect; + + if (!redirect.Enabled) + return RedirectDecision.None(); + + if (redirect.AllowReturnUrlOverride && flow.ReturnUrlInfo is { } info) + { + if (info.IsAbsolute && (info.AbsoluteUri!.Scheme == Uri.UriSchemeHttp || info.AbsoluteUri!.Scheme == Uri.UriSchemeHttps)) + { + var origin = info.AbsoluteUri!.GetLeftPart(UriPartial.Authority); + ValidateAllowed(origin, flow.OriginalOptions); + return RedirectDecision.To(info.AbsoluteUri.ToString()); + } + + if (!string.IsNullOrWhiteSpace(info.RelativePath)) + { + var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + return RedirectDecision.To(UrlComposer.Combine(baseAddress, info.RelativePath)); + } + } + + if (!string.IsNullOrWhiteSpace(fallbackPath)) + { + var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + + IDictionary? query = null; + + if (failureReason is not null) + { + var code = redirect.FailureCodes != null && + redirect.FailureCodes.TryGetValue(failureReason.Value, out var mapped) + ? mapped + : "failed"; + + query = new Dictionary + { + [redirect.FailureQueryKey ?? "error"] = code + }; + } + + return RedirectDecision.To(UrlComposer.Combine(baseAddress, fallbackPath, query)); + } + + return RedirectDecision.None(); + } + + private static void ValidateAllowed(string baseAddress, UAuthServerOptions options) + { + if (options.Hub.AllowedClientOrigins.Count == 0) + return; + + if (!options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress))) + { + throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed."); + } + } + + private static string Normalize(string uri) => uri.TrimEnd('/').ToLowerInvariant(); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs new file mode 100644 index 00000000..40809f0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ClientBaseAddressResolver +{ + private readonly IReadOnlyList _providers; + + public ClientBaseAddressResolver(IEnumerable providers) + { + _providers = providers.ToList(); + } + + public string Resolve(HttpContext ctx, UAuthServerOptions options) + { + string? fallback = null; + + foreach (var provider in _providers) + { + if (!provider.TryResolve(ctx, options, out var candidate)) + continue; + + if (provider is IFallbackClientBaseAddressProvider) + { + fallback ??= candidate; + continue; + } + + return Validate(candidate, options); + } + + if (fallback is not null) + return Validate(fallback, options); + + throw new InvalidOperationException("Unable to resolve client base address from request."); + } + + private static string Validate(string baseAddress, UAuthServerOptions options) + { + if (options.Hub.AllowedClientOrigins.Count == 0) + return baseAddress; + + if (options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress))) + return baseAddress; + + throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed. " + + "The origin is not present in AllowedClientOrigins."); + } + + private static string Normalize(string uri) => uri.TrimEnd('/').ToLowerInvariant(); +} + diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs new file mode 100644 index 00000000..dec178d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ConfiguredClientBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (string.IsNullOrWhiteSpace(options.Hub.ClientBaseAddress)) + return false; + + baseAddress = options.Hub.ClientBaseAddress; + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs new file mode 100644 index 00000000..55c789ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IAuthRedirectResolver +{ + RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext context); + RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext context, AuthFailureReason reason); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs new file mode 100644 index 00000000..c0304d22 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface IClientBaseAddressProvider +{ + bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs new file mode 100644 index 00000000..69e352fa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs @@ -0,0 +1,5 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface IFallbackClientBaseAddressProvider : IClientBaseAddressProvider +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs new file mode 100644 index 00000000..14843f36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class OriginHeaderBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (!context.Request.Headers.TryGetValue("Origin", out var origin)) + return false; + + if (!Uri.TryCreate(origin!, UriKind.Absolute, out var uri)) + return false; + + baseAddress = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs new file mode 100644 index 00000000..cbee309e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RedirectDecision +{ + public bool Enabled { get; } + public string? TargetUrl { get; } + + private RedirectDecision(bool enabled, string? targetUrl) + { + Enabled = enabled; + TargetUrl = targetUrl; + } + + public static RedirectDecision None() => new(false, null); + + public static RedirectDecision To(string url) + { + if (string.IsNullOrWhiteSpace(url)) + throw new ArgumentException("Redirect target URL cannot be empty.", nameof(url)); + + return new RedirectDecision(true, url); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs new file mode 100644 index 00000000..50be0ee1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class RefererHeaderBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (!context.Request.Headers.TryGetValue("Referer", out var referer)) + return false; + + if (!Uri.TryCreate(referer!, UriKind.Absolute, out var uri)) + return false; + + baseAddress = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs new file mode 100644 index 00000000..646e8b7b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class RequestHostBaseAddressProvider : IClientBaseAddressProvider, IFallbackClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = $"{context.Request.Scheme}://{context.Request.Host}"; + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs new file mode 100644 index 00000000..fa169511 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs @@ -0,0 +1,28 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed record ReturnUrlInfo +{ + public ReturnUrlKind Kind { get; } + public string? RelativePath { get; } + public Uri? AbsoluteUri { get; } + + private ReturnUrlInfo(ReturnUrlKind kind, string? relative, Uri? absolute) + { + Kind = kind; + RelativePath = relative; + AbsoluteUri = absolute; + } + + public static ReturnUrlInfo None() + => new(ReturnUrlKind.None, null, null); + + public static ReturnUrlInfo Relative(string path) + => new(ReturnUrlKind.Relative, path, null); + + public static ReturnUrlInfo Absolute(Uri uri) + => new(ReturnUrlKind.Absolute, null, uri); + + public bool IsNone => Kind == ReturnUrlKind.None; + public bool IsRelative => Kind == ReturnUrlKind.Relative; + public bool IsAbsolute => Kind == ReturnUrlKind.Absolute; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs new file mode 100644 index 00000000..fa82e13d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum ReturnUrlKind +{ + None, + Relative, + Absolute +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs new file mode 100644 index 00000000..ba204e7a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class ReturnUrlParser +{ + public static ReturnUrlInfo Parse(string? returnUrl) + { + if (string.IsNullOrWhiteSpace(returnUrl)) + return ReturnUrlInfo.None(); + + returnUrl = returnUrl.Trim(); + + if (returnUrl.StartsWith("/", StringComparison.Ordinal) || + returnUrl.StartsWith("./", StringComparison.Ordinal) || + returnUrl.StartsWith("../", StringComparison.Ordinal)) + { + return ReturnUrlInfo.Relative(returnUrl); + } + + if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var abs) && (abs.Scheme == Uri.UriSchemeHttp || abs.Scheme == Uri.UriSchemeHttps)) + { + return ReturnUrlInfo.Absolute(abs); + } + + if (returnUrl.StartsWith("//", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid returnUrl."); + + throw new InvalidOperationException("Invalid returnUrl."); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index c57b633f..daa4862d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class BearerSessionIdResolver : IInnerSessionIdResolver { - public string Key => "bearer"; + public string Name => "bearer"; public AuthSessionId? Resolve(HttpContext context) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs index fcec4400..1a687013 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -1,22 +1,32 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Infrastructure; // TODO: Add policy and effective auth resolver. public sealed class CompositeSessionIdResolver : ISessionIdResolver { - private readonly IReadOnlyList _resolvers; + private readonly IReadOnlyDictionary _resolvers; + private readonly UAuthSessionResolutionOptions _options; - public CompositeSessionIdResolver(IEnumerable resolvers) + public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) { - _resolvers = resolvers.ToList(); + _options = options.Value.SessionResolution; + _resolvers = resolvers.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); } public AuthSessionId? Resolve(HttpContext context) { - foreach (var resolver in _resolvers) + foreach (var name in _options.Order) { + if (!IsEnabled(name)) + continue; + + if (!_resolvers.TryGetValue(name, out var resolver)) + continue; + var id = resolver.Resolve(context); if (id is not null) return id; @@ -24,4 +34,13 @@ public CompositeSessionIdResolver(IEnumerable resolvers return null; } + + private bool IsEnabled(string name) => name switch + { + "Bearer" => _options.EnableBearer, + "Header" => _options.EnableHeader, + "Cookie" => _options.EnableCookie, + "Query" => _options.EnableQuery, + _ => false + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs index c1bab7c3..0d7bbaef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { - public string Key => "cookie"; + public string Name => "cookie"; private readonly UAuthServerOptions _options; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index 7c1bca18..7b4fe2ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { - public string Key => "header"; + public string Name => "header"; private readonly UAuthServerOptions _options; public HeaderSessionIdResolver(IOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs index 5ef9dc1f..f09ea5cc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs @@ -5,6 +5,6 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IInnerSessionIdResolver { - string Key { get; } + string Name { get; } AuthSessionId? Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index 2082c612..268bd1d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class QuerySessionIdResolver : IInnerSessionIdResolver { - public string Key => "query"; + public string Name => "query"; private readonly UAuthServerOptions _options; public QuerySessionIdResolver(IOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs new file mode 100644 index 00000000..ea149f56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class UrlComposer +{ + public static string Combine(string baseUri, string path, IDictionary? query = null) + { + var url = baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); + + if (query is null || query.Count == 0) + return url; + + var qs = string.Join("&", + query + .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) + .Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value!)}")); + + return string.IsNullOrWhiteSpace(qs) + ? url + : $"{url}?{qs}"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 1f9e4a4d..e34c23d0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -32,12 +32,12 @@ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOp if (!resolution.IsResolved) { - if (opts.RequireTenant) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Tenant is required."); - return; - } + //if (opts.RequireTenant) + //{ + // context.Response.StatusCode = StatusCodes.Status400BadRequest; + // await context.Response.WriteAsync("Tenant is required."); + // return; + //} context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Tenant could not be resolved."); diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs index be3e7fc5..1612e00a 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs @@ -12,8 +12,8 @@ public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOpt if (string.IsNullOrWhiteSpace(rawTenantId)) { - if (options.RequireTenant) - throw new InvalidOperationException("Tenant is required but could not be resolved."); + //if (options.RequireTenant) + // throw new InvalidOperationException("Tenant is required but could not be resolved."); throw new InvalidOperationException("Tenant could not be resolved."); } diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 028f16ed..54d80e47 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -18,8 +18,7 @@ public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions ResolveAsync(HttpContext context) { - var resolutionContext = - TenantResolutionContextFactory.FromHttpContext(context); + var resolutionContext =TenantResolutionContextFactory.FromHttpContext(context); var raw = await _idResolver.ResolveTenantIdAsync(resolutionContext); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index 9419dd2c..a62c5c7b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -33,14 +33,25 @@ public sealed class CredentialResponseOptions }; public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) - => new() { - Kind = Kind, - Mode = Mode, - Name = Name, - HeaderFormat = HeaderFormat, - TokenFormat = TokenFormat, - Cookie = cookie - }; - + if (Mode != TokenResponseMode.Cookie) + throw new InvalidOperationException("Cookie can only be set when Mode = Cookie."); + + return new CredentialResponseOptions() + { + Kind = Kind, + Mode = Mode, + Name = Name, + HeaderFormat = HeaderFormat, + TokenFormat = TokenFormat, + Cookie = cookie + }; + } + + public static CredentialResponseOptions Disabled(CredentialKind kind) + => new() + { + Kind = kind, + Mode = TokenResponseMode.None + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index 8a8ae0aa..9b8d9f6c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -6,9 +6,9 @@ namespace CodeBeam.UltimateAuth.Server.Options; internal class ConfigureDefaults { - internal static void ApplyModeDefaults(UAuthServerOptions o) + internal static void ApplyModeDefaults(UAuthMode effectiveMode, UAuthServerOptions o) { - switch (o.Mode) + switch (effectiveMode) { case UAuthMode.PureOpaque: ApplyPureOpaqueDefaults(o); @@ -27,14 +27,14 @@ internal static void ApplyModeDefaults(UAuthServerOptions o) break; default: - throw new InvalidOperationException($"Unsupported UAuthMode: {o.Mode}"); + throw new InvalidOperationException($"Unsupported UAuthMode: {effectiveMode}"); } } private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var c = o.Cookie; var r = o.AuthResponse; @@ -72,7 +72,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) private static void ApplyHybridDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var c = o.Cookie; var r = o.AuthResponse; @@ -97,7 +97,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) private static void ApplySemiHybridDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var p = o.Pkce; var c = o.Cookie; @@ -117,7 +117,7 @@ private static void ApplySemiHybridDefaults(UAuthServerOptions o) private static void ApplyPureJwtDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var p = o.Pkce; var c = o.Cookie; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs index a2b7f955..0eaf5182 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.AspNetCore.Http; @@ -9,4 +10,5 @@ public interface IEffectiveServerOptionsProvider { UAuthServerOptions GetOriginal(HttpContext context); EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile); + EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs index b542d2aa..d9aafb6d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -6,14 +6,19 @@ public sealed class LoginRedirectOptions { public bool RedirectEnabled { get; set; } = true; - public string SuccessRedirect { get; init; } = "/"; - public string FailureRedirect { get; init; } = "/login"; + public string SuccessRedirect { get; set; } = "/"; + public string FailureRedirect { get; set; } = "/login"; - public string FailureQueryKey { get; init; } = "error"; + public string FailureQueryKey { get; set; } = "error"; public string CodeQueryKey { get; set; } = "code"; public Dictionary FailureCodes { get; set; } = new(); + /// + /// Whether query-based returnUrl override is allowed. + /// + public bool AllowReturnUrlOverride { get; set; } = true; + internal LoginRedirectOptions Clone() => new() { RedirectEnabled = RedirectEnabled, @@ -21,6 +26,7 @@ public sealed class LoginRedirectOptions FailureRedirect = FailureRedirect, FailureQueryKey = FailureQueryKey, CodeQueryKey = CodeQueryKey, - FailureCodes = new Dictionary(FailureCodes) + FailureCodes = new Dictionary(FailureCodes), + AllowReturnUrlOverride = AllowReturnUrlOverride }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs index 56755f2b..d278ec00 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs @@ -1,39 +1,29 @@ -using Microsoft.AspNetCore.Http; +namespace CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Options; - -public sealed class UAuthCookieSetOptions +public sealed class UAuthCookiePolicyOptions { - public bool EnableSessionCookie { get; set; } = true; - public bool EnableAccessTokenCookie { get; set; } = true; - public bool EnableRefreshTokenCookie { get; set; } = true; - public UAuthCookieOptions Session { get; init; } = new() { Name = "uas", HttpOnly = true, - SameSite = SameSiteMode.None }; public UAuthCookieOptions RefreshToken { get; init; } = new() { Name = "uar", HttpOnly = true, - SameSite = SameSiteMode.None }; public UAuthCookieOptions AccessToken { get; init; } = new() { Name = "uat", HttpOnly = true, - SameSite = SameSiteMode.None }; - internal UAuthCookieSetOptions Clone() => new() + internal UAuthCookiePolicyOptions Clone() => new() { Session = Session.Clone(), RefreshToken = RefreshToken.Clone(), AccessToken = AccessToken.Clone() }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs index d8c3023c..af530528 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -12,5 +12,4 @@ public sealed class UAuthDiagnosticsOptions { EnableRefreshHeaders = EnableRefreshHeaders }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs index 432dc91e..2d904064 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -21,5 +21,4 @@ public sealed class UAuthHubServerOptions FlowLifetime = FlowLifetime, LoginPath = LoginPath }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs similarity index 82% rename from src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index dd143c9e..1da6d92d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class PrimaryCredentialPolicy +public sealed class UAuthPrimaryCredentialPolicy { /// /// Default primary credential for UI-style requests. @@ -14,7 +14,7 @@ public sealed class PrimaryCredentialPolicy /// public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; - internal PrimaryCredentialPolicy Clone() => new() + internal UAuthPrimaryCredentialPolicy Clone() => new() { Ui = Ui, Api = Api diff --git a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs similarity index 87% rename from src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs index 0c9dda0e..9297b557 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class AuthResponseOptions +public sealed class UAuthResponseOptions { public CredentialResponseOptions SessionIdDelivery { get; set; } = new(); public CredentialResponseOptions AccessTokenDelivery { get; set; } = new(); @@ -9,7 +9,7 @@ public sealed class AuthResponseOptions public LoginRedirectOptions Login { get; set; } = new(); public LogoutRedirectOptions Logout { get; set; } = new(); - internal AuthResponseOptions Clone() => new() + internal UAuthResponseOptions Clone() => new() { SessionIdDelivery = SessionIdDelivery.Clone(), AccessTokenDelivery = AccessTokenDelivery.Clone(), @@ -17,5 +17,4 @@ public sealed class AuthResponseOptions Login = Login.Clone(), Logout = Logout.Clone() }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs new file mode 100644 index 00000000..b586f272 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -0,0 +1,37 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerEndpointOptions +{ + /// + /// Base API route. Default: "/auth" + /// Changing this prevents conflicts with other auth systems. + /// + public string BasePath { get; set; } = "/auth"; + + public bool Login { get; set; } = true; + public bool Pkce { get; set; } = true; + public bool Token { get; set; } = true; + public bool Session { get; set; } = true; + + //public bool UserInfo { get; set; } = true; + public bool UserLifecycle { get; set; } = true; + public bool UserProfile { get; set; } = true; + public bool UserIdentifier { get; set; } = true; + public bool Credentials { get; set; } = true; + + public bool Authorization { get; set; } = true; + + internal UAuthServerEndpointOptions Clone() => new() + { + Login = Login, + Pkce = Pkce, + Token = Token, + Session = Session, + //UserInfo = UserInfo, + UserLifecycle = UserLifecycle, + UserProfile = UserProfile, + UserIdentifier = UserIdentifier, + Credentials = Credentials, + Authorization = Authorization + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index b80b1c6a..c8e03cb7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -1,8 +1,7 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Routing; namespace CodeBeam.UltimateAuth.Server.Options; @@ -14,81 +13,66 @@ namespace CodeBeam.UltimateAuth.Server.Options; /// public sealed class UAuthServerOptions { - /// - /// Defines how UltimateAuth executes authentication flows. - /// Default is Hybrid. - /// - public UAuthMode? Mode { get; set; } - - /// - /// Defines how UAuthHub is deployed relative to the application. - /// Default is Integrated - /// Blazor server projects should choose embedded mode for maximum security. - /// - public UAuthHubDeploymentMode HubDeploymentMode { get; set; } = UAuthHubDeploymentMode.Integrated; - - // ------------------------------------------------------- - // ROUTING - // ------------------------------------------------------- - - /// - /// Base API route. Default: "/auth" - /// Changing this prevents conflicts with other auth systems. - /// - public string RoutePrefix { get; set; } = "/auth"; - // ------------------------------------------------------- // CORE OPTION COMPOSITION // (Server must NOT duplicate Core options) // ------------------------------------------------------- + public UAuthLoginOptions Login { get; set; } = new(); + /// - /// Session behavior (lifetime, sliding expiration, etc.) - /// Fully defined in Core. + /// Session behavior (lifetime, sliding expiration, etc.) Fully defined in Core. /// public UAuthSessionOptions Session { get; set; } = new(); /// - /// Token issuing behavior (lifetimes, refresh policies). - /// Fully defined in Core. + /// Token issuing behavior (lifetimes, refresh policies). Fully defined in Core. /// - public UAuthTokenOptions Tokens { get; set; } = new(); + public UAuthTokenOptions Token { get; set; } = new(); /// - /// PKCE configuration (required for WASM). - /// Fully defined in Core. + /// PKCE configuration (required for WASM). Fully defined in Core. /// public UAuthPkceOptions Pkce { get; set; } = new(); + public UAuthEvents Events { get; set; } = new(); + /// - /// Multi-tenancy behavior (resolver, normalization, etc.) - /// Fully defined in Core. + /// Multi-tenancy behavior (resolver, normalization, etc.) Fully defined in Core. /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + // ------------------------------------------------------- + // SERVER-ONLY BEHAVIOR + // ------------------------------------------------------- + + /// + /// Defines which authentication modes are allowed to be used by the server. + /// This is a safety guardrail, not a mode selection mechanism. + /// The final mode is still resolved via IEffectiveAuthModeResolver. + /// If null or empty, all modes are allowed. + /// + public IReadOnlyCollection? AllowedModes { get; set; } + + /// + /// Defines how UAuthHub is deployed relative to the application. + /// Default is Integrated + /// Blazor server projects should choose embedded mode for maximum security. + /// + public UAuthHubDeploymentMode HubDeploymentMode { get; set; } = UAuthHubDeploymentMode.Integrated; + /// /// Allows advanced users to override cookie behavior. /// Unsafe combinations will be rejected at startup. /// - public UAuthCookieSetOptions Cookie { get; set; } = new(); + public UAuthCookiePolicyOptions Cookie { get; set; } = new(); public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); - internal Type? CustomCookieManagerType { get; private set; } - - public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManager - { - CustomCookieManagerType = typeof(T); - } - - // ------------------------------------------------------- - // SERVER-ONLY BEHAVIOR - // ------------------------------------------------------- - - public PrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); + public UAuthPrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); - public AuthResponseOptions AuthResponse { get; init; } = new(); + public UAuthResponseOptions AuthResponse { get; init; } = new(); public UAuthHubServerOptions Hub { get; set; } = new(); @@ -99,33 +83,22 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public UAuthSessionResolutionOptions SessionResolution { get; set; } = new(); /// - /// Enables/disables specific endpoint groups. - /// Useful for API hardening. + /// Enables/disables specific endpoint groups. Useful for API hardening. /// - public bool? EnableLoginEndpoints { get; set; } = true; - public bool? EnablePkceEndpoints { get; set; } = true; - public bool? EnableTokenEndpoints { get; set; } = true; - public bool? EnableSessionEndpoints { get; set; } = true; - public bool? EnableUserInfoEndpoints { get; set; } = true; + public UAuthServerEndpointOptions Endpoints { get; set; } = new(); - public bool? EnableUserLifecycleEndpoints { get; set; } = true; - public bool? EnableUserProfileEndpoints { get; set; } = true; - public bool? EnableUserIdentifierEndpoints { get; set; } = true; - public bool? EnableCredentialsEndpoints { get; set; } = true; - public bool? EnableAuthorizationEndpoints { get; set; } = true; + public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); - public UserIdentifierOptions UserIdentifiers { get; set; } = new(); - - /// - /// If true, server will add anti-forgery headers - /// and require proper request metadata. - /// - public bool EnableAntiCsrfProtection { get; set; } = true; + ///// + ///// If true, server will add anti-forgery headers + ///// and require proper request metadata. + ///// + //public bool EnableAntiCsrfProtection { get; set; } = true; - /// - /// If true, login attempts are rate-limited to prevent brute force attacks. - /// - public bool EnableLoginRateLimiting { get; set; } = true; + ///// + ///// If true, login attempts are rate-limited to prevent brute force attacks. + ///// + //public bool EnableLoginRateLimiting { get; set; } = true; // ------------------------------------------------------- @@ -133,17 +106,11 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage // ------------------------------------------------------- /// - /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults. - /// Example: adding new routes, overriding authorization, adding filters. - /// - public Action? OnConfigureEndpoints { get; set; } - - /// - /// Allows developers to add or replace server services before DI is built. - /// Example: overriding default ILoginService. + /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults like + /// adding new routes, overriding authorization, adding filters. + /// This hook must not remove or re-map UltimateAuth endpoints. Misuse may break security guarantees. /// - public Action? ConfigureServices { get; set; } - + public Action? OnConfigureEndpoints { get; set; } internal Dictionary> ModeConfigurations { get; set; } = new(); @@ -152,13 +119,14 @@ internal UAuthServerOptions Clone() { return new UAuthServerOptions { - Mode = Mode, + AllowedModes = AllowedModes?.ToArray(), HubDeploymentMode = HubDeploymentMode, - RoutePrefix = RoutePrefix, + Login = Login.Clone(), Session = Session.Clone(), - Tokens = Tokens.Clone(), + Token = Token.Clone(), Pkce = Pkce.Clone(), + Events = Events.Clone(), MultiTenant = MultiTenant.Clone(), Cookie = Cookie.Clone(), Diagnostics = Diagnostics.Clone(), @@ -168,24 +136,14 @@ internal UAuthServerOptions Clone() Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), UserIdentifiers = UserIdentifiers.Clone(), + Endpoints = Endpoints.Clone(), - EnableLoginEndpoints = EnableLoginEndpoints, - EnablePkceEndpoints = EnablePkceEndpoints, - EnableTokenEndpoints = EnableTokenEndpoints, - EnableSessionEndpoints = EnableSessionEndpoints, - EnableUserInfoEndpoints = EnableUserInfoEndpoints, - EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, - EnableUserProfileEndpoints = EnableUserProfileEndpoints, - EnableCredentialsEndpoints = EnableCredentialsEndpoints, - EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, + //EnableAntiCsrfProtection = EnableAntiCsrfProtection, + //EnableLoginRateLimiting = EnableLoginRateLimiting, - EnableAntiCsrfProtection = EnableAntiCsrfProtection, - EnableLoginRateLimiting = EnableLoginRateLimiting, + ModeConfigurations = new Dictionary>(ModeConfigurations), - ModeConfigurations = ModeConfigurations, OnConfigureEndpoints = OnConfigureEndpoints, - ConfigureServices = ConfigureServices, - CustomCookieManagerType = CustomCookieManagerType }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs deleted file mode 100644 index 1bc7c36f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs +++ /dev/null @@ -1,42 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Options; - -public sealed class UAuthServerOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) - { - if (string.IsNullOrWhiteSpace(options.RoutePrefix)) - { - return ValidateOptionsResult.Fail( "RoutePrefix must be specified."); - } - - if (options.RoutePrefix.Contains("//")) - { - return ValidateOptionsResult.Fail("RoutePrefix cannot contain '//'."); - } - - if (options.Mode.HasValue && !Enum.IsDefined(typeof(UAuthMode), options.Mode)) - { - return ValidateOptionsResult.Fail($"Invalid UAuthMode: {options.Mode}"); - } - - if (options.Mode != UAuthMode.PureJwt) - { - if (options.Session.Lifetime <= TimeSpan.Zero) - { - return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero."); - } - - if (options.Session.MaxLifetime is not null && - options.Session.MaxLifetime <= TimeSpan.Zero) - { - return ValidateOptionsResult.Fail( - "Session.MaxLifetime must be greater than zero when specified."); - } - } - - return ValidateOptionsResult.Success; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs index c7876148..f36b16bd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -7,7 +7,7 @@ public sealed class UAuthSessionResolutionOptions public bool EnableBearer { get; set; } = true; public bool EnableHeader { get; set; } = true; public bool EnableCookie { get; set; } = true; - public bool EnableQuery { get; set; } = false; + public bool EnableQuery { get; set; } = true; public string HeaderName { get; set; } = "X-UAuth-Session"; public string QueryParameterName { get; set; } = "session_id"; @@ -32,5 +32,4 @@ public sealed class UAuthSessionResolutionOptions QueryParameterName = QueryParameterName, Order = new List(Order) }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs similarity index 90% rename from src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs index e7e14434..0429af6f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UserIdentifierOptions +public sealed class UAuthUserIdentifierOptions { public bool AllowUsernameChange { get; set; } = true; public bool AllowMultipleUsernames { get; set; } = false; @@ -13,7 +13,7 @@ public sealed class UserIdentifierOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; - internal UserIdentifierOptions Clone() => new() + internal UAuthUserIdentifierOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, AllowMultipleUsernames = AllowMultipleUsernames, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs new file mode 100644 index 00000000..450b7bbd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerLoginOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var login = options.Login; + + if (login.MaxFailedAttempts < 0) + errors.Add("Login.MaxFailedAttempts cannot be negative."); + + if (login.LockoutMinutes < 0) + errors.Add("Login.LockoutMinutes cannot be negative."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs new file mode 100644 index 00000000..ec3afed5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthServerMultiTenantOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var multiTenant = options.MultiTenant; + + if (!multiTenant.Enabled) + { + if (multiTenant.EnableRoute || multiTenant.EnableHeader || multiTenant.EnableDomain) + { + errors.Add("Multi-tenancy is disabled, but one or more tenant resolvers are enabled. " + + "Either enable multi-tenancy or disable all tenant resolvers."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + if (!multiTenant.EnableRoute && !multiTenant.EnableHeader && !multiTenant.EnableDomain) + { + errors.Add("Multi-tenancy is enabled but no tenant resolver is active. " + + "Enable at least one of: route, header or domain."); + } + + if (multiTenant.EnableHeader) + { + if (string.IsNullOrWhiteSpace(multiTenant.HeaderName)) + { + errors.Add("MultiTenant.HeaderName must be specified when header-based tenant resolution is enabled."); + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs new file mode 100644 index 00000000..5b1fe8bc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -0,0 +1,72 @@ +using CodeBeam.UltimateAuth.Core; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + if (string.IsNullOrWhiteSpace(options.Endpoints.BasePath)) + { + return ValidateOptionsResult.Fail( "BasePath must be specified."); + } + + if (options.Endpoints.BasePath.Contains("//")) + { + return ValidateOptionsResult.Fail("BasePath cannot contain '//'."); + } + + var allowedModes = options.AllowedModes; + + if (allowedModes is { Count: > 0 }) + { + foreach (var mode in allowedModes) + { + if (!Enum.IsDefined(typeof(UAuthMode), mode)) + { + return ValidateOptionsResult.Fail($"Invalid UAuthMode value: {mode}"); + } + + // TODO: Delete here when SemiHybrid and PureJwt modes are implemented. + if (mode is UAuthMode.SemiHybrid or UAuthMode.PureJwt) + { + return ValidateOptionsResult.Fail($"Auth mode '{mode}' is not implemented yet and cannot be enabled."); + } + } + } + + bool anySessionModeAllowed = allowedModes is null || allowedModes.Count == 0 || + allowedModes.Contains(UAuthMode.Hybrid) || allowedModes.Contains(UAuthMode.PureOpaque) || allowedModes.Contains(UAuthMode.SemiHybrid); + + if (anySessionModeAllowed) + { + if (options.Session.Lifetime <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail( + "Session.Lifetime must be greater than zero."); + } + + if (options.Session.MaxLifetime is not null && + options.Session.MaxLifetime <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail( + "Session.MaxLifetime must be greater than zero when specified."); + } + } + + + // Only add cross-option validation beyond this point, individual options should validate in their own validators. + if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime) + { + return ValidateOptionsResult.Fail("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime."); + } + + if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime) + { + return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs new file mode 100644 index 00000000..b74a6d3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerPkceOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + + if (options.Pkce.AuthorizationCodeLifetimeSeconds <= 0) + { + errors.Add("Pkce.AuthorizationCodeLifetimeSeconds must be > 0."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs new file mode 100644 index 00000000..192b2786 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerSessionOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var session = options.Session; + + if (session.Lifetime <= TimeSpan.Zero) + errors.Add("Session.Lifetime must be greater than zero."); + + if (session.MaxLifetime.HasValue && session.MaxLifetime <= TimeSpan.Zero) + errors.Add("Session.MaxLifetime must be greater than zero when specified."); + + if (session.MaxLifetime.HasValue && + session.MaxLifetime < session.Lifetime) + errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); + + if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) + errors.Add("Session.IdleTimeout cannot be negative."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs new file mode 100644 index 00000000..76058c11 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerSessionResolutionOptionsValidator : IValidateOptions +{ + private static readonly HashSet KnownResolvers = + new(StringComparer.OrdinalIgnoreCase) + { + "Bearer", + "Header", + "Cookie", + "Query" + }; + + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var o = options.SessionResolution; + + if (!o.EnableBearer && !o.EnableHeader && !o.EnableCookie && !o.EnableQuery) + { + return ValidateOptionsResult.Fail("At least one session resolver must be enabled (Bearer, Header, Cookie, Query)."); + } + + if (o.Order is null || o.Order.Count == 0) + { + return ValidateOptionsResult.Fail("SessionResolution.Order cannot be empty."); + } + + foreach (var item in o.Order) + { + if (!KnownResolvers.Contains(item)) + { + return ValidateOptionsResult.Fail($"Unknown session resolver '{item}' in SessionResolution.Order."); + } + } + + foreach (var item in o.Order) + { + if (item.Equals("Bearer", StringComparison.OrdinalIgnoreCase) && !o.EnableBearer || + item.Equals("Header", StringComparison.OrdinalIgnoreCase) && !o.EnableHeader || + item.Equals("Cookie", StringComparison.OrdinalIgnoreCase) && !o.EnableCookie || + item.Equals("Query", StringComparison.OrdinalIgnoreCase) && !o.EnableQuery) + { + return ValidateOptionsResult.Fail($"Session resolver '{item}' is listed in Order but is not enabled."); + } + } + + if (o.EnableHeader && string.IsNullOrWhiteSpace(o.HeaderName)) + { + return ValidateOptionsResult.Fail("SessionResolution.HeaderName must be specified when header resolver is enabled."); + } + + if (o.EnableQuery && string.IsNullOrWhiteSpace(o.QueryParameterName)) + { + return ValidateOptionsResult.Fail("SessionResolution.QueryParameterName must be specified when query resolver is enabled."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs new file mode 100644 index 00000000..129b62b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerTokenOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var tokens = options.Token; + + if (!tokens.IssueJwt && !tokens.IssueOpaque) + errors.Add("Token: At least one of IssueJwt or IssueOpaque must be enabled."); + + if (tokens.AccessTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.AccessTokenLifetime must be greater than zero."); + + if (tokens.IssueRefresh) + { + if (tokens.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero when IssueRefresh is enabled."); + + if (tokens.RefreshTokenLifetime <= tokens.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + } + + if (tokens.IssueJwt) + { + if (string.IsNullOrWhiteSpace(tokens.Issuer) || tokens.Issuer.Trim().Length < 3) + errors.Add("Token.Issuer must be at least 3 characters when IssueJwt is enabled."); + + if (string.IsNullOrWhiteSpace(tokens.Audience) || tokens.Audience.Trim().Length < 3) + errors.Add("Token.Audience must be at least 3 characters when IssueJwt is enabled."); + } + + if (tokens.IssueOpaque) + { + if (tokens.OpaqueIdBytes < 16) + errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + + if (tokens.OpaqueIdBytes > 128) + errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs new file mode 100644 index 00000000..82af7684 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + if (!options.UserIdentifiers.AllowAdminOverride && !options.UserIdentifiers.AllowUserOverride) + { + return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + + "At least one actor must be able to manage user identifiers."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs new file mode 100644 index 00000000..922c53c4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Server.Runtime; + +internal sealed class ServerRuntimeMarker : IUAuthRuntimeMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs rename to src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 18202efc..aebf9590 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,27 +1,34 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthFlowContextFactory _authFlowContextFactory; private readonly ILoginOrchestrator _loginOrchestrator; private readonly ISessionOrchestrator _orchestrator; + private readonly UAuthEventDispatcher _events; public UAuthFlowService( IAuthFlowContextAccessor authFlow, + IAuthFlowContextFactory authFlowContextFactory, ILoginOrchestrator loginOrchestrator, - ISessionOrchestrator orchestrator) + ISessionOrchestrator orchestrator, + UAuthEventDispatcher events) { _authFlow = authFlow; + _authFlowContextFactory = authFlowContextFactory; _loginOrchestrator = loginOrchestrator; _orchestrator = orchestrator; + _events = events; } public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) @@ -44,21 +51,29 @@ public Task LoginAsync(AuthFlowContext flow, LoginRequest request, return _loginOrchestrator.LoginAsync(flow, request, ct); } - public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { var effectiveFlow = execution.EffectiveClientProfile is null ? flow - : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); + : await _authFlowContextFactory.RecreateWithClientProfileAsync(flow, (UAuthClientProfile)execution.EffectiveClientProfile, ct); + return await _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); } - public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { var authFlow = _authFlow.Current; var now = request.At ?? DateTimeOffset.UtcNow; var authContext = authFlow.ToAuthContext(now); - return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + var revoked = await _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + + if (!revoked) + return; + + if (authFlow.UserKey is not UserKey uaKey) + return; + + await _events.DispatchAsync(new UserLoggedOutContext(request.Tenant, uaKey, request.At ?? DateTimeOffset.Now, LogoutReason.Explicit, request.SessionId)); } public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs index eb7616ba..bf34a06f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs @@ -48,6 +48,38 @@ await strategy.ExecuteAsync(async () => }); } + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + public async Task GetSessionAsync(AuthSessionId sessionId) { var projection = await _db.Sessions @@ -70,20 +102,21 @@ public async Task SaveSessionAsync(UAuthSession session) _db.Sessions.Add(projection); } - public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) { - var projection = await _db.Sessions - .SingleOrDefaultAsync(x => x.SessionId == sessionId); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); if (projection is null) - return; + return false; var session = projection.ToDomain(); if (session.IsRevoked) - return; + return false; var revoked = session.Revoke(at); _db.Sessions.Update(revoked.ToProjection()); + + return true; } public async Task GetChainAsync(SessionChainId chainId) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs index 0b3cbb16..893a73b0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs @@ -26,6 +26,19 @@ public async Task ExecuteAsync(Func action, Cancellatio } } + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + public Task GetSessionAsync(AuthSessionId sessionId) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); public Task SaveSessionAsync(UAuthSession session) @@ -34,13 +47,16 @@ public Task SaveSessionAsync(UAuthSession session) return Task.CompletedTask; } - public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) { - if (_sessions.TryGetValue(sessionId, out var session)) - { - _sessions[sessionId] = session.Revoke(at); - } - return Task.CompletedTask; + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(false); + + if (session.IsRevoked) + return Task.FromResult(false); + + _sessions[sessionId] = session.Revoke(at); + return Task.FromResult(true); } public Task GetChainAsync(SessionChainId chainId) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index e6209864..d86b3f6a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,9 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(typeof(InMemoryUserSecurityStore<>)); + services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); + services.TryAddScoped(typeof(IUserSecurityStateWriter<>), typeof(InMemoryUserSecurityStateWriter<>)); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs new file mode 100644 index 00000000..ab3eccf0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityState : IUserSecurityState +{ + public long SecurityVersion { get; init; } + public int FailedLoginAttempts { get; init; } + public DateTimeOffset? LockedUntil { get; init; } + public bool RequiresReauthentication { get; init; } + + public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs index 55077d7b..53d47848 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -2,11 +2,17 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider +internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider where TUserId : notnull { + private readonly InMemoryUserSecurityStore _store; + + public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) + { + _store = store; + } + public Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) { - // InMemory default: no MFA, no lockout, no risk signals - return Task.FromResult(null); + return Task.FromResult(_store.Get(tenant, userId)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs new file mode 100644 index 00000000..7f2fc5ed --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter where TUserId : notnull +{ + private readonly InMemoryUserSecurityStore _store; + + public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) + { + _store = store; + } + + public Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + { + var current = _store.Get(tenant, userId); + + var next = new InMemoryUserSecurityState + { + SecurityVersion = (current?.SecurityVersion ?? 0) + 1, + FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, + LockedUntil = current?.LockedUntil, + RequiresReauthentication = current?.RequiresReauthentication ?? false + }; + + _store.Set(tenant, userId, next); + return Task.CompletedTask; + } + + public Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default) + { + var current = _store.Get(tenant, userId); + + var next = new InMemoryUserSecurityState + { + SecurityVersion = (current?.SecurityVersion ?? 0) + 1, + FailedLoginAttempts = current?.FailedLoginAttempts ?? 0, + LockedUntil = lockedUntil, + RequiresReauthentication = current?.RequiresReauthentication ?? false + }; + + _store.Set(tenant, userId, next); + return Task.CompletedTask; + } + + public Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + { + _store.Clear(tenant, userId); + return Task.CompletedTask; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs new file mode 100644 index 00000000..429de15f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView where TUserId : notnull +{ + private readonly ConcurrentDictionary<(TenantKey, TUserId), InMemoryUserSecurityState> _states = new(); + + public InMemoryUserSecurityState? Get(TenantKey tenant, TUserId userId) + => _states.TryGetValue((tenant, userId), out var state) ? state : null; + + public void Set(TenantKey tenant, TUserId userId, InMemoryUserSecurityState state) + => _states[(tenant, userId)] = state; + + public void Clear(TenantKey tenant, TUserId userId) + => _states.TryRemove((tenant, userId), out _); + + public IUserSecurityState? GetState(TenantKey tenant, TUserId userId) + => Get(tenant, userId); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 80d6bbc3..abc82331 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -14,7 +14,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl // Marker only – runtime validation happens via DI resolution }); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 179ed6f2..458344b6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -3,8 +3,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -15,6 +17,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; private readonly IEnumerable _integrations; + private readonly UAuthUserIdentifierOptions _identifierOptions; private readonly IClock _clock; public UserApplicationService( @@ -23,6 +26,7 @@ public UserApplicationService( IUserProfileStore profileStore, IUserIdentifierStore identifierStore, IEnumerable integrations, + IOptions options, IClock clock) { _accessOrchestrator = accessOrchestrator; @@ -30,6 +34,7 @@ public UserApplicationService( _profileStore = profileStore; _identifierStore = identifierStore; _integrations = integrations; + _identifierOptions = options.Value.UserIdentifiers; _clock = clock; } @@ -213,6 +218,17 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var userKey = context.GetTargetUserKey(); + var existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + EnsureOverrideAllowed(context); + EnsureMultipleIdentifierAllowed(request.Type, existing); + + if (request.IsPrimary) + { + // new identifiers are not verified by default, so we check against the requirement even if the request doesn't explicitly set it to true. + // This prevents adding a primary identifier that doesn't meet verification requirements. + EnsureVerificationRequirements(request.Type, isVerified: false ); + } + await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { @@ -236,6 +252,13 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) throw new InvalidOperationException("identifier_value_unchanged"); + EnsureOverrideAllowed(context); + + if (request.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + { + throw new InvalidOperationException("username_change_not_allowed"); + } + await _identifierStore.UpdateValueAsync(context.ResourceTenant, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); }); @@ -248,6 +271,15 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar { var userKey = context.GetTargetUserKey(); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + EnsureOverrideAllowed(context); + EnsureVerificationRequirements(target.Type, target.IsVerified); + await _identifierStore.SetPrimaryAsync(context.ResourceTenant, userKey, request.Type, request.Value, innerCt); }); @@ -260,6 +292,8 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr { var userKey = context.GetTargetUserKey(); + EnsureOverrideAllowed(context); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); @@ -295,6 +329,8 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde { var targetUserKey = context.GetTargetUserKey(); + EnsureOverrideAllowed(context); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); @@ -359,6 +395,45 @@ private async Task BuildUserViewAsync(TenantKey tenant, UserKey use }; } + private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyList existing) + { + bool hasSameType = existing.Any(i => !i.IsDeleted && i.Type == type); + + if (!hasSameType) + return; + + if (type == UserIdentifierType.Username && !_identifierOptions.AllowMultipleUsernames) + throw new InvalidOperationException("multiple_usernames_not_allowed"); + + if (type == UserIdentifierType.Email && !_identifierOptions.AllowMultipleEmail) + throw new InvalidOperationException("multiple_emails_not_allowed"); + + if (type == UserIdentifierType.Phone && !_identifierOptions.AllowMultiplePhone) + throw new InvalidOperationException("multiple_phones_not_allowed"); + } + + private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) + { + if (type == UserIdentifierType.Email && _identifierOptions.RequireEmailVerification && !isVerified) + { + throw new InvalidOperationException("email_verification_required"); + } + + if (type == UserIdentifierType.Phone && _identifierOptions.RequirePhoneVerification && !isVerified) + { + throw new InvalidOperationException("phone_verification_required"); + } + } + + private void EnsureOverrideAllowed(AccessContext context) + { + if (context.IsSelfAction && !_identifierOptions.AllowUserOverride) + throw new InvalidOperationException("user_override_not_allowed"); + + if (!context.IsSelfAction && !_identifierOptions.AllowAdminOverride) + throw new InvalidOperationException("admin_override_not_allowed"); + } + private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) => (from, to) switch { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs similarity index 85% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs rename to src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index e77f70c9..b864f6e8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -5,11 +5,11 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -internal sealed class UserRuntimeStore : IUserRuntimeStateProvider +internal sealed class UserRuntimeStateProvider : IUserRuntimeStateProvider { private readonly IUserLifecycleStore _lifecycleStore; - public UserRuntimeStore(IUserLifecycleStore lifecycleStore) + public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) { _lifecycleStore = lifecycleStore; } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs index f6b6547d..571cc0f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs @@ -3,6 +3,9 @@ public interface IUserSecurityState { long SecurityVersion { get; } - bool IsLocked { get; } + int FailedLoginAttempts { get; } + DateTimeOffset? LockedUntil { get; } bool RequiresReauthentication { get; } + + bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs new file mode 100644 index 00000000..bcb09246 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +internal interface IUserSecurityStateDebugView +{ + IUserSecurityState? GetState(TenantKey tenant, TUserId userId); + void Clear(TenantKey tenant, TUserId userId); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs new file mode 100644 index 00000000..103adaba --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityStateWriter +{ + Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default); + Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); + Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs new file mode 100644 index 00000000..7e3ab71a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.InMemory")] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs new file mode 100644 index 00000000..cb09ff7b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs @@ -0,0 +1,65 @@ +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientOptionsValidatorTests +{ + [Fact] + public void ClientProfile_not_specified_and_autodetect_disabled_should_fail() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.ClientProfile = UAuthClientProfile.NotSpecified; + o.AutoDetectClientProfile = false; + }); + + services.AddSingleton, UAuthClientOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*ClientProfile*AutoDetectClientProfile*"); + } + + [Fact] + public void ClientEndpoint_basepath_empty_should_fail() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Endpoints.BasePath = ""; + }); + + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () =>_ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*BasePath*"); + } + + [Fact] + public void Valid_client_options_should_pass() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.ClientProfile = UAuthClientProfile.BlazorWasm; + o.AutoDetectClientProfile = false; + o.Endpoints.BasePath = "/auth"; + }); + + services.AddSingleton, UAuthClientOptionsValidator>(); + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.ClientProfile.Should().Be(UAuthClientProfile.BlazorWasm); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs new file mode 100644 index 00000000..e94a7109 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs @@ -0,0 +1,166 @@ +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Extensions; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core; + +public sealed class ConfigurationGuardsTests +{ + [Fact] + public void Default_No_Config_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + }); + + var _ = provider.GetRequiredService>().Value; + } + + [Fact] + public void Direct_Config_With_Allow_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.AllowDirectCoreConfiguration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + }); + + var options = provider.GetRequiredService>().Value; + + Assert.Equal(TimeSpan.FromMinutes(5), options.Session.IdleTimeout); + } + + [Fact] + public void Direct_Config_Without_Allow_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Server_Without_Core_Config_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(); + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + } + + [Fact] + public void Server_With_Core_Config_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Server_With_Core_Config_Even_With_Allow_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.AllowDirectCoreConfiguration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Core_configuration_is_blocked_when_server_is_present() + { + var dict = new Dictionary + { + ["UltimateAuth:Core:Session:IdleTimeout"] = "00:05:00" + }; + + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + + var provider = Build(services => + { + services.AddSingleton(config); + services.AddUltimateAuth(); + services.AddUltimateAuthServer(); + }); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Direct core configuration is not allowed*"); + } + + [Fact] + public void Core_configuration_is_allowed_when_server_is_not_present() + { + var dict = new Dictionary + { + ["UltimateAuth:Core:AllowDirectCoreConfiguration"] = "true", + ["UltimateAuth:Core:Session:IdleTimeout"] = "00:05:00" + }; + + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + + var provider = Build(services => + { + services.AddSingleton(config); + services.AddUltimateAuth(); + }); + + var options = provider.GetRequiredService>().Value; + options.Session.IdleTimeout.Should().Be(TimeSpan.FromMinutes(5)); + } + + private sealed class FakeServerMarker : IUAuthRuntimeMarker { } + + private static IServiceProvider Build(Action configure) + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + configure(services); + return services.BuildServiceProvider(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs new file mode 100644 index 00000000..0da6e73b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs @@ -0,0 +1,70 @@ +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class OptionValidatorTests +{ + [Fact] + public void Negative_max_failed_attempts_should_fail_validation() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = -1 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Excessive_max_failed_attempts_should_fail_validation() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 1000 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Lockout_enabled_without_duration_should_fail() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 3, + LockoutMinutes = 0 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Lockout_disabled_should_allow_zero_duration() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 0, + LockoutMinutes = 0 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeTrue(); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index bf98b03d..952588b5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Tokens.InMemory; using System.Text; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 17c16dbb..d5518a18 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -45,6 +45,11 @@ public Task LoginAsync(LoginRequest request) throw new NotImplementedException(); } + public Task LoginAsync(LoginRequest request, string? returnUrl) + { + throw new NotImplementedException(); + } + public Task LogoutAsync() { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs new file mode 100644 index 00000000..67a4b94d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class AuthFlowTestFactory +{ + public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, EffectiveRedirectResponse? redirect = null) + { + return new AuthFlowContext( + flowType: AuthFlowType.Login, + clientProfile: UAuthClientProfile.BlazorServer, + effectiveMode: UAuthMode.PureOpaque, + device: TestDevice.Default(), + tenantKey: TenantKey.Single, + isAuthenticated: true, + userKey: UserKey.New(), + session: null, + originalOptions: TestServerOptions.Default(), + effectiveOptions: TestServerOptions.Effective(), + response: new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(CredentialKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.RefreshToken), + redirect: redirect ?? EffectiveRedirectResponse.Disabled + ), + primaryTokenKind: PrimaryTokenKind.Session, + returnUrlInfo: returnUrlInfo ?? ReturnUrlInfo.None() + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs new file mode 100644 index 00000000..137f63b9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestAccessContext +{ + public static AccessContext WithAction(string action) + { + return new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.Single, + isAuthenticated: false, + isSystemActor: false, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs new file mode 100644 index 00000000..99e87dbb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestAuthModeResolver : IEffectiveAuthModeResolver +{ + public UAuthMode Resolve(UAuthClientProfile profile, AuthFlowType flowType) + => UAuthMode.PureOpaque; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs new file mode 100644 index 00000000..9cbcab5c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -0,0 +1,68 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestAuthRuntime where TUserId : notnull +{ + public IServiceProvider Services { get; } + + public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) + { + var services = new ServiceCollection(); + + services.AddLogging(); + + services.AddUltimateAuth(configureCore ?? (_ => { })); + services.AddUltimateAuthServer(options => + { + configureServer?.Invoke(options); + }); + + services.AddSingleton(); + // InMemory plugins + services.AddUltimateAuthUsersInMemory(); + services.AddUltimateAuthCredentialsInMemory(); + services.AddUltimateAuthInMemorySessions(); + services.AddUltimateAuthInMemoryTokens(); + services.AddUltimateAuthAuthorizationInMemory(); + services.AddUltimateAuthAuthorizationReference(); + + services.AddScoped, LoginOrchestrator>(); + services.AddScoped(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + services.AddSingleton(configuration); + + + Services = services.BuildServiceProvider(); + Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); + } + + public ILoginOrchestrator GetLoginOrchestrator() + => Services.GetRequiredService>(); + + public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) + { + var httpContext = TestHttpContext.Create(tenant); + return Services.GetRequiredService().CreateAsync(httpContext, AuthFlowType.Login); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs new file mode 100644 index 00000000..bdf7e960 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestClientBaseAddressResolver +{ + public static ClientBaseAddressResolver Create() + { + var providers = new IClientBaseAddressProvider[] + { + new OriginHeaderBaseAddressProvider(), + new RefererHeaderBaseAddressProvider(), + new ConfiguredClientBaseAddressProvider(), + new RequestHostBaseAddressProvider(), + }; + + return new ClientBaseAddressResolver(providers); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs new file mode 100644 index 00000000..7265c154 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestDevice +{ + public static DeviceContext Default() => DeviceContext.FromDeviceId(DeviceId.Create("test-device-000-000-000-000-01")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs similarity index 59% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs index bcfe4c34..38fdf1c1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs @@ -1,8 +1,12 @@ -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Tests.Unit; +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestHelpers { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs new file mode 100644 index 00000000..e9c83b01 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHttpContext +{ + public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile clientProfile = UAuthClientProfile.NotSpecified) + { + var ctx = new DefaultHttpContext(); + + var resolvedTenant = tenant ?? TenantKey.Single; + ctx.Items[TenantMiddleware.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); + + ctx.Request.Headers["User-Agent"] = "UltimateAuth-Test"; + ctx.Request.Scheme = "https"; + ctx.Request.Host = new HostString("app.example.com"); + + return ctx; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs new file mode 100644 index 00000000..fa45dccd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHttpContextExtensions +{ + public static HttpContext WithQuery(this HttpContext ctx, string key, string value) + { + ctx.Request.QueryString = QueryString.Create(key, value); + return ctx; + } + + public static HttpContext WithHeader(this HttpContext ctx, string name, string value) + { + ctx.Request.Headers[name] = value; + return ctx; + } + + public static HttpContext WithForm(this HttpContext ctx, IDictionary form) + { + ctx.Request.ContentType = "application/x-www-form-urlencoded"; + ctx.Request.Form = new FormCollection( + form.ToDictionary( + x => x.Key, + x => new Microsoft.Extensions.Primitives.StringValues(x.Value) + ) + ); + return ctx; + } + + public static HttpContext WithReturnUrl(this HttpContext ctx, string returnUrl) + { + return ctx.WithForm(new Dictionary + { + ["return_url"] = returnUrl + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs similarity index 85% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index 4aa8dbc8..fca5e3a5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Tests.Unit; +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestIds { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs new file mode 100644 index 00000000..c6b363ec --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestPasswordHasher : IUAuthPasswordHasher +{ + public string Hash(string password) => $"HASH::{password}"; + public bool Verify(string hashedPassword, string providedPassword) => hashedPassword == $"HASH::{providedPassword}"; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs new file mode 100644 index 00000000..ce721ead --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestRedirectResolver : IAuthRedirectResolver +{ + private readonly AuthRedirectResolver _inner; + + private TestRedirectResolver(AuthRedirectResolver inner) + { + _inner = inner; + } + + public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) + => _inner.ResolveSuccess(flow, ctx); + + public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason) + => _inner.ResolveFailure(flow, ctx, reason); + + public static TestRedirectResolver Create(IEnumerable? providers = null) + { + var baseProviders = providers?.ToList() ?? new List + { + new OriginHeaderBaseAddressProvider(), + new RefererHeaderBaseAddressProvider(), + new ConfiguredClientBaseAddressProvider(), + new RequestHostBaseAddressProvider() + }; + + var baseResolver = new ClientBaseAddressResolver(baseProviders); + var authResolver = new AuthRedirectResolver(baseResolver); + + return new TestRedirectResolver(authResolver); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs new file mode 100644 index 00000000..54f182c0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestServerOptions +{ + public static UAuthServerOptions Default() + => new() + { + Hub = + { + ClientBaseAddress = "https://app.example.com" + } + }; + + public static EffectiveUAuthServerOptions Effective(UAuthMode mode = UAuthMode.PureOpaque) + => new EffectiveUAuthServerOptions + { + Mode = mode, + Options = Default() + }; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index 3c1c08f5..bb7e05d6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -11,23 +11,16 @@ public class ActionTextTests [InlineData("users.profile.get", false)] public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) { - var context = new AccessContext { Action = action }; + var context = TestAccessContext.WithAction(action); var policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); } [Fact] public void RequireAdminPolicy_DoesNotMatch_Substrings() { - var context = new AccessContext - { - Action = "users.profile.get.administrator" - }; - + var context = TestAccessContext.WithAction("users.profile.get.administrator"); var policy = new RequireAdminPolicy(); - Assert.False(policy.AppliesTo(context)); } - } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs new file mode 100644 index 00000000..9141aa56 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientBaseAddressProviderTests +{ + [Fact] + public void Resolve_Uses_Absolute_ReturnUrl() + { + var resolver = TestClientBaseAddressResolver.Create(); + var ctx = TestHttpContext.Create().WithReturnUrl("https://app.example.com/dashboard"); + + var options = new UAuthServerOptions(); + + var result = resolver.Resolve(ctx, options); + + result.Should().Be("https://app.example.com"); + } + + [Fact] + public void Resolve_Ignores_Relative_ReturnUrl() + { + var resolver = TestClientBaseAddressResolver.Create(); + + var ctx = TestHttpContext + .Create() + .WithReturnUrl("/dashboard"); + + var options = new UAuthServerOptions + { + Hub = { ClientBaseAddress = "https://fallback.example.com" } + }; + + var result = resolver.Resolve(ctx, options); + + result.Should().Be("https://fallback.example.com"); + } + + [Fact] + public void Resolve_Fails_When_Origin_Not_Allowed() + { + var resolver = TestClientBaseAddressResolver.Create(); + var ctx = TestHttpContext.Create().WithHeader("Origin", "https://evil.com"); + + var options = new UAuthServerOptions + { + Hub = + { + AllowedClientOrigins = new HashSet + { + "https://app.example.com" + } + } + }; + + Action act = () => resolver.Resolve(ctx, options); + + act.Should().Throw().WithMessage("*not allowed*"); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs index 9c27d724..9b72cccf 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -9,17 +9,6 @@ public class EffectiveAuthModeResolverTests { private readonly EffectiveAuthModeResolver _resolver = new(); - [Fact] - public void ConfiguredMode_Wins_Over_ClientProfile() - { - var mode = _resolver.Resolve( - configuredMode: UAuthMode.PureJwt, - clientProfile: UAuthClientProfile.BlazorWasm, - flowType: AuthFlowType.Login); - - Assert.Equal(UAuthMode.PureJwt, mode); - } - [Theory] [InlineData(UAuthClientProfile.BlazorServer, UAuthMode.PureOpaque)] [InlineData(UAuthClientProfile.BlazorWasm, UAuthMode.Hybrid)] @@ -27,12 +16,7 @@ public void ConfiguredMode_Wins_Over_ClientProfile() [InlineData(UAuthClientProfile.Api, UAuthMode.PureJwt)] public void Default_Mode_Is_Derived_From_ClientProfile(UAuthClientProfile profile, UAuthMode expected) { - var mode = _resolver.Resolve( - configuredMode: null, - clientProfile: profile, - flowType: AuthFlowType.Login); - + var mode = _resolver.Resolve(clientProfile: profile, flowType: AuthFlowType.Login); Assert.Equal(expected, mode); } - } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs index 17df3e17..f2d98b20 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -1,9 +1,15 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -12,104 +18,66 @@ public class EffectiveServerOptionsProviderTests [Fact] public void Original_Options_Are_Not_Mutated() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var ctx = TestHttpContext.Create(); - effective.Options.Tokens.AccessTokenLifetime = TimeSpan.FromSeconds(10); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + effective.Options.Token.AccessTokenLifetime = TimeSpan.FromSeconds(10); - Assert.NotEqual( - baseOptions.Tokens.AccessTokenLifetime, - effective.Options.Tokens.AccessTokenLifetime - ); + Assert.NotEqual(baseOptions.Token.AccessTokenLifetime, effective.Options.Token.AccessTokenLifetime); } - [Fact] - public void EffectiveMode_Comes_From_ModeResolver() + public void EffectiveMode_Is_Determined_By_ModeResolver() { - var baseOptions = new UAuthServerOptions - { - Mode = null // Not specified - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.Api); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.Api); Assert.Equal(UAuthMode.PureJwt, effective.Mode); } [Fact] - public void Mode_Defaults_Are_Applied() + public void Mode_Defaults_Are_Applied_Before_Overrides() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.PureOpaque - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); Assert.True(effective.Options.Session.SlidingExpiration); Assert.NotNull(effective.Options.Session.IdleTimeout); } [Fact] - public void ModeConfiguration_Overrides_Defaults() + public void ModeConfiguration_Overrides_Mode_Defaults() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); - baseOptions.ConfigureMode(UAuthMode.Hybrid, o => + baseOptions.ConfigureMode(UAuthMode.PureOpaque, o => { - o.Tokens.AccessTokenLifetime = TimeSpan.FromMinutes(1); + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(1); }); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); - - Assert.Equal( - TimeSpan.FromMinutes(1), - effective.Options.Tokens.AccessTokenLifetime - ); + Assert.Equal(TimeSpan.FromMinutes(1), effective.Options.Token.AccessTokenLifetime); } [Fact] public void Each_Call_Returns_New_EffectiveOptions_Instance() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var ctx = TestHttpContext.Create(); var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs new file mode 100644 index 00000000..b237d8fb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -0,0 +1,420 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.InMemory; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System.Security; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class LoginOrchestratorTests +{ + [Fact] + public async Task Successful_login_should_return_success_result() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Successful_login_should_create_session() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + result.SessionId.Should().NotBeNull(); + } + + [Fact] + public async Task First_failed_login_should_record_attempt() + { + var runtime = new TestAuthRuntime(configureCore: o => + { + o.Login.MaxFailedAttempts = 3; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.FailedLoginAttempts.Should().Be(1); + } + + [Fact] + public async Task Successful_login_should_clear_failure_state() + { + var runtime = new TestAuthRuntime(configureCore: o => + { + o.Login.MaxFailedAttempts = 3; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", // valid password + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + + var state = store.GetState(TenantKey.Single,UserKey.Parse("user", null)); + state.Should().BeNull(); + } + + [Fact] + public async Task Invalid_password_should_fail_login() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Non_existent_user_should_fail_login_gracefully() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "ghost", + Secret = "whatever", + Device = TestDevice.Default(), + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task MaxFailedAttempts_one_should_lock_user_on_first_fail() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.IsLocked.Should().BeTrue(); + } + + [Fact] + public async Task Locked_user_should_not_login_even_with_correct_password() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + // lock + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + // try again with correct password + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Locked_user_should_not_increment_failed_attempts() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); + } + + [Fact] + public async Task MaxFailedAttempts_zero_should_disable_lockout() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 0; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + for (int i = 0; i < 5; i++) + { + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + } + + var store = runtime.Services.GetRequiredService>(); + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.IsLocked.Should().BeFalse(); + state.FailedLoginAttempts.Should().Be(5); + } + + [Fact] + public async Task Invalid_device_id_should_throw_security_exception() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + Func act = () => orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = DeviceContext.FromDeviceId(DeviceId.Create("x")), // too short + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Locked_user_failed_login_should_not_extend_lockout_duration() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + o.Login.LockoutMinutes = 15; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + var lockedUntil = state1!.LockedUntil; + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + state2!.LockedUntil.Should().Be(lockedUntil); + } + + [Fact] + public async Task Login_success_should_trigger_UserLoggedIn_event() + { + // arrange + UserLoggedInContext? captured = null; + + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnUserLoggedIn = ctx => + { + captured = ctx; + return Task.CompletedTask; + }; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + // act + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + // assert + captured.Should().NotBeNull(); + captured!.UserKey.Should().Be(UserKey.Parse("user", null)); + } + + [Fact] + public async Task Login_success_should_trigger_OnAnyEvent() + { + var count = 0; + + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnAnyEvent = _ => + { + count++; + return Task.CompletedTask; + }; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Event_handler_exception_should_not_break_login_flow() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnUserLoggedIn = _ => throw new Exception("boom"); + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs new file mode 100644 index 00000000..58bd4dcd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -0,0 +1,199 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class RedirectTests +{ + [Fact] + public void LoginFlow_Uses_Configured_Redirect_Options() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.Configure(o => + { + o.AuthResponse.Login.AllowReturnUrlOverride = false; + o.AuthResponse.Login.SuccessRedirect = "/welcome"; + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + var optionsProvider = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + + var effective = optionsProvider.GetEffective(TenantKey.Single, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var response = resolver.Resolve(effective.Mode, AuthFlowType.Login, UAuthClientProfile.BlazorServer, effective); + + response.Redirect.AllowReturnUrlOverride.Should().BeFalse(); + response.Redirect.SuccessPath.Should().Be("/welcome"); + } + + [Fact] + public void ClientProfile_Is_Read_From_Header() + { + var reader = new ClientProfileReader(); + var ctx = TestHttpContext.Create(); + ctx.Request.Headers["X-UAuth-ClientProfile"] = "BlazorServer"; + + var profile = reader.Read(ctx); + profile.Should().Be(UAuthClientProfile.BlazorServer); + } + + [Theory] + [InlineData(UAuthClientProfile.BlazorWasm, AuthFlowType.Login, UAuthMode.Hybrid)] + [InlineData(UAuthClientProfile.BlazorServer, AuthFlowType.Login, UAuthMode.PureOpaque)] + public void ClientProfile_Resolves_To_Correct_Mode(UAuthClientProfile profile, AuthFlowType flow, UAuthMode expected) + { + var resolver = new EffectiveAuthModeResolver(); + var mode = resolver.Resolve(profile, flow); + mode.Should().Be(expected); + } + + [Fact] + public void Absolute_ReturnUrl_Is_Used_When_Override_Allowed() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://app.example.com/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.Enabled.Should().BeTrue(); + decision.TargetUrl.Should().Be("https://app.example.com/dashboard"); + } + + [Fact] + public void Absolute_ReturnUrl_Is_Ignored_When_Override_Disabled() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://app.example.com/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/welcome"); + } + + [Fact] + public void Relative_ReturnUrl_Is_Combined_With_BaseAddress() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); // https://app.example.com + + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/dashboard"); + } + + [Fact] + public void SuccessPath_Is_Used_When_No_ReturnUrl() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: null, + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/welcome"); + } + + [Fact] + public void Absolute_ReturnUrl_Outside_AllowedOrigins_Throws() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://evil.com"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + flow.OriginalOptions.Hub.AllowedClientOrigins.Add("https://app.example.com"); + Action act = () => resolver.ResolveSuccess(flow, ctx); + act.Should().Throw().WithMessage("*not allowed*"); + } + + [Fact] + public void Failure_Redirect_Contains_Mapped_Error_Code() + { + var redirect = new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: "/login", + failureQueryKey: "error", + failureCodes: new Dictionary + { + [AuthFailureReason.InvalidCredentials] = "bad_credentials" + }, + allowReturnUrlOverride: false + ); + + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: null, + redirect: redirect + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveFailure(flow, ctx, AuthFailureReason.InvalidCredentials); + decision.TargetUrl.Should().Be("https://app.example.com/login?error=bad_credentials"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs new file mode 100644 index 00000000..e3968775 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class ReturnUrlParserTests +{ + [Fact] + public void Relative_Path_Is_Not_Treated_As_Absolute_Uri() + { + var input = "/dashboard"; + var info = ReturnUrlParser.Parse(input); + + info.Should().NotBeNull(); + info!.IsAbsolute.Should().BeFalse(); + info.RelativePath.Should().Be("/dashboard"); + info.AbsoluteUri.Should().BeNull(); + } + + [Fact] + public void Absolute_Https_Url_Is_Treated_As_Absolute() + { + var input = "https://app.example.com/dashboard"; + var info = ReturnUrlParser.Parse(input); + + info.Should().NotBeNull(); + info!.IsAbsolute.Should().BeTrue(); + info.AbsoluteUri!.ToString().Should().Be(input); + info.RelativePath.Should().BeNull(); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html;base64,AAA")] + [InlineData("file:///etc/passwd")] + [InlineData("ftp://evil.com")] + public void Parser_Rejects_Unsafe_Schemes(string returnUrl) + { + Action act = () => ReturnUrlParser.Parse(returnUrl); + act.Should().Throw().WithMessage("*Invalid returnUrl*"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs new file mode 100644 index 00000000..f25dfb04 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -0,0 +1,362 @@ +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ServerOptionsValidatorTests +{ + [Fact] + public void Server_session_options_with_negative_idle_timeout_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Session.IdleTimeout = TimeSpan.FromSeconds(-5); + }); + + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + + services.AddOptions().ValidateOnStart(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Session.IdleTimeout*"); + } + + [Fact] + public void Valid_server_session_options_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(30); + o.Session.IdleTimeout = TimeSpan.FromMinutes(10); + }); + + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + + services.AddOptions().ValidateOnStart(); + + var provider = services.BuildServiceProvider(); + + provider.Should().NotBeNull(); + } + + [Fact] + public void Server_token_options_with_small_opaque_id_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Token.IssueOpaque = true; + o.Token.OpaqueIdBytes = 8; + }); + + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*OpaqueIdBytes*"); + } + + [Fact] + public void Valid_server_token_options_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Token.IssueJwt = true; + o.Token.IssueOpaque = true; + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5); + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(1); + o.Token.OpaqueIdBytes = 32; + }); + + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + options.Should().NotBeNull(); + } + + [Fact] + public void Pkce_authorization_code_lifetime_must_be_positive() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.Pkce.AuthorizationCodeLifetimeSeconds = 0; + }); + + services.AddSingleton, UAuthServerPkceOptionsValidator>(); + var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws(() => + { + _ = provider.GetRequiredService>().Value; + }); + + Assert.Contains("Pkce.AuthorizationCodeLifetimeSeconds must be > 0", ex.Message); + } + + [Fact] + public void MultiTenant_enabled_without_resolver_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableRoute = false; + o.MultiTenant.EnableHeader = false; + o.MultiTenant.EnableDomain = false; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*no tenant resolver is active*"); + } + + [Fact] + public void MultiTenant_disabled_with_resolver_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = false; + o.MultiTenant.EnableRoute = true; // no-meaning if multi-tenancy is disabled + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Multi-tenancy is disabled*"); + } + + [Fact] + public void Header_enabled_without_header_name_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableHeader = true; + o.MultiTenant.HeaderName = ""; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*HeaderName must be specified*"); + } + + [Fact] + public void Valid_multi_tenant_route_only_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableRoute = true; + o.MultiTenant.EnableHeader = false; + o.MultiTenant.EnableDomain = false; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + options.MultiTenant.Enabled.Should().BeTrue(); + options.MultiTenant.EnableRoute.Should().BeTrue(); + } + + [Fact] + public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.UserIdentifiers.AllowAdminOverride = false; + o.UserIdentifiers.AllowUserOverride = false; + }); + + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*AllowAdminOverride and AllowUserOverride*"); + } + + [Fact] + public void UserIdentifiers_at_least_one_override_enabled_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.UserIdentifiers.AllowAdminOverride = true; + o.UserIdentifiers.AllowUserOverride = false; + }); + + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.UserIdentifiers.AllowAdminOverride.Should().BeTrue(); + } + + [Fact] + public void No_session_resolver_enabled_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = false; + o.SessionResolution.EnableHeader = false; + o.SessionResolution.EnableCookie = false; + o.SessionResolution.EnableQuery = false; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*At least one session resolver must be enabled*"); + } + + [Fact] + public void Disabled_resolver_in_order_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.EnableQuery = false; + o.SessionResolution.Order = new() { "Bearer", "Query" }; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*not enabled*"); + } + + [Fact] + public void Header_enabled_without_name_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableHeader = true; + o.SessionResolution.HeaderName = ""; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*HeaderName*"); + } + + [Fact] + public void Valid_session_resolution_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.EnableHeader = false; + o.SessionResolution.EnableCookie = false; + o.SessionResolution.EnableQuery = false; + o.SessionResolution.Order = new() { "Bearer" }; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.SessionResolution.EnableBearer.Should().BeTrue(); + } +}