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
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