+
+
+
+
+
+
+
+
+
+
+ Access Denied
+
+
+ This page cannot be accessed directly.
+ UAuthHub login flows can only be initiated by an authorized client application.
+
+
+
+
+
+ UltimateAuth protects this resource based on your session and permissions.
+
+
+
+
+ return;
+}
+
+
+
+
+ Nav.NavigateTo("/home", true))">UltimateAuth
+
+ UAuthHub Sample
+
+
+
+
+
+
+
+
+
+ context.ToggleAsync())">
+
+
+ @((state.Identity?.DisplayName ?? "?").Trim() is var n ? (n.Length >= 2 ? n[..2] : n[..1]) : "?")
+
+
+
+
+
+
+ @state.Identity?.DisplayName
+ @string.Join(", ", state.Claims.Roles)
+
+
+
+
+
+
+
+ @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active)
+ {
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
index d9123d59..7242adbd 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
@@ -1,7 +1,130 @@
-namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Errors;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure;
+using Microsoft.AspNetCore.Components;
+using MudBlazor;
+
+namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout;
+
+public partial class MainLayout
{
- public partial class MainLayout
+ [CascadingParameter]
+ public UAuthState UAuth { get; set; } = default!;
+
+ [CascadingParameter]
+ public DarkModeManager DarkModeManager { get; set; } = default!;
+
+ private async Task Refresh()
{
-
+ await UAuthClient.Flows.RefreshAsync();
+ }
+
+ private async Task Logout()
+ {
+ await UAuthClient.Flows.LogoutAsync();
+ }
+
+ private Color GetBadgeColor()
+ {
+ if (UAuth is null || !UAuth.IsAuthenticated)
+ return Color.Error;
+
+ if (UAuth.IsStale)
+ return Color.Warning;
+
+ var state = UAuth.Identity?.SessionState;
+
+ if (state is null || state == SessionState.Active)
+ return Color.Success;
+
+ if (state == SessionState.Invalid)
+ return Color.Error;
+
+ return Color.Warning;
+ }
+
+ private void HandleSignInClick()
+ {
+ var uri = Nav.ToAbsoluteUri(Nav.Uri);
+
+ if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase))
+ {
+ Nav.NavigateTo("/login?focus=1", replace: true, forceLoad: true);
+ return;
+ }
+
+ GoToLoginWithReturn();
+ }
+
+ private async Task Validate()
+ {
+ try
+ {
+ var result = await UAuthClient.Flows.ValidateAsync();
+
+ if (result.IsValid)
+ {
+ if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended)
+ {
+ Snackbar.Add("Your account is suspended by you.", Severity.Warning);
+ return;
+ }
+ Snackbar.Add($"Session active • Tenant: {result.Snapshot?.Identity?.Tenant.Value} • User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success);
+ }
+ else
+ {
+ switch (result.State)
+ {
+ case SessionState.Expired:
+ Snackbar.Add("Session expired. Please sign in again.", Severity.Warning);
+ break;
+
+ case SessionState.DeviceMismatch:
+ Snackbar.Add("Session invalid for this device.", Severity.Error);
+ break;
+
+ default:
+ Snackbar.Add($"Session state: {result.State}", Severity.Error);
+ break;
+ }
+ }
+ }
+ catch (UAuthTransportException)
+ {
+ Snackbar.Add("Network error.", Severity.Error);
+ }
+ catch (UAuthProtocolException)
+ {
+ Snackbar.Add("Invalid response.", Severity.Error);
+ }
+ catch (UAuthException ex)
+ {
+ Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error);
+ }
+ }
+
+ private void GoToLoginWithReturn()
+ {
+ var uri = Nav.ToAbsoluteUri(Nav.Uri);
+
+ if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase))
+ {
+ Nav.NavigateTo("/login", replace: true);
+ return;
+ }
+
+ var current = Nav.ToBaseRelativePath(uri.ToString());
+ if (string.IsNullOrWhiteSpace(current))
+ current = "home";
+
+ var returnUrl = Uri.EscapeDataString("/" + current.TrimStart('/'));
+ Nav.NavigateTo($"/login?returnUrl={returnUrl}", replace: true);
}
}
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 219617bf..b1720a39 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor
@@ -1,82 +1,103 @@
@page "/"
@page "/login"
-@using CodeBeam.UltimateAuth.Client
-@using CodeBeam.UltimateAuth.Client.Authentication
-@using CodeBeam.UltimateAuth.Client.Diagnostics
+@attribute [UAuthLoginPage]
+@inherits UAuthHubPageBase
+
+@implements IDisposable
@using CodeBeam.UltimateAuth.Client.Infrastructure
+@using CodeBeam.UltimateAuth.Client.Options
+@using CodeBeam.UltimateAuth.Client.Runtime
@using CodeBeam.UltimateAuth.Core.Abstractions
@using CodeBeam.UltimateAuth.Core.Contracts
-@using CodeBeam.UltimateAuth.Core.Domain
-@using CodeBeam.UltimateAuth.Core.Runtime
-@using CodeBeam.UltimateAuth.Server.Abstractions
-@using CodeBeam.UltimateAuth.Server.Infrastructure
@using CodeBeam.UltimateAuth.Server.Services
@using CodeBeam.UltimateAuth.Server.Stores
-@inject IUAuthStateManager StateManager
-@inject IHubFlowReader HubFlowReader
-@inject IHubCredentialResolver HubCredentialResolver
+@using Microsoft.Extensions.Options
+@inject IUAuthClient UAuthClient
@inject IAuthStore AuthStore
+@inject IHubFlowService HubFlowService
+@inject IPkceService PkceService
+@inject IHubCredentialResolver HubCredentialResolver
@inject IClientStorage BrowserStorage
-@inject IUAuthFlowService Flow
@inject ISnackbar Snackbar
-@inject IFlowCredentialResolver CredentialResolver
-@inject IUAuthClient UAuthClient
-@inject NavigationManager Nav
-@inject IUAuthProductInfoProvider ProductInfo
-@inject AuthenticationStateProvider AuthStateProvider
-@inject UAuthClientDiagnostics Diagnostics
-
-
-
-
- @if (_state == null || !_state.IsActive)
- {
-
-
-
-
+@inject IUAuthClientProductInfoProvider ClientProductInfoProvider
+@inject IDeviceIdProvider DeviceIdProvider
+@inject IDialogService DialogService
+@inject IOptions Options
- Access Denied
-
-
- This page cannot be accessed directly.
- UAuthHub login flows can only be initiated by an authorized client application.
-
+
+
+
+
+
+
+
+
+
+ UAuthHub
+ The centralized AuthServer for your application.
+
-
+
-
- UltimateAuth protects this resource based on your session and permissions.
-
+
+ @_productInfo?.ProductName v @_productInfo?.Version
+ Client Profile: @_productInfo?.ClientProfile.ToString()
+ @_productInfo?.FrameworkDescription
-
- return;
- }
-
-
- Welcome to UltimateAuth!
-
-
- Login
-
-
+
+
+
+
+
+
+
+
+
+ Sign In
+
+
+
+
+ Login
+ @if (_isLocked)
+ {
+
+
+
+ Your account is locked.
+ Try again in
+
+
+ @_remaining.Minutes.ToString("00"):@_remaining.Seconds.ToString("00")
+
+
+
+ }
+
+
+
+ Default sample users:
+ admin/admin (Admin),
+ user/user (Standard User)
+
-
- Programmatic Pkce Login
-
+
-
- @ProductInfo.Get().ProductName v @ProductInfo.Get().Version
-
+
+ Programmatic Login
+ Login programmatically as admin/admin.
+
-
- Hub SessionId: @_state?.HubSessionId
- Client Profile: @_state?.ClientProfile
- Return Url: @_state?.ReturnUrl
- Flow Type: @_state?.FlowType
- IsActive: @_state?.IsActive
-
-
-
+ @*
*@
+
+ @* TODO: Enhance sample *@
+ @* Forgot Password *@
+ @* Don't have an account? SignUp *@
+
+
+
+
+
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
index dc0f988e..c3258e6a 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
@@ -1,75 +1,74 @@
-using CodeBeam.UltimateAuth.Client.Contracts;
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Blazor;
+using CodeBeam.UltimateAuth.Client.Runtime;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Stores;
-using Microsoft.AspNetCore.Components;
-using Microsoft.AspNetCore.WebUtilities;
using MudBlazor;
namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages;
public partial class Home
{
- [SupplyParameterFromQuery(Name = "hub")]
- public string? HubKey { get; set; }
-
private string? _username;
private string? _password;
- private HubFlowState? _state;
+ private UAuthClientProductInfo? _productInfo;
+ private UAuthLoginForm _loginForm = null!;
- protected override async Task OnParametersSetAsync()
- {
- if (string.IsNullOrWhiteSpace(HubKey))
- {
- _state = null;
- return;
- }
+ private CancellationTokenSource? _lockoutCts;
+ private PeriodicTimer? _lockoutTimer;
+ private DateTimeOffset? _lockoutUntil;
+ private TimeSpan _remaining;
+ private bool _isLocked;
+ private DateTimeOffset? _lockoutStartedAt;
+ private TimeSpan _lockoutDuration;
+ private double _progressPercent;
+ private int? _remainingAttempts = null;
+ private bool _errorHandled;
- if (HubSessionId.TryParse(HubKey, out var hubSessionId))
- _state = await HubFlowReader.GetStateAsync(hubSessionId);
+ protected override async Task OnInitializedAsync()
+ {
+ _productInfo = ClientProductInfoProvider.Get();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- if (!firstRender)
+ if (string.IsNullOrWhiteSpace(HubKey))
return;
- var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error");
-
- if (!string.IsNullOrWhiteSpace(currentError))
+ if (HubState is null || !HubState.Exists)
{
- Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error);
- await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error");
+ return;
}
- var uri = Nav.ToAbsoluteUri(Nav.Uri);
- var query = QueryHelpers.ParseQuery(uri.Query);
-
- if (query.TryGetValue("__uauth_error", out var error))
- {
- await BrowserStorage.SetAsync(StorageScope.Session, "uauth:last_error", error.ToString());
- }
-
- if (string.IsNullOrWhiteSpace(HubKey))
+ if (HubState.IsExpired)
{
+ await ContinuePkceAsync();
return;
}
- if (_state is null || !_state.Exists)
- return;
-
- if (_state?.IsActive != true)
+ if (HubState.Error != null && !_errorHandled)
{
- await StartNewPkceAsync();
- return;
+ _errorHandled = true;
+ Snackbar.Add(ResolveErrorMessage(HubState.Error), Severity.Error);
+ await ContinuePkceAsync();
+
+ if (HubSessionId.TryParse(HubKey, out var hubSessionId))
+ {
+ await ReloadState();
+ }
+
+ await _loginForm.ReloadAsync();
+
+ StateHasChanged();
}
}
// For testing & debugging
private async Task ProgrammaticPkceLogin()
{
- var hub = _state;
+ var hub = HubState;
if (hub is null)
return;
@@ -79,15 +78,54 @@ private async Task ProgrammaticPkceLogin()
var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId);
- var request = new PkceLoginRequest
+ var request = new PkceCompleteRequest
{
Identifier = "admin",
Secret = "admin",
AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty,
CodeVerifier = credentials?.CodeVerifier ?? string.Empty,
- ReturnUrl = _state?.ReturnUrl ?? string.Empty
+ ReturnUrl = HubState?.ReturnUrl ?? string.Empty,
+ HubSessionId = HubState?.HubSessionId.Value ?? hubSessionId.Value,
};
- await UAuthClient.Flows.CompletePkceLoginAsync(request);
+
+ await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryAndCommit);
+ }
+
+ private async Task HandleLoginResult(IUAuthTryResult result)
+ {
+ if (result is TryPkceLoginResult pkce)
+ {
+ if (!result.Success)
+ {
+ if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
+ {
+ _lockoutUntil = until;
+ StartCountdown();
+ }
+
+ _remainingAttempts = result.RemainingAttempts;
+
+ ShowLoginError(result.Reason, result.RemainingAttempts);
+ await ContinuePkceAsync();
+ }
+ }
+ }
+
+ private HubCredentials? _pkce;
+
+ private async Task ContinuePkceAsync()
+ {
+ if (string.IsNullOrWhiteSpace(HubKey))
+ return;
+
+ var key = new AuthArtifactKey(HubKey);
+ var artifact = await AuthStore.GetAsync(key) as HubFlowArtifact;
+
+ if (artifact is null)
+ return;
+
+ _pkce = await PkceService.RefreshAsync(artifact);
+ await HubFlowService.ContinuePkceAsync(HubKey, _pkce.AuthorizationCode, _pkce.CodeVerifier);
}
private async Task StartNewPkceAsync()
@@ -98,7 +136,7 @@ private async Task StartNewPkceAsync()
private async Task
ResolveReturnUrlAsync()
{
- var fromContext = _state?.ReturnUrl;
+ var fromContext = HubState?.ReturnUrl;
if (!string.IsNullOrWhiteSpace(fromContext))
return fromContext;
@@ -115,18 +153,106 @@ private async Task ResolveReturnUrlAsync()
return flow.ReturnUrl!;
}
- // Config default (recommend adding to options)
- //if (!string.IsNullOrWhiteSpace(_options.Login.DefaultReturnUrl))
- // return _options.Login.DefaultReturnUrl!;
-
return Nav.Uri;
}
-
- private string ResolveErrorMessage(string? errorKey)
+
+ private async void StartCountdown()
+ {
+ if (_lockoutUntil is null)
+ return;
+
+ _isLocked = true;
+ _lockoutStartedAt = DateTimeOffset.UtcNow;
+ _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow;
+ UpdateRemaining();
+
+ _lockoutCts?.Cancel();
+ _lockoutCts = new CancellationTokenSource();
+
+ _lockoutTimer?.Dispose();
+ _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1));
+
+ try
+ {
+ while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token))
+ {
+ UpdateRemaining();
+
+ if (_remaining <= TimeSpan.Zero)
+ {
+ ResetLockoutState();
+ await InvokeAsync(StateHasChanged);
+ break;
+ }
+
+ await InvokeAsync(StateHasChanged);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+
+ }
+ }
+
+ private void ResetLockoutState()
+ {
+ _isLocked = false;
+ _lockoutUntil = null;
+ _progressPercent = 0;
+ _remainingAttempts = null;
+ }
+
+ private void UpdateRemaining()
+ {
+ if (_lockoutUntil is null || _lockoutStartedAt is null)
+ return;
+
+ var now = DateTimeOffset.UtcNow;
+
+ _remaining = _lockoutUntil.Value - now;
+
+ if (_remaining <= TimeSpan.Zero)
+ {
+ _remaining = TimeSpan.Zero;
+ return;
+ }
+
+ var elapsed = now - _lockoutStartedAt.Value;
+
+ if (_lockoutDuration.TotalSeconds > 0)
+ {
+ var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100);
+ _progressPercent = Math.Max(0, percent);
+ }
+ }
+
+ private void ShowLoginError(AuthFailureReason? reason, int? remainingAttempts)
+ {
+ string message = reason switch
+ {
+ AuthFailureReason.InvalidCredentials when remainingAttempts is > 0
+ => $"Invalid username or password. {remainingAttempts} attempt(s) remaining.",
+
+ AuthFailureReason.InvalidCredentials
+ => "Invalid username or password.",
+
+ AuthFailureReason.RequiresMfa
+ => "Multi-factor authentication required.",
+
+ AuthFailureReason.LockedOut
+ => "Your account is locked.",
+
+ _ => "Login failed."
+ };
+
+ Snackbar.Add(message, Severity.Error);
+ }
+
+ private string ResolveErrorMessage(HubErrorCode? errorCode)
{
- if (errorKey == "invalid")
+ if (errorCode == HubErrorCode.InvalidCredentials)
{
- return "Login failed.";
+ return "Invalid credentials.";
}
return "Failed attempt.";
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
index aada4df3..f530884f 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
@@ -10,6 +10,7 @@
@using CodeBeam.UltimateAuth.Sample.UAuthHub
@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components
@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout
+@using CodeBeam.UltimateAuth.Core.Domain
@using CodeBeam.UltimateAuth.Client
@using CodeBeam.UltimateAuth.Client.Blazor
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs
deleted file mode 100644
index 71cb29b3..00000000
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using CodeBeam.UltimateAuth.Core.Options;
-using CodeBeam.UltimateAuth.Server.Options;
-using CodeBeam.UltimateAuth.Server.Stores;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
-
-namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Controllers;
-
-[Route("auth/uauthhub")]
-[IgnoreAntiforgeryToken]
-public sealed class HubLoginController : Controller
-{
- private readonly IAuthStore _authStore;
- private readonly UAuthServerOptions _options;
- private readonly IClock _clock;
-
- public HubLoginController(IAuthStore authStore, IOptions options, IClock clock)
- {
- _authStore = authStore;
- _options = options.Value;
- _clock = clock;
- }
-
- [HttpPost("login")]
- [IgnoreAntiforgeryToken]
- public async Task BeginLogin(
- [FromForm] string authorization_code,
- [FromForm] string code_verifier,
- [FromForm] UAuthClientProfile client_profile,
- [FromForm] string? return_url)
- {
- var hubSessionId = HubSessionId.New();
-
- var payload = new HubFlowPayload();
- payload.Set("authorization_code", authorization_code);
- payload.Set("code_verifier", code_verifier);
-
- var artifact = new HubFlowArtifact(
- hubSessionId: hubSessionId,
- flowType: HubFlowType.Login,
- clientProfile: client_profile,
- tenant: TenantKeys.System,
- returnUrl: return_url,
- payload: payload,
- expiresAt: _clock.UtcNow.Add(_options.Hub.FlowLifetime));
-
- await _authStore.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact, HttpContext.RequestAborted);
-
- return Redirect($"{_options.Hub.LoginPath}?hub={hubSessionId.Value}");
- }
-}
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs
deleted file mode 100644
index eb5fe640..00000000
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Runtime;
-
-internal sealed class DefaultUAuthHubMarker : IUAuthHubMarker
-{
-}
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs
index d5c689a8..47a813c8 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs
@@ -2,11 +2,9 @@
using CodeBeam.UltimateAuth.Client.Blazor.Extensions;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
-using CodeBeam.UltimateAuth.Core.Runtime;
using CodeBeam.UltimateAuth.InMemory;
using CodeBeam.UltimateAuth.Sample.UAuthHub.Components;
using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure;
-using CodeBeam.UltimateAuth.Security.Argon2;
using CodeBeam.UltimateAuth.Server.Extensions;
using MudBlazor.Services;
using MudExtensions.Services;
@@ -14,28 +12,34 @@
var builder = WebApplication.CreateBuilder(args);
-// Add services to the container.
builder.Services.AddRazorComponents()
- .AddInteractiveServerComponents();
-
-builder.Services.AddControllers();
+ .AddInteractiveServerComponents()
+ .AddCircuitOptions(options =>
+ {
+ options.DetailedErrors = true;
+ });
builder.Services.AddMudServices(o => {
o.SnackbarConfiguration.PreventDuplicates = false;
});
builder.Services.AddMudExtensions();
-//builder.Services.AddAuthorization();
-
-//builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
builder.Services.AddUltimateAuthServer(o => {
o.Diagnostics.EnableRefreshDetails = true;
//o.Session.MaxLifetime = TimeSpan.FromSeconds(32);
+ //o.Session.Lifetime = TimeSpan.FromSeconds(32);
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
+ //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
+ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
+ o.Login.MaxFailedAttempts = 2;
+ o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
+ o.Identifiers.AllowMultipleUsernames = true;
})
- .AddUltimateAuthInMemory();
+ .AddUltimateAuthInMemory()
+ .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL
builder.Services.AddUltimateAuthClientBlazor(o =>
{
@@ -43,28 +47,11 @@
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
});
-builder.Services.AddSingleton();
-builder.Services.AddScoped();
-
-builder.Services.AddCors(options =>
-{
- options.AddPolicy("WasmSample", policy =>
- {
- policy
- .WithOrigins("https://localhost:6130")
- .AllowAnyHeader()
- .AllowAnyMethod()
- .AllowCredentials()
- .WithExposedHeaders("X-UAuth-Refresh"); // TODO: Add exposed headers globally
- });
-});
-
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
- // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
else
@@ -78,26 +65,16 @@
}
app.UseHttpsRedirection();
-app.UseCors("WasmSample");
app.UseUltimateAuthWithAspNetCore();
app.UseAntiforgery();
app.MapUltimateAuthEndpoints();
+app.MapUAuthHub();
app.MapStaticAssets();
-app.MapControllers();
app.MapRazorComponents()
.AddInteractiveServerRenderMode()
.AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());
-app.MapGet("/health", () =>
-{
- return Results.Ok(new
- {
- service = "UAuthHub",
- status = "ok"
- });
-});
-
app.Run();
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css
index 671b6199..17fcfd6a 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css
@@ -13,7 +13,7 @@ a, .btn-link {
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
- box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
+ box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
@@ -50,15 +50,6 @@ h1:focus {
border-color: #929292;
}
-.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
- color: var(--bs-secondary-color);
- text-align: end;
-}
-
-.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
- text-align: start;
-}
-
.uauth-stack {
min-height: 60vh;
max-height: calc(100vh - var(--mud-appbar-height));
@@ -79,6 +70,11 @@ h1:focus {
color: white;
}
+ .uauth-login-paper.mud-theme-secondary {
+ background: linear-gradient(145deg, var(--mud-palette-secondary), rgba(0, 0, 0, 0.85) );
+ color: white;
+ }
+
.uauth-brand-glow {
filter: drop-shadow(0 0 25px rgba(255,255,255,0.15));
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
index 7687854b..f1d587c7 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
@@ -73,7 +73,7 @@
-
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
index 29f37744..0cbc8441 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
@@ -52,7 +52,6 @@ private void HandleLoginPayload(AuthFlowPayload payload)
}
_remainingAttempts = payload.RemainingAttempts;
-
ShowLoginError(payload.Reason, payload.RemainingAttempts);
}
@@ -158,6 +157,28 @@ private void UpdateRemaining()
}
}
+ private void HandleTry(IUAuthTryResult result)
+ {
+ if (result is TryLoginResult pkce)
+ {
+ if (!result.Success)
+ {
+ if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
+ {
+ _lockoutUntil = until;
+ StartCountdown();
+ }
+
+ _remainingAttempts = result.RemainingAttempts;
+ ShowLoginError(result.Reason, result.RemainingAttempts);
+ }
+ }
+ else
+ {
+ Snackbar.Add("Unexpected result type.", Severity.Error);
+ }
+ }
+
private async Task OpenResetDialog()
{
await DialogService.ShowAsync("Reset Credentials", GetDialogParameters(), GetDialogOptions());
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
index c82f0e04..5c3245d9 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
@@ -86,7 +86,7 @@
Instead of use PKCE flow.
This section only demonstrates that direct login flow is working, but not suggested.
-
+
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
index 459081f1..b644ee9a 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
@@ -99,6 +99,21 @@ private async Task ProgrammaticLogin()
await UAuthClient.Flows.LoginAsync(request, ReturnUrl ?? "/home");
}
+ private async Task HandleLoginResult(IUAuthTryResult result)
+ {
+ if (!result.Success)
+ {
+ if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
+ {
+ _lockoutUntil = until;
+ StartCountdown();
+ }
+
+ _remainingAttempts = result.RemainingAttempts;
+ ShowLoginError(result.Reason, result.RemainingAttempts);
+ }
+ }
+
private async void StartCountdown()
{
if (_lockoutUntil is null)
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs
new file mode 100644
index 00000000..bd5c1d72
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions;
+
+public interface IUserAgentParser
+{
+ UserAgentInfo Parse(string? userAgent);
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs
new file mode 100644
index 00000000..1fee5de4
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs
@@ -0,0 +1,11 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public interface IUAuthTryResult
+{
+ bool Success { get; }
+ AuthFailureReason? Reason { get; }
+ int? RemainingAttempts { get; }
+ DateTimeOffset? LockoutUntilUtc { get; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
index c85b9b92..ae45a071 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
@@ -1,15 +1,12 @@
using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record LoginRequest
{
- public TenantKey Tenant { get; init; }
public string Identifier { get; init; } = default!;
public string Secret { get; init; } = default!;
public CredentialType Factor { get; init; } = CredentialType.Password;
- public DateTimeOffset? At { get; init; }
public IReadOnlyDictionary? Metadata { get; init; }
///
@@ -17,4 +14,5 @@ public sealed record LoginRequest
/// Server policy may still ignore this.
///
public bool RequestTokens { get; init; } = true;
+ public string? PreviewReceipt { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs
index f9d3b280..f445bf58 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs
@@ -36,6 +36,14 @@ public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens =
RefreshToken = tokens?.RefreshToken
};
+ public static LoginResult SuccessPreview()
+ {
+ return new LoginResult
+ {
+ Status = LoginStatus.Success
+ };
+ }
+
public static LoginResult Continue(LoginContinuation continuation)
=> new()
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs
new file mode 100644
index 00000000..f91c14ab
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed record TryLoginResult : IUAuthTryResult
+{
+ public bool Success { get; init; }
+ public AuthFailureReason? Reason { get; init; }
+ public int? RemainingAttempts { get; init; }
+ public DateTimeOffset? LockoutUntilUtc { get; init; }
+ public bool RequiresMfa { get; init; }
+ public string? PreviewReceipt { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs
new file mode 100644
index 00000000..bcb1e132
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs
@@ -0,0 +1,16 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed record PkceAuthorizeCommand
+{
+ public string CodeChallenge { get; init; } = default!;
+ public string ChallengeMethod { get; init; } = "S256";
+ public required DeviceContext Device { get; init; }
+ public string? RedirectUri { get; init; }
+
+ public UAuthClientProfile ClientProfile { get; init; }
+ public TenantKey Tenant { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
index d04b6eca..22cb7bab 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
@@ -1,10 +1,22 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+using System.Text.Json.Serialization;
-internal sealed class PkceCompleteRequest
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class PkceCompleteRequest
{
+ [JsonPropertyName("authorization_code")]
public string AuthorizationCode { get; init; } = default!;
+
+ [JsonPropertyName("code_verifier")]
public string CodeVerifier { get; init; } = default!;
+
+
public string Identifier { get; init; } = default!;
public string Secret { get; init; } = default!;
+
+ [JsonPropertyName("return_url")]
public string ReturnUrl { get; init; } = default!;
+
+ [JsonPropertyName("hub_session_id")]
+ public string HubSessionId { get; init; } = default!;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs
new file mode 100644
index 00000000..bc16b023
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs
@@ -0,0 +1,14 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed record PkceCompleteResult
+{
+ public bool Success { get; init; }
+
+ public AuthFailureReason? FailureReason { get; init; }
+
+ public LoginResult? LoginResult { get; init; }
+
+ public bool InvalidPkce { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs
deleted file mode 100644
index 9d446030..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed class PkceLoginRequest
-{
- public string AuthorizationCode { get; init; } = default!;
- public string CodeVerifier { get; init; } = default!;
- public string ReturnUrl { get; init; } = default!;
-
- public string Identifier { get; init; } = default!;
- public string Secret { get; init; } = default!;
-
- public TenantKey Tenant { get; init; }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs
new file mode 100644
index 00000000..fb0b7913
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class TryPkceLoginResult : IUAuthTryResult
+{
+ public bool Success { get; init; }
+ public AuthFailureReason? Reason { get; init; }
+ public int? RemainingAttempts { get; init; }
+ public DateTimeOffset? LockoutUntilUtc { get; init; }
+ public bool RequiresMfa { get; init; }
+ public bool RetryWithNewPkce { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifact.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs
rename to src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifact.cs
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs
similarity index 92%
rename from src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs
rename to src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs
index 70512e67..85157ad7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs
@@ -4,6 +4,7 @@ public enum AuthArtifactType
{
PkceAuthorizationCode,
HubFlow,
+ LoginPreview,
HubLogin,
MfaChallenge,
PasswordReset,
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs
new file mode 100644
index 00000000..78d69a80
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs
@@ -0,0 +1,53 @@
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public sealed class LoginPreviewArtifact : AuthArtifact
+{
+ public TenantKey Tenant { get; }
+ public UserKey UserKey { get; }
+ public CredentialType Factor { get; }
+ public string DeviceId { get; }
+ public string Identifier { get; }
+ public UAuthClientProfile ClientProfile { get; }
+ public string Fingerprint { get; }
+
+ public LoginPreviewArtifact(
+ TenantKey tenant,
+ UserKey userKey,
+ CredentialType factor,
+ string deviceId,
+ string identifier,
+ UAuthClientProfile clientProfile,
+ string fingerprint,
+ DateTimeOffset expiresAt)
+ : base(AuthArtifactType.LoginPreview, expiresAt)
+ {
+ Tenant = tenant;
+ UserKey = userKey;
+ Factor = factor;
+ DeviceId = deviceId;
+ Identifier = identifier;
+ ClientProfile = clientProfile;
+ Fingerprint = fingerprint;
+ }
+
+ public bool Matches(
+ TenantKey tenant,
+ UserKey userKey,
+ CredentialType factor,
+ string deviceId,
+ string identifier,
+ UAuthClientProfile clientProfile,
+ string fingerprint)
+ {
+ return Tenant == tenant
+ && UserKey == userKey
+ && Factor == factor
+ && string.Equals(DeviceId, deviceId, StringComparison.Ordinal)
+ && string.Equals(Identifier, identifier, StringComparison.Ordinal)
+ && ClientProfile == clientProfile
+ && string.Equals(Fingerprint, fingerprint, StringComparison.Ordinal);
+ }
+}
\ No newline at end of file
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs
new file mode 100644
index 00000000..88d61eb3
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs
@@ -0,0 +1,9 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public sealed class UserAgentInfo
+{
+ public string? DeviceType { get; init; }
+ public string? Platform { get; init; }
+ public string? OperatingSystem { get; init; }
+ public string? Browser { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs
new file mode 100644
index 00000000..c185ab21
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public enum HubErrorCode
+{
+ None = 0,
+ InvalidCredentials,
+ LockedOut,
+ RequiresMfa,
+ Unknown
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs
index f85e5c9a..e350d863 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs
@@ -10,15 +10,19 @@ public sealed class HubFlowArtifact : AuthArtifact
public UAuthClientProfile ClientProfile { get; }
public TenantKey Tenant { get; }
+ public DeviceContext Device { get; }
public string? ReturnUrl { get; }
public HubFlowPayload Payload { get; }
+ public HubErrorCode? Error { get; private set; }
+
public HubFlowArtifact(
HubSessionId hubSessionId,
HubFlowType flowType,
UAuthClientProfile clientProfile,
TenantKey tenant,
+ DeviceContext device,
string? returnUrl,
HubFlowPayload payload,
DateTimeOffset expiresAt)
@@ -28,7 +32,20 @@ public HubFlowArtifact(
FlowType = flowType;
ClientProfile = clientProfile;
Tenant = tenant;
+ Device = device;
ReturnUrl = returnUrl;
Payload = payload;
}
+
+ public void SetError(HubErrorCode error)
+ {
+ Error = error;
+ RegisterAttempt();
+ }
+
+ public void ClearError()
+ {
+ Error = null;
+ RegisterAttempt();
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs
index c9833737..47c2c78f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs
@@ -19,4 +19,17 @@ public bool TryGet(string key, out T? value)
value = default;
return false;
}
+
+ public T GetRequired(string key)
+ {
+ if (TryGet(key, out var value) && value is not null)
+ return value;
+
+ throw new InvalidOperationException($"Payload key '{key}' is missing or invalid.");
+ }
+
+ public T? GetOptional(string key)
+ {
+ return TryGet(key, out var value) ? value : default;
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs
index b45b995e..691d7a57 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs
@@ -8,9 +8,28 @@ public sealed class HubFlowState
public HubFlowType FlowType { get; init; }
public UAuthClientProfile ClientProfile { get; init; }
public string? ReturnUrl { get; init; }
+ public HubErrorCode? Error { get; init; }
+ public int AttemptCount { get; init; }
public bool IsActive { get; init; }
public bool IsExpired { get; init; }
public bool IsCompleted { get; init; }
public bool Exists { get; init; }
+
+ public HubFlowState ClearError()
+ {
+ return new HubFlowState
+ {
+ HubSessionId = HubSessionId,
+ FlowType = FlowType,
+ ClientProfile = ClientProfile,
+ ReturnUrl = ReturnUrl,
+ Error = null,
+ AttemptCount = AttemptCount,
+ IsActive = IsActive,
+ IsExpired = IsExpired,
+ IsCompleted = IsCompleted,
+ Exists = Exists
+ };
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs
index be3f758b..c6ceb415 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs
@@ -1,17 +1,17 @@
-namespace CodeBeam.UltimateAuth.Core.Domain;
+//namespace CodeBeam.UltimateAuth.Core.Domain;
-public sealed class HubLoginArtifact : AuthArtifact
-{
- public string AuthorizationCode { get; }
- public string CodeVerifier { get; }
+//public sealed class HubLoginArtifact : AuthArtifact
+//{
+// public string AuthorizationCode { get; }
+// public string CodeVerifier { get; }
- public HubLoginArtifact(
- string authorizationCode,
- string codeVerifier,
- DateTimeOffset expiresAt)
- : base(AuthArtifactType.HubLogin, expiresAt)
- {
- AuthorizationCode = authorizationCode;
- CodeVerifier = codeVerifier;
- }
-}
+// public HubLoginArtifact(
+// string authorizationCode,
+// string codeVerifier,
+// DateTimeOffset expiresAt)
+// : base(AuthArtifactType.HubLogin, expiresAt)
+// {
+// AuthorizationCode = authorizationCode;
+// CodeVerifier = codeVerifier;
+// }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
index b3cf195e..d30489d9 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
@@ -158,9 +158,18 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres
if (threshold < 0)
throw new UAuthValidationException(nameof(threshold));
- var nextCount = FailedAttempts + 1;
+ var effectiveFailedAttempts = FailedAttempts;
+ var effectiveLockedUntil = LockedUntil;
- DateTimeOffset? nextLockedUntil = LockedUntil;
+ if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value)
+ {
+ effectiveFailedAttempts = 0;
+ effectiveLockedUntil = null;
+ }
+
+ var nextCount = effectiveFailedAttempts + 1;
+
+ DateTimeOffset? nextLockedUntil = effectiveLockedUntil;
if (threshold > 0 && nextCount >= threshold)
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs
index 779222b7..5cfc2f39 100644
--- a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs
@@ -74,6 +74,7 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio
services.AddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
return services;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs
new file mode 100644
index 00000000..9c3e3a58
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs
@@ -0,0 +1,85 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure;
+
+internal sealed class UAuthUserAgentParser : IUserAgentParser
+{
+ public UserAgentInfo Parse(string? userAgent)
+ {
+ if (string.IsNullOrWhiteSpace(userAgent))
+ return new UserAgentInfo();
+
+ var ua = userAgent.ToLowerInvariant();
+
+ return new UserAgentInfo
+ {
+ DeviceType = ResolveDeviceType(ua),
+ Platform = ResolvePlatform(ua),
+ OperatingSystem = ResolveOperatingSystem(ua),
+ Browser = ResolveBrowser(ua)
+ };
+ }
+
+ private static string ResolveDeviceType(string ua)
+ {
+ if (ua.Contains("ipad") || ua.Contains("tablet"))
+ return "tablet";
+
+ if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android"))
+ return "mobile";
+
+ return "desktop";
+ }
+
+ private static string ResolvePlatform(string ua)
+ {
+ if (ua.Contains("ipad") || ua.Contains("tablet"))
+ return "tablet";
+
+ if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android"))
+ return "mobile";
+
+ return "desktop";
+ }
+
+ private static string ResolveOperatingSystem(string ua)
+ {
+ if (ua.Contains("android"))
+ return "android";
+
+ if (ua.Contains("iphone") || ua.Contains("ipad"))
+ return "ios";
+
+ if (ua.Contains("windows"))
+ return "windows";
+
+ if (ua.Contains("mac"))
+ return "macos";
+
+ if (ua.Contains("linux"))
+ return "linux";
+
+ return "unknown";
+ }
+
+ private static string ResolveBrowser(string ua)
+ {
+ if (ua.Contains("edg/"))
+ return "edge";
+
+ if (ua.Contains("opr/") || ua.Contains("opera"))
+ return "opera";
+
+ if (ua.Contains("chrome") && !ua.Contains("chromium"))
+ return "chrome";
+
+ if (ua.Contains("safari") && !ua.Contains("chrome"))
+ return "safari";
+
+ if (ua.Contains("firefox"))
+ return "firefox";
+
+ return "unknown";
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs
index f0c18bf8..4677358e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs
@@ -41,12 +41,15 @@ public sealed class UAuthLoginOptions
///
public bool ExtendLockOnFailure { get; set; } = false;
+ public TimeSpan TryLoginDuration { get; set; } = TimeSpan.FromSeconds(180);
+
internal UAuthLoginOptions Clone() => new()
{
MaxFailedAttempts = MaxFailedAttempts,
LockoutDuration = LockoutDuration,
IncludeFailureDetails = IncludeFailureDetails,
FailureWindow = FailureWindow,
- ExtendLockOnFailure = ExtendLockOnFailure
+ ExtendLockOnFailure = ExtendLockOnFailure,
+ TryLoginDuration = TryLoginDuration
};
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs
index d91d578b..a8d869d3 100644
--- a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs
@@ -6,4 +6,5 @@
///
public interface IUAuthHubMarker
{
+ bool RequiresCors { get; }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
index 8be92d7a..595c51a9 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
@@ -8,5 +8,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions;
///
public interface IDeviceResolver
{
- DeviceInfo Resolve(HttpContext context);
+ Task ResolveAsync(HttpContext context);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs
index 067810f4..92fe58c1 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs
@@ -1,24 +1,32 @@
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Extensions;
using Microsoft.AspNetCore.Http;
namespace CodeBeam.UltimateAuth.Server.Auth;
internal sealed class ClientProfileReader : IClientProfileReader
{
- public UAuthClientProfile Read(HttpContext context)
+ public async Task ReadAsync(HttpContext context)
{
if (context.Request.Headers.TryGetValue(UAuthConstants.Headers.ClientProfile, out var headerValue) && TryParse(headerValue, out var headerProfile))
{
return headerProfile;
}
- if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) &&
- TryParse(formValue, out var formProfile))
+ var form = await context.GetCachedFormAsync();
+
+ if (form is not null && form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && TryParse(formValue, out var formProfile))
{
return formProfile;
}
+ //if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) &&
+ // TryParse(formValue, out var formProfile))
+ //{
+ // return formProfile;
+ //}
+
return UAuthClientProfile.NotSpecified;
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
index fdefb918..6cb5db77 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
@@ -47,7 +47,7 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp
var sessionCtx = ctx.GetSessionContext();
var user = ctx.GetUserContext();
- var clientProfile = _clientProfileReader.Read(ctx);
+ var clientProfile = await _clientProfileReader.ReadAsync(ctx);
var originalOptions = _serverOptionsProvider.GetOriginal(ctx);
var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile);
@@ -61,9 +61,9 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp
var effectiveMode = effectiveOptions.Mode;
var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode);
var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions);
- var deviceInfo = _deviceResolver.Resolve(ctx);
+ var deviceInfo = await _deviceResolver.ResolveAsync(ctx);
var deviceContext = _deviceContextFactory.Create(deviceInfo);
- var returnUrl = ctx.GetReturnUrl();
+ var returnUrl = await ctx.GetReturnUrlAsync();
var returnUrlInfo = ReturnUrlParser.Parse(returnUrl);
SessionSecurityContext? sessionSecurityContext = null;
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs
index e64d3d7a..1d54a7de 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs
@@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth;
public interface IClientProfileReader
{
- UAuthClientProfile Read(HttpContext context);
+ Task ReadAsync(HttpContext context);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs
index bb5fded8..96fff082 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs
@@ -45,7 +45,7 @@ public UAuthAuthenticationHandler(
protected override async Task HandleAuthenticateAsync()
{
- var credential = _transportCredentialResolver.Resolve(Context);
+ var credential = await _transportCredentialResolver.ResolveAsync(Context);
if (credential is null)
return AuthenticateResult.NoResult();
diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs
new file mode 100644
index 00000000..81c22c36
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs
@@ -0,0 +1,20 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Contracts;
+
+public sealed record HubBeginRequest
+{
+ public string AuthorizationCode { get; init; } = default!;
+ public string CodeVerifier { get; init; } = default!;
+
+ public UAuthClientProfile ClientProfile { get; init; }
+ public TenantKey Tenant { get; init; }
+
+ public string? ReturnUrl { get; init; }
+
+ public string? PreviousHubSessionId { get; init; }
+
+ public required DeviceContext Device { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs
new file mode 100644
index 00000000..288b531e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.Server.Contracts;
+
+public sealed record HubSessionResult
+{
+ public string HubSessionId { get; init; } = default!;
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs
index 72b3df38..a275b413 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs
@@ -5,4 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints;
public interface ILoginEndpointHandler
{
Task LoginAsync(HttpContext ctx);
+ Task TryLoginAsync(HttpContext ctx);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
index 547dcf9b..bc5cd909 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
@@ -17,4 +17,6 @@ public interface IPkceEndpointHandler
/// then issues a session or token.
///
Task CompleteAsync(HttpContext ctx);
+
+ Task TryCompleteAsync(HttpContext ctx);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
index e3b0f3e6..5d6b0885 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
@@ -3,65 +3,108 @@
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Flows;
using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Services;
+using CodeBeam.UltimateAuth.Server.Stores;
+using CodeBeam.UltimateAuth.Users;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
namespace CodeBeam.UltimateAuth.Server.Endpoints;
-public sealed class LoginEndpointHandler : ILoginEndpointHandler
+internal sealed class LoginEndpointHandler : ILoginEndpointHandler
{
private readonly IAuthFlowContextAccessor _authFlow;
- private readonly IUAuthFlowService _flowService;
- private readonly IClock _clock;
+ private readonly IUAuthInternalFlowService _internalFlowService;
private readonly ICredentialResponseWriter _credentialResponseWriter;
private readonly IAuthRedirectResolver _redirectResolver;
+ private readonly IAuthStore _authStore;
+ private readonly ILoginIdentifierResolver _loginIdentifierResolver;
+ private readonly UAuthServerOptions _options;
+ private readonly IClock _clock;
public LoginEndpointHandler(
IAuthFlowContextAccessor authFlow,
- IUAuthFlowService flowService,
- IClock clock,
+ IUAuthInternalFlowService internalFlowService,
ICredentialResponseWriter credentialResponseWriter,
- IAuthRedirectResolver redirectResolver)
+ IAuthRedirectResolver redirectResolver,
+ IAuthStore authStore,
+ ILoginIdentifierResolver loginIdentifierResolver,
+ IOptions options,
+ IClock clock)
{
_authFlow = authFlow;
- _flowService = flowService;
- _clock = clock;
+ _internalFlowService = internalFlowService;
_credentialResponseWriter = credentialResponseWriter;
_redirectResolver = redirectResolver;
+ _authStore = authStore;
+ _loginIdentifierResolver = loginIdentifierResolver;
+ _options = options.Value;
+ _clock = clock;
}
public async Task LoginAsync(HttpContext ctx)
{
var authFlow = _authFlow.Current;
+ var request = await ReadLoginRequestAsync(ctx);
- if (!ctx.Request.HasFormContentType)
- return Results.BadRequest("Invalid content type.");
-
- var form = await ctx.Request.ReadFormAsync();
+ if (request is null || string.IsNullOrWhiteSpace(request.Identifier) || string.IsNullOrWhiteSpace(request.Secret))
+ {
+ var failure = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials);
+ return failure.Enabled ? Results.Redirect(failure.TargetUrl!) : Results.Unauthorized();
+ }
- var identifier = form["Identifier"].ToString();
- var secret = form["Secret"].ToString();
+ var suppressFailureAttempt = false;
- if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret))
+ if (!string.IsNullOrWhiteSpace(request.PreviewReceipt) && authFlow.Device.DeviceId is DeviceId deviceId)
{
- var decisionFailureInvalid = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials);
-
- return decisionFailureInvalid.Enabled
- ? Results.Redirect(decisionFailureInvalid.TargetUrl!)
- : Results.Unauthorized();
+ var key = new AuthArtifactKey(request.PreviewReceipt);
+ var artifact = await _authStore.GetAsync(key, ctx.RequestAborted) as LoginPreviewArtifact;
+
+ if (artifact is not null)
+ {
+ var fingerprint = LoginPreviewFingerprint.Create(
+ authFlow.Tenant,
+ request.Identifier,
+ CredentialType.Password,
+ request.Secret,
+ deviceId);
+
+ if (artifact.Matches(
+ authFlow.Tenant,
+ artifact.UserKey,
+ CredentialType.Password,
+ deviceId.Value,
+ request.Identifier,
+ authFlow.ClientProfile,
+ fingerprint))
+ {
+ suppressFailureAttempt = true;
+ await _authStore.ConsumeAsync(key, ctx.RequestAborted);
+ }
+ }
}
var flowRequest = new LoginRequest
{
- Identifier = identifier,
- Secret = secret,
- Tenant = authFlow.Tenant,
- At = _clock.UtcNow,
- RequestTokens = authFlow.AllowsTokenIssuance
+ Identifier = request.Identifier,
+ Secret = request.Secret,
+ Factor = CredentialType.Password,
+ PreviewReceipt = request.PreviewReceipt,
+ RequestTokens = authFlow.AllowsTokenIssuance,
+ Metadata = request.Metadata,
};
- var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted);
+ var result = await _internalFlowService.LoginAsync(authFlow, flowRequest,
+ new LoginExecutionOptions
+ {
+ Mode = LoginExecutionMode.Commit,
+ SuppressFailureAttempt = suppressFailureAttempt,
+ SuppressSuccessReset = false
+ }, ctx.RequestAborted);
if (!result.IsSuccess)
{
@@ -93,4 +136,111 @@ public async Task LoginAsync(HttpContext ctx)
? Results.Redirect(decision.TargetUrl!)
: Results.Ok();
}
+
+ public async Task TryLoginAsync(HttpContext ctx)
+ {
+ var authFlow = _authFlow.Current;
+
+ if (!ctx.Request.HasFormContentType && !ctx.Request.HasJsonContentType())
+ return Results.BadRequest("Invalid content type.");
+
+ var request = await ReadLoginRequestAsync(ctx);
+ if (request is null)
+ return Results.BadRequest("Invalid payload.");
+
+ if (string.IsNullOrWhiteSpace(request.Identifier) || string.IsNullOrWhiteSpace(request.Secret))
+ {
+ return Results.Ok(new TryLoginResult
+ {
+ Success = false,
+ Reason = AuthFailureReason.InvalidCredentials
+ });
+ }
+
+ request = new LoginRequest
+ {
+ Identifier = request.Identifier,
+ Secret = request.Secret,
+ Factor = CredentialType.Password,
+ PreviewReceipt = request.PreviewReceipt,
+ RequestTokens = authFlow.AllowsTokenIssuance,
+ Metadata = request.Metadata
+ };
+
+ var result = await _internalFlowService.LoginAsync(
+ authFlow,
+ request,
+ new LoginExecutionOptions
+ {
+ Mode = LoginExecutionMode.Preview,
+ SuppressFailureAttempt = false,
+ SuppressSuccessReset = true
+ },
+ ctx.RequestAborted);
+
+ string? previewReceipt = null;
+
+ if (result.IsSuccess && authFlow.Device.DeviceId is DeviceId deviceId)
+ {
+ var fingerprint = LoginPreviewFingerprint.Create(
+ authFlow.Tenant,
+ request.Identifier,
+ request.Factor,
+ request.Secret,
+ deviceId);
+
+ var receipt = AuthArtifactKey.New();
+ previewReceipt = receipt.Value;
+
+ var resolution = await _loginIdentifierResolver.ResolveAsync(authFlow.Tenant, request.Identifier, ctx.RequestAborted);
+
+ if (resolution?.UserKey is { } userKey)
+ {
+ var artifact = new LoginPreviewArtifact(
+ authFlow.Tenant,
+ userKey,
+ request.Factor,
+ deviceId.Value,
+ request.Identifier,
+ authFlow.ClientProfile,
+ fingerprint,
+ _clock.UtcNow.Add(_options.Login.TryLoginDuration));
+
+ await _authStore.StoreAsync(receipt, artifact, ctx.RequestAborted);
+ }
+ }
+
+ return Results.Ok(new TryLoginResult
+ {
+ Success = result.IsSuccess,
+ Reason = result.FailureReason,
+ RemainingAttempts = result.RemainingAttempts,
+ LockoutUntilUtc = result.LockoutUntilUtc,
+ RequiresMfa = result.RequiresMfa,
+ PreviewReceipt = previewReceipt
+ });
+ }
+
+ private async Task ReadLoginRequestAsync(HttpContext ctx)
+ {
+ if (ctx.Request.HasJsonContentType())
+ {
+ return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted);
+ }
+
+ if (ctx.Request.HasFormContentType)
+ {
+ var form = await ctx.GetCachedFormAsync();
+
+ return new LoginRequest
+ {
+ Identifier = form?["Identifier"].FirstOrDefault() ?? string.Empty,
+ Secret = form?["Secret"].FirstOrDefault() ?? string.Empty,
+ PreviewReceipt = form?["PreviewReceipt"].FirstOrDefault(),
+ // TODO: Other properties?
+ };
+ }
+
+ return null;
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
index 419fbc16..9743e39e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
@@ -1,15 +1,19 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Extensions;
using CodeBeam.UltimateAuth.Server.Flows;
using CodeBeam.UltimateAuth.Server.Infrastructure;
-using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Server.Stores;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Options;
+using Microsoft.AspNetCore.WebUtilities;
+using System.Text;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Server.Endpoints;
@@ -17,80 +21,64 @@ internal sealed class PkceEndpointHandler : IPkceEndpointHandler
{
private readonly IAuthFlowContextAccessor _authContext;
private readonly IUAuthFlowService _flow;
+ private readonly IPkceService _pkceService;
+ private readonly IUAuthInternalFlowService _internalFlowService;
private readonly IAuthStore _authStore;
private readonly IPkceAuthorizationValidator _validator;
private readonly IClock _clock;
- private readonly UAuthServerOptions _options;
private readonly ICredentialResponseWriter _credentialResponseWriter;
private readonly IAuthRedirectResolver _redirectResolver;
public PkceEndpointHandler(
IAuthFlowContextAccessor authContext,
IUAuthFlowService flow,
+ IPkceService pkceService,
+ IUAuthInternalFlowService internalFlowService,
IAuthStore authStore,
IPkceAuthorizationValidator validator,
IClock clock,
- IOptions options,
ICredentialResponseWriter credentialResponseWriter,
IAuthRedirectResolver redirectResolver)
{
_authContext = authContext;
_flow = flow;
+ _pkceService = pkceService;
+ _internalFlowService = internalFlowService;
_authStore = authStore;
_validator = validator;
_clock = clock;
- _options = options.Value;
_credentialResponseWriter = credentialResponseWriter;
_redirectResolver = redirectResolver;
}
public async Task AuthorizeAsync(HttpContext ctx)
{
- var authContext = _authContext.Current;
-
- // TODO: Make PKCE flow free
- if (authContext.FlowType != AuthFlowType.Login)
- return Results.BadRequest("PKCE is only supported for login flow.");
+ var auth = _authContext.Current;
var request = await ReadPkceAuthorizeRequestAsync(ctx);
if (request is null)
return Results.BadRequest("Invalid content type.");
- if (string.IsNullOrWhiteSpace(request.CodeChallenge))
- return Results.BadRequest("code_challenge is required.");
-
- if (!string.Equals(request.ChallengeMethod, "S256", StringComparison.Ordinal))
- return Results.BadRequest("Only S256 challenge method is supported.");
-
- var authorizationCode = AuthArtifactKey.New();
-
- var snapshot = new PkceContextSnapshot(
- clientProfile: authContext.ClientProfile,
- tenant: authContext.Tenant,
- redirectUri: request.RedirectUri,
- deviceId: request.DeviceId
- );
-
- var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds);
-
- var artifact = new PkceAuthorizationArtifact(
- authorizationCode: authorizationCode,
- codeChallenge: request.CodeChallenge,
- challengeMethod: PkceChallengeMethod.S256,
- expiresAt: expiresAt,
- context: snapshot
- );
-
- await _authStore.StoreAsync(authorizationCode, artifact, ctx.RequestAborted);
+ var result = await _pkceService.AuthorizeAsync(
+ new PkceAuthorizeCommand
+ {
+ CodeChallenge = request.CodeChallenge,
+ ChallengeMethod = request.ChallengeMethod,
+ Device = request.Device,
+ RedirectUri = request.RedirectUri,
+ ClientProfile = auth.ClientProfile,
+ Tenant = auth.Tenant
+ },
+ ctx.RequestAborted);
return Results.Ok(new PkceAuthorizeResponse
{
- AuthorizationCode = authorizationCode.Value,
- ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds
+ AuthorizationCode = result.AuthorizationCode,
+ ExpiresIn = result.ExpiresIn
});
}
- public async Task CompleteAsync(HttpContext ctx)
+ public async Task TryCompleteAsync(HttpContext ctx)
{
var authContext = _authContext.Current;
@@ -99,67 +87,111 @@ public async Task CompleteAsync(HttpContext ctx)
var request = await ReadPkceCompleteRequestAsync(ctx);
if (request is null)
- return Results.BadRequest("Invalid PKCE completion payload.");
+ return Results.BadRequest("Invalid PKCE payload.");
if (string.IsNullOrWhiteSpace(request.AuthorizationCode) || string.IsNullOrWhiteSpace(request.CodeVerifier))
return Results.BadRequest("authorization_code and code_verifier are required.");
var artifactKey = new AuthArtifactKey(request.AuthorizationCode);
- var artifact = await _authStore.ConsumeAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact;
+ var artifact = await _authStore.GetAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact;
if (artifact is null)
- return Results.Unauthorized(); // replay / expired / unknown code
+ {
+ return Results.Ok(new TryPkceLoginResult
+ {
+ Success = false,
+ RetryWithNewPkce = true
+ });
+ }
- var validation = _validator.Validate(artifact, request.CodeVerifier,
+ var validation = _validator.Validate(
+ artifact,
+ request.CodeVerifier,
new PkceContextSnapshot(
- clientProfile: authContext.ClientProfile,
- tenant: authContext.Tenant,
- redirectUri: null,
- deviceId: artifact.Context.DeviceId),
+ clientProfile: artifact.Context.ClientProfile,
+ tenant: artifact.Context.Tenant,
+ redirectUri: artifact.Context.RedirectUri,
+ device: artifact.Context.Device),
_clock.UtcNow);
if (!validation.Success)
{
- artifact.RegisterAttempt();
- return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid");
+ return Results.Ok(new TryPkceLoginResult
+ {
+ Success = false,
+ RetryWithNewPkce = true
+ });
}
var loginRequest = new LoginRequest
{
Identifier = request.Identifier,
Secret = request.Secret,
- Tenant = authContext.Tenant,
- At = _clock.UtcNow,
RequestTokens = authContext.AllowsTokenIssuance
};
var execution = new AuthExecutionContext
{
EffectiveClientProfile = artifact.Context.ClientProfile,
- Device = DeviceContext.Create(DeviceId.Create(artifact.Context.DeviceId), null, null, null, null, null)
+ Device = artifact.Context.Device
};
- var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted);
+ var preview = await _internalFlowService.LoginAsync(authContext, execution, loginRequest,
+ new LoginExecutionOptions
+ {
+ Mode = LoginExecutionMode.Preview,
+ SuppressFailureAttempt = false,
+ SuppressSuccessReset = true
+ }, ctx.RequestAborted);
- if (!result.IsSuccess)
- return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid");
-
- if (result.SessionId is not null)
+ return Results.Ok(new TryPkceLoginResult
{
- _credentialResponseWriter.Write(ctx, GrantKind.Session, result.SessionId.Value);
- }
+ Success = preview.IsSuccess,
+ Reason = preview.FailureReason,
+ RemainingAttempts = preview.RemainingAttempts,
+ LockoutUntilUtc = preview.LockoutUntilUtc,
+ RequiresMfa = preview.FailureReason == AuthFailureReason.RequiresMfa,
+ RetryWithNewPkce = false
+ });
+ }
- if (result.AccessToken is not null)
- {
- _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken);
- }
+ public async Task CompleteAsync(HttpContext ctx)
+ {
+ var auth = _authContext.Current;
- if (result.RefreshToken is not null)
- {
- _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken);
- }
+ var request = await ReadPkceCompleteRequestAsync(ctx);
+ if (request is null)
+ return Results.BadRequest("Invalid PKCE payload.");
- var decision = _redirectResolver.ResolveSuccess(authContext, ctx);
+ var result = await _pkceService.CompleteAsync(
+ auth,
+ new PkceCompleteRequest
+ {
+ AuthorizationCode = request.AuthorizationCode!,
+ CodeVerifier = request.CodeVerifier!,
+ Identifier = request.Identifier,
+ Secret = request.Secret
+ },
+ ctx.RequestAborted);
+
+ if (result.InvalidPkce)
+ return Results.Unauthorized();
+
+ if (!result.Success)
+ return await RedirectToLoginWithErrorAsync(ctx, auth, "invalid");
+
+ var login = result.LoginResult!;
+
+ if (login.SessionId is not null)
+ _credentialResponseWriter.Write(ctx, GrantKind.Session, login.SessionId.Value);
+
+ if (login.AccessToken is not null)
+ _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, login.AccessToken);
+
+ if (login.RefreshToken is not null)
+ _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, login.RefreshToken);
+
+ var decision = _redirectResolver.ResolveSuccess(auth, ctx);
return decision.Enabled
? Results.Redirect(decision.TargetUrl!)
@@ -175,19 +207,47 @@ public async Task CompleteAsync(HttpContext ctx)
if (ctx.Request.HasFormContentType)
{
- var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted);
+ var form = await ctx.GetCachedFormAsync();
+
+ var codeChallenge = form?["code_challenge"].ToString();
+ var challengeMethod = form?["challenge_method"].ToString();
+ var redirectUri = form?["redirect_uri"].ToString();
+
+ if (string.IsNullOrWhiteSpace(codeChallenge))
+ throw new UAuthValidationException("code_challenge is required");
+
+ if (string.IsNullOrWhiteSpace(challengeMethod))
+ throw new UAuthValidationException("challange_method is required");
+
+ var deviceRaw = form?["device"].FirstOrDefault();
+ DeviceContext? device = null;
+
+ if (!string.IsNullOrWhiteSpace(deviceRaw))
+ {
+ try
+ {
+ var bytes = WebEncoders.Base64UrlDecode(deviceRaw);
+ var json = Encoding.UTF8.GetString(bytes);
+ device = JsonSerializer.Deserialize(json);
+ }
+ catch
+ {
+ device = null;
+ }
+ }
- var codeChallenge = form["code_challenge"].ToString();
- var challengeMethod = form["challenge_method"].ToString();
- var redirectUri = form["redirect_uri"].ToString();
- var deviceId = form["device_id"].ToString();
+ if (device == null)
+ {
+ var info = await ctx.GetDeviceAsync();
+ device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress);
+ }
return new PkceAuthorizeRequest
{
CodeChallenge = codeChallenge,
ChallengeMethod = challengeMethod,
RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri,
- DeviceId = deviceId
+ Device = device
};
}
@@ -198,27 +258,32 @@ public async Task CompleteAsync(HttpContext ctx)
{
if (ctx.Request.HasJsonContentType())
{
- return await ctx.Request.ReadFromJsonAsync(
- cancellationToken: ctx.RequestAborted);
+ return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted);
}
if (ctx.Request.HasFormContentType)
{
- var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted);
+ var form = await ctx.GetCachedFormAsync();
+
+ var authorizationCode = form?["authorization_code"].FirstOrDefault();
+ var codeVerifier = form?["code_verifier"].FirstOrDefault();
+ var identifier = form?["Identifier"].FirstOrDefault();
+ var secret = form?["Secret"].FirstOrDefault();
+ var returnUrl = form?["return_url"].FirstOrDefault();
- var authorizationCode = form["authorization_code"].ToString();
- var codeVerifier = form["code_verifier"].ToString();
- var identifier = form["Identifier"].ToString();
- var secret = form["Secret"].ToString();
- var returnUrl = form["return_url"].ToString();
+ if (string.IsNullOrWhiteSpace(authorizationCode))
+ throw new UAuthValidationException("authorization_code is required");
+
+ if (string.IsNullOrWhiteSpace(codeVerifier))
+ throw new UAuthValidationException("code_verifier is required");
return new PkceCompleteRequest
{
AuthorizationCode = authorizationCode,
CodeVerifier = codeVerifier,
- Identifier = identifier,
- Secret = secret,
- ReturnUrl = returnUrl
+ Identifier = identifier ?? string.Empty,
+ Secret = secret ?? string.Empty,
+ ReturnUrl = returnUrl ?? string.Empty
};
}
@@ -228,7 +293,7 @@ public async Task CompleteAsync(HttpContext ctx)
private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthFlowContext auth, string error)
{
var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login";
- var hubKey = ctx.Request.Query["hub"].ToString();
+ var hubKey = await ResolveHubKeyAsync(ctx);
if (!string.IsNullOrWhiteSpace(hubKey))
{
@@ -237,15 +302,28 @@ private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthF
if (artifact is HubFlowArtifact hub)
{
- hub.MarkCompleted();
- await _authStore.StoreAsync(key, hub, ctx.RequestAborted);
+ hub.SetError(HubErrorCode.InvalidCredentials);
+ await _authStore.StoreAsync(key, hub);
+
+ return Results.Redirect($"{basePath}?{UAuthConstants.Query.Hub}={Uri.EscapeDataString(hubKey)}");
}
+ }
+ return Results.Redirect(basePath);
+ }
+
+ private async Task ResolveHubKeyAsync(HttpContext ctx)
+ {
+ if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.Hub, out var q) && !string.IsNullOrWhiteSpace(q))
+ return q.ToString();
- return Results.Redirect(
- $"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}");
+ if (ctx.Request.HasFormContentType)
+ {
+ var form = await ctx.GetCachedFormAsync();
+
+ if (form?.TryGetValue("hub_session_id", out var f) == true && !string.IsNullOrWhiteSpace(f))
+ return f.ToString();
}
- return Results.Redirect(
- $"{basePath}?__uauth_error={Uri.EscapeDataString(error)}");
+ return null;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
index 4387daee..fa1d0120 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
@@ -47,6 +47,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx)
=> await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
+ group.MapPost("/try-login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx)
+ => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
+
group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx)
=> await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession));
@@ -95,6 +98,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
=> await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
+
+ pkce.MapPost("/try-complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
+ => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
}
//if (options.Endpoints.Token != false)
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
index 808c8717..9473ee3a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
@@ -11,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints;
internal sealed class ValidateEndpointHandler : IValidateEndpointHandler
{
private readonly IAuthFlowContextAccessor _authContext;
- private readonly IFlowCredentialResolver _credentialResolver;
+ private readonly IValidateCredentialResolver _credentialResolver;
private readonly ISessionValidator _sessionValidator;
private readonly IAuthStateSnapshotFactory _snapshotFactory;
private readonly IClock _clock;
public ValidateEndpointHandler(
IAuthFlowContextAccessor authContext,
- IFlowCredentialResolver credentialResolver,
+ IValidateCredentialResolver credentialResolver,
ISessionValidator sessionValidator,
IAuthStateSnapshotFactory snapshotFactory,
IClock clock)
@@ -33,7 +33,7 @@ public ValidateEndpointHandler(
public async Task ValidateAsync(HttpContext context, CancellationToken ct = default)
{
var auth = _authContext.Current;
- var credential = _credentialResolver.Resolve(context, auth.Response);
+ var credential = await _credentialResolver.ResolveAsync(context, auth.Response);
if (credential is null)
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
index de1bd560..5a5ec37a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
@@ -7,9 +7,9 @@ namespace CodeBeam.UltimateAuth.Server.Extensions;
public static class DeviceExtensions
{
- public static DeviceInfo GetDevice(this HttpContext context)
+ public static async Task GetDeviceAsync(this HttpContext context)
{
var resolver = context.RequestServices.GetRequiredService();
- return resolver.Resolve(context);
+ return await resolver.ResolveAsync(context);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs
index deb51630..cedcbdfe 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs
@@ -13,7 +13,8 @@ public static IEndpointRouteBuilder MapUltimateAuthEndpoints(this IEndpointRoute
{
var registrar = endpoints.ServiceProvider.GetRequiredService();
var options = endpoints.ServiceProvider.GetRequiredService>().Value;
- var rootGroup = endpoints.MapGroup("");
+ var rootGroup = endpoints.MapGroup("")
+ .RequireCors("UAuthHub");
registrar.MapEndpoints(rootGroup, options);
if (endpoints is WebApplication app)
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs
new file mode 100644
index 00000000..480e882b
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs
@@ -0,0 +1,34 @@
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Extensions;
+
+internal static class HttpContextRequestExtensions
+{
+ private const string FormCacheKey = "__uauth_form";
+
+ public static async Task GetCachedFormAsync(this HttpContext ctx)
+ {
+ if (!ctx.Request.HasFormContentType)
+ return null;
+
+ if (ctx.Items.TryGetValue(FormCacheKey, out var existing) && existing is IFormCollection cached)
+ return cached;
+
+ try
+ {
+ ctx.Request.EnableBuffering();
+ var form = await ctx.Request.ReadFormAsync();
+ ctx.Request.Body.Position = 0;
+ ctx.Items[FormCacheKey] = form;
+ return form;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch
+ {
+ throw new InvalidOperationException();
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs
index 890cdf28..3ca9c43a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs
@@ -5,16 +5,28 @@ namespace CodeBeam.UltimateAuth.Server.Extensions;
internal static class HttpContextReturnUrlExtensions
{
- public static string? GetReturnUrl(this HttpContext ctx)
+ public static async Task GetReturnUrlAsync(this HttpContext ctx)
{
- if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue(UAuthConstants.Form.ReturnUrl, out var form))
+ if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query))
{
- return form.ToString();
+ return query.ToString();
}
- if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query))
+ if (ctx.Request.HasFormContentType)
{
- return query.ToString();
+ try
+ {
+ var form = await ctx.GetCachedFormAsync();
+
+ if (form?.TryGetValue(UAuthConstants.Form.ReturnUrl, out var formValue) == true)
+ {
+ return formValue.ToString();
+ }
+ }
+ catch (IOException)
+ {
+ return null;
+ }
}
return null;
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index 4778a004..589f059f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -168,7 +168,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddSingleton();
@@ -183,6 +185,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
@@ -203,14 +206,13 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
- services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
- services.TryAddScoped();
services.TryAddSingleton();
services.TryAddScoped();
@@ -225,7 +227,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
services.TryAddSingleton();
- services.TryAddScoped();
services.TryAddSingleton();
services.TryAddScoped();
@@ -393,6 +394,45 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC
return services;
}
+
+ public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null)
+ {
+ services.PostConfigure(options =>
+ {
+ configure?.Invoke(options.Hub);
+ });
+
+ services.TryAddSingleton();
+
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+
+ services.AddCors(options =>
+ {
+ options.AddPolicy("UAuthHub", policy =>
+ {
+ var sp = services.BuildServiceProvider();
+ var serverOptions = sp.GetRequiredService>().Value;
+
+ var origins = serverOptions.Hub.AllowedClientOrigins
+ .Select(OriginHelper.Normalize)
+ .ToArray();
+
+ if (origins.Length > 0)
+ {
+ policy
+ .WithOrigins(origins)
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials()
+ .WithExposedHeaders("X-UAuth-Refresh");
+ }
+ });
+ });
+
+ return services;
+ }
}
internal sealed class NullTenantResolver : ITenantIdResolver
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
index 5efccabe..23e99b20 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
@@ -1,5 +1,8 @@
-using CodeBeam.UltimateAuth.Server.Middlewares;
+using CodeBeam.UltimateAuth.Core.Runtime;
+using CodeBeam.UltimateAuth.Server.Middlewares;
using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace CodeBeam.UltimateAuth.Server.Extensions;
@@ -15,8 +18,23 @@ public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app)
return app;
}
- public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app)
+ public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null)
{
+ var logger = app.ApplicationServices
+ .GetRequiredService()
+ .CreateLogger("UltimateAuth");
+
+ var marker = app.ApplicationServices.GetService();
+ var requiresCors = marker?.RequiresCors == true;
+
+ if (enableCors == true || (enableCors == null && requiresCors))
+ app.UseCors();
+
+ if (requiresCors && enableCors == false)
+ {
+ logger.LogWarning("UAuthHub requires CORS. Either call app.UseCors() or enable it via UseUltimateAuthWithAspNetCore(enableCors: true).");
+ }
+
app.UseUltimateAuth();
app.UseAuthentication();
app.UseAuthorization();
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs
new file mode 100644
index 00000000..7aef3c12
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs
@@ -0,0 +1,16 @@
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+namespace CodeBeam.UltimateAuth.Server.Extensions;
+
+public static class UAuthHubEndpointExtensions
+{
+ public static IEndpointRouteBuilder MapUAuthHub(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/auth/uauthhub");
+ group.MapPost("/entry", HandleHub.HandleHubEntry);
+
+ return endpoints;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs
index 6fac9ccc..c2274f14 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs
@@ -13,3 +13,8 @@ public interface ILoginOrchestrator
{
Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default);
}
+
+internal interface IInternalLoginOrchestrator
+{
+ Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default);
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs
new file mode 100644
index 00000000..56892530
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Server.Flows;
+
+internal enum LoginExecutionMode
+{
+ Preview = 0,
+ Commit = 1
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs
new file mode 100644
index 00000000..aeb960f4
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Server.Flows;
+
+internal sealed record LoginExecutionOptions
+{
+ public LoginExecutionMode Mode { get; init; } = LoginExecutionMode.Commit;
+ public bool SuppressFailureAttempt { get; init; }
+ public bool SuppressSuccessReset { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
index c34d264b..18734ab8 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
@@ -18,7 +18,7 @@
namespace CodeBeam.UltimateAuth.Server.Flows;
-internal sealed class LoginOrchestrator : ILoginOrchestrator
+internal sealed class LoginOrchestrator : ILoginOrchestrator, IInternalLoginOrchestrator
{
private readonly ILoginIdentifierResolver _identifierResolver;
private readonly IEnumerable _credentialProviders; // authentication
@@ -31,6 +31,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator
private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk
private readonly UAuthEventDispatcher _events;
private readonly UAuthServerOptions _options;
+ private readonly IClock _clock;
public LoginOrchestrator(
ILoginIdentifierResolver identifierResolver,
@@ -43,7 +44,8 @@ public LoginOrchestrator(
ISessionStoreFactory storeFactory,
IAuthenticationSecurityManager authenticationSecurityManager,
UAuthEventDispatcher events,
- IOptions options)
+ IOptions options,
+ IClock clock)
{
_identifierResolver = identifierResolver;
_credentialProviders = credentialProviders;
@@ -56,17 +58,21 @@ public LoginOrchestrator(
_authenticationSecurityManager = authenticationSecurityManager;
_events = events;
_options = options.Value;
+ _clock = clock;
}
- public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default)
+ public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default)
+ => LoginAsync(flow, request, new LoginExecutionOptions { Mode = LoginExecutionMode.Commit }, ct);
+
+ public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (flow.Device.DeviceId is not DeviceId deviceId)
throw new UAuthConflictException("Device id could not resolved.");
- var now = request.At ?? DateTimeOffset.UtcNow;
- var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct);
+ var now = _clock.UtcNow;
+ var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct);
var userKey = resolution?.UserKey;
bool userExists = false;
@@ -77,19 +83,18 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
if (userKey is not null)
{
- var user = await _users.GetAsync(request.Tenant, userKey.Value, ct);
+ var user = await _users.GetAsync(flow.Tenant, userKey.Value, ct);
if (user is not null && user.CanAuthenticate && !user.IsDeleted)
{
userExists = true;
-
- accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(request.Tenant, userKey.Value, ct);
+ accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(flow.Tenant, userKey.Value, ct);
if (accountState.IsLocked(now))
{
return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0);
}
- factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(request.Tenant, userKey.Value, request.Factor, ct);
+ factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct);
if (factorState.IsLocked(now))
{
@@ -98,7 +103,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
foreach (var provider in _credentialProviders)
{
- var credentials = await provider.GetByUserAsync(request.Tenant, userKey.Value, ct);
+ var credentials = await provider.GetByUserAsync(flow.Tenant, userKey.Value, ct);
foreach (var credential in credentials)
{
@@ -119,7 +124,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
}
// TODO: Add create-time uniqueness guard for chain id for concurrency
- var sessionStore = _storeFactory.Create(request.Tenant);
+ var sessionStore = _storeFactory.Create(flow.Tenant);
SessionChainId? chainId = null;
if (userKey is not null)
@@ -133,7 +138,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
// TODO: Add accountState here, currently it only checks factor state
var decisionContext = new LoginDecisionContext
{
- Tenant = request.Tenant,
+ Tenant = flow.Tenant,
Identifier = request.Identifier,
CredentialsValid = credentialsValid,
UserExists = userExists,
@@ -144,32 +149,36 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
var decision = _authority.Decide(decisionContext);
- var max = _options.Login.MaxFailedAttempts;
-
if (decision.Kind == LoginDecisionKind.Deny)
{
if (userKey is not null && userExists && factorState is not null)
{
- var securityVersion = factorState.SecurityVersion;
- factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure);
- await _authenticationSecurityManager.UpdateAsync(factorState, securityVersion, ct);
-
DateTimeOffset? lockedUntil = null;
int? remainingAttempts = null;
+ if (!loginExecution.SuppressFailureAttempt)
+ {
+ var version = factorState.SecurityVersion;
+ factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure);
+ await _authenticationSecurityManager.UpdateAsync(factorState, version, ct);
+ }
+
if (_options.Login.IncludeFailureDetails)
{
- if (factorState.IsLocked(now))
+ var stateForResponse = factorState;
+
+ if (stateForResponse.IsLocked(now))
{
- lockedUntil = factorState.LockedUntil;
+ lockedUntil = stateForResponse.LockedUntil;
remainingAttempts = 0;
}
else if (_options.Login.MaxFailedAttempts > 0)
{
- remainingAttempts = _options.Login.MaxFailedAttempts - factorState.FailedAttempts;
+ remainingAttempts = _options.Login.MaxFailedAttempts - stateForResponse.FailedAttempts;
}
}
+
return LoginResult.Failed(
factorState.IsLocked(now)
? AuthFailureReason.LockedOut
@@ -194,18 +203,23 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
return LoginResult.Failed(AuthFailureReason.InvalidCredentials);
// After this point, the login is successful. We can reset any failure counts and proceed to create a session.
- if (factorState is not null)
+ if (loginExecution.Mode == LoginExecutionMode.Preview)
+ {
+ return LoginResult.SuccessPreview();
+ }
+
+ if (!loginExecution.SuppressSuccessReset && factorState is not null)
{
var version = factorState.SecurityVersion;
factorState = factorState.RegisterSuccess();
await _authenticationSecurityManager.UpdateAsync(factorState, version, ct);
}
- var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct);
+ var claims = await _claimsProvider.GetClaimsAsync(flow.Tenant, userKey.Value, ct);
var sessionContext = new AuthenticatedSessionContext
{
- Tenant = request.Tenant,
+ Tenant = flow.Tenant,
UserKey = userKey.Value,
Now = now,
Device = flow.Device,
@@ -224,7 +238,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
{
var tokenContext = new TokenIssuanceContext
{
- Tenant = request.Tenant,
+ Tenant = flow.Tenant,
UserKey = userKey.Value,
SessionId = issuedSession.Session.SessionId,
ChainId = issuedSession.Session.ChainId,
@@ -239,7 +253,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
}
await _events.DispatchAsync(
- new UserLoggedInContext(request.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));
+ new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));
return LoginResult.Success(issuedSession.Session.SessionId, tokens);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs
new file mode 100644
index 00000000..a32dc31f
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs
@@ -0,0 +1,16 @@
+using System.Security.Cryptography;
+using System.Text;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Server.Flows;
+
+internal static class LoginPreviewFingerprint
+{
+ public static string Create(TenantKey tenant, string identifier, CredentialType factor, string secret, DeviceId deviceId)
+ {
+ var normalized = $"{tenant.Value}|{identifier}|{factor}|{deviceId.Value}|{secret}";
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
+ return Convert.ToHexString(bytes);
+ }
+}
\ No newline at end of file
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
index 1f1f22b2..ac788f61 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
@@ -11,8 +11,8 @@ public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string
if (artifact.IsExpired(now))
return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired);
- //if (!IsContextValid(artifact.Context, completionContext))
- //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch);
+ if (!IsContextValid(artifact.Context, completionContext))
+ return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch);
if (artifact.ChallengeMethod != PkceChallengeMethod.S256)
return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod);
@@ -34,7 +34,7 @@ private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnap
if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal))
return false;
- if (!string.Equals(original.DeviceId, completion.DeviceId, StringComparison.Ordinal))
+ if (!Equals(original.Device, completion.Device))
return false;
return true;
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs
index 9b5d390b..2186a502 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs
@@ -1,9 +1,11 @@
-namespace CodeBeam.UltimateAuth.Server.Flows;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Flows;
internal sealed class PkceAuthorizeRequest
{
public string CodeChallenge { get; init; } = default!;
public string ChallengeMethod { get; init; } = default!;
public string? RedirectUri { get; init; }
- public string? DeviceId { get; init; }
+ public required DeviceContext Device { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs
index 37c10714..289d1b84 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Options;
namespace CodeBeam.UltimateAuth.Server.Flows;
@@ -14,12 +15,12 @@ public PkceContextSnapshot(
UAuthClientProfile clientProfile,
TenantKey tenant,
string? redirectUri,
- string? deviceId)
+ DeviceContext device)
{
ClientProfile = clientProfile;
Tenant = tenant;
RedirectUri = redirectUri;
- DeviceId = deviceId;
+ Device = device;
}
///
@@ -42,5 +43,5 @@ public PkceContextSnapshot(
/// Optional device binding identifier.
/// Enables future hard-binding of PKCE flows to devices.
///
- public string? DeviceId { get; }
+ public DeviceContext Device { get; }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs
index 22015398..a14f8437 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs
@@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure;
public interface ITransportCredentialResolver
{
- TransportCredential? Resolve(HttpContext context);
+ ValueTask ResolveAsync(HttpContext context);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs
index 2a23d8b3..2751b2a3 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs
@@ -14,121 +14,85 @@ public TransportCredentialResolver(IOptionsMonitor server)
_server = server;
}
- public TransportCredential? Resolve(HttpContext context)
+ public async ValueTask ResolveAsync(HttpContext context)
{
var cookies = _server.CurrentValue.Cookie;
- if (TryFromAuthorizationHeader(context, out var bearer))
- return bearer;
-
- if (TryFromCookies(context, cookies, out var cookie))
- return cookie;
-
- if (TryFromQuery(context, out var query))
- return query;
-
- if (TryFromBody(context, out var body))
- return body;
-
- if (TryFromHub(context, out var hub))
- return hub;
-
- return null;
+ return await TryFromAuthorizationHeaderAsync(context)
+ ?? await TryFromCookiesAsync(context, cookies)
+ ?? await TryFromQueryAsync(context)
+ ?? await TryFromBodyAsync(context)
+ ?? await TryFromHubAsync(context);
}
// TODO: Make scheme configurable, shouldn't be hard coded
- private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCredential credential)
+ private static async ValueTask TryFromAuthorizationHeaderAsync(HttpContext ctx)
{
- credential = default!;
-
if (!ctx.Request.Headers.TryGetValue("Authorization", out var header))
- return false;
+ return null;
var value = header.ToString();
if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
- return false;
+ return null;
var token = value["Bearer ".Length..].Trim();
if (string.IsNullOrWhiteSpace(token))
- return false;
+ return null;
- credential = new TransportCredential
+ return new TransportCredential
{
Kind = TransportCredentialKind.AccessToken,
Value = token,
TenantId = ctx.GetTenant().Value,
- Device = ctx.GetDevice()
+ Device = await ctx.GetDeviceAsync()
};
-
- return true;
}
- private static bool TryFromCookies(
- HttpContext ctx,
- UAuthCookiePolicyOptions cookieSet,
- out TransportCredential credential)
+ private static async ValueTask TryFromCookiesAsync(HttpContext ctx, UAuthCookiePolicyOptions cookieSet)
{
- credential = default!;
-
- // Session cookie
if (TryReadCookie(ctx, cookieSet.Session.Name, out var session))
- {
- credential = Build(ctx, TransportCredentialKind.Session, session);
- return true;
- }
+ return await BuildAsync(ctx, TransportCredentialKind.Session, session);
- // Refresh token cookie
if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh))
- {
- credential = Build(ctx, TransportCredentialKind.RefreshToken, refresh);
- return true;
- }
+ return await BuildAsync(ctx, TransportCredentialKind.RefreshToken, refresh);
- // Access token cookie (optional)
if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access))
- {
- credential = Build(ctx, TransportCredentialKind.AccessToken, access);
- return true;
- }
+ return await BuildAsync(ctx, TransportCredentialKind.AccessToken, access);
- return false;
+ return null;
}
- private static bool TryFromQuery(HttpContext ctx, out TransportCredential credential)
+ private static async ValueTask TryFromQueryAsync(HttpContext ctx)
{
- credential = default!;
-
if (!ctx.Request.Query.TryGetValue("access_token", out var token))
- return false;
+ return null;
var value = token.ToString();
if (string.IsNullOrWhiteSpace(value))
- return false;
+ return null;
- credential = new TransportCredential
+ return new TransportCredential
{
Kind = TransportCredentialKind.AccessToken,
Value = value,
TenantId = ctx.GetTenant().Value,
- Device = ctx.GetDevice()
+ Device = await ctx.GetDeviceAsync()
};
-
- return true;
}
- private static bool TryFromBody(HttpContext ctx, out TransportCredential credential)
+ private static ValueTask TryFromBodyAsync(HttpContext ctx)
{
- credential = default!;
// intentionally empty for now
// body parsing is expensive and opt-in later
- return false;
+
+ return ValueTask.FromResult(null);
}
- private static bool TryFromHub(HttpContext ctx, out TransportCredential credential)
+ private static ValueTask TryFromHubAsync(HttpContext ctx)
{
- credential = default!;
// UAuthHub detection can live here later
- return false;
+
+ return ValueTask.FromResult(null);
}
private static bool TryReadCookie(HttpContext ctx, string name, out string value)
@@ -149,12 +113,12 @@ private static bool TryReadCookie(HttpContext ctx, string name, out string value
return true;
}
- private static TransportCredential Build(HttpContext ctx, TransportCredentialKind kind, string value)
+ private static async Task BuildAsync(HttpContext ctx, TransportCredentialKind kind, string value)
=> new()
{
Kind = kind,
Value = value,
TenantId = ctx.GetTenant().Value,
- Device = ctx.GetDevice()
+ Device = await ctx.GetDeviceAsync()
};
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs
similarity index 71%
rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs
rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs
index a798db95..3a8efc49 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs
@@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure;
/// Gets the credential from the HTTP context.
/// IPrimaryCredentialResolver is used to determine which kind of credential to resolve.
///
-public interface IFlowCredentialResolver
+public interface IValidateCredentialResolver
{
- ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response);
+ Task ResolveAsync(HttpContext context, EffectiveAuthResponse response);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs
similarity index 72%
rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs
rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs
index 8aa3f572..1923e5c3 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs
@@ -8,29 +8,29 @@
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-internal sealed class FlowCredentialResolver : IFlowCredentialResolver
+internal sealed class ValidateCredentialResolver : IValidateCredentialResolver
{
private readonly IPrimaryCredentialResolver _primaryResolver;
- public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver)
+ public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver)
{
_primaryResolver = primaryResolver;
}
- public ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response)
+ public async Task ResolveAsync(HttpContext context, EffectiveAuthResponse response)
{
var kind = _primaryResolver.Resolve(context);
return kind switch
{
- PrimaryGrantKind.Stateful => ResolveSession(context, response),
- PrimaryGrantKind.Stateless => ResolveAccessToken(context, response),
+ PrimaryGrantKind.Stateful => await ResolveSession(context, response),
+ PrimaryGrantKind.Stateless => await ResolveAccessToken(context, response),
_ => null
};
}
- private static ResolvedCredential? ResolveSession(HttpContext context, EffectiveAuthResponse response)
+ private static async Task ResolveSession(HttpContext context, EffectiveAuthResponse response)
{
var delivery = response.SessionIdDelivery;
@@ -52,11 +52,11 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver)
Kind = PrimaryGrantKind.Stateful,
Value = raw.Trim(),
Tenant = context.GetTenant(),
- Device = context.GetDevice()
+ Device = await context.GetDeviceAsync()
};
}
- private static ResolvedCredential? ResolveAccessToken(HttpContext context, EffectiveAuthResponse response)
+ private static async Task ResolveAccessToken(HttpContext context, EffectiveAuthResponse response)
{
var delivery = response.AccessTokenDelivery;
@@ -84,8 +84,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver)
Kind = PrimaryGrantKind.Stateless,
Value = value,
Tenant = context.GetTenant(),
- Device = context.GetDevice()
+ Device = await context.GetDeviceAsync()
};
}
-
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs
index dbcc0a50..8fa959b7 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs
@@ -1,33 +1,44 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Abstractions;
+using CodeBeam.UltimateAuth.Server.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-// TODO: This is a very basic implementation.
-// Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. (Add IDeviceInfoParser)
+// TODO: Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection.
public sealed class DeviceResolver : IDeviceResolver
{
- public DeviceInfo Resolve(HttpContext context)
+ private readonly IUserAgentParser _userAgentParser;
+
+ public DeviceResolver(IUserAgentParser userAgentParser)
+ {
+ _userAgentParser = userAgentParser;
+ }
+
+ public async Task ResolveAsync(HttpContext context)
{
var request = context.Request;
- var rawDeviceId = ResolveRawDeviceId(context);
+ var rawDeviceId = await ResolveRawDeviceId(context);
if (!DeviceId.TryCreate(rawDeviceId, out var deviceId))
{
//throw new InvalidOperationException("device_id_required");
}
var ua = request.Headers.UserAgent.ToString();
+ var parsed = _userAgentParser.Parse(ua);
+
var deviceInfo = new DeviceInfo
{
DeviceId = deviceId,
- Platform = ResolvePlatform(ua),
- OperatingSystem = ResolveOperatingSystem(ua),
- Browser = ResolveBrowser(ua),
+ DeviceType = parsed.DeviceType,
+ Platform = parsed.Platform,
+ OperatingSystem = parsed.OperatingSystem,
+ Browser = parsed.Browser,
UserAgent = ua,
IpAddress = ResolveIp(context)
};
@@ -35,14 +46,21 @@ public DeviceInfo Resolve(HttpContext context)
return deviceInfo;
}
- private static string? ResolveRawDeviceId(HttpContext context)
+ private static async Task ResolveRawDeviceId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-UDID", out var header))
return header.ToString();
- if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.Device, out var formValue) && !StringValues.IsNullOrEmpty(formValue))
+ if (context.Request.HasFormContentType)
{
- return formValue.ToString();
+ var form = await context.GetCachedFormAsync();
+
+ if (form is not null &&
+ form.TryGetValue(UAuthConstants.Form.Device, out var formValue) &&
+ !StringValues.IsNullOrEmpty(formValue))
+ {
+ return formValue.ToString();
+ }
}
if (context.Request.Cookies.TryGetValue("udid", out var cookie))
@@ -51,63 +69,6 @@ public DeviceInfo Resolve(HttpContext context)
return null;
}
- private static string? ResolvePlatform(string ua)
- {
- var s = ua.ToLowerInvariant();
-
- if (s.Contains("ipad") || s.Contains("tablet") || s.Contains("sm-t") /* bazı samsung tabletler */)
- return "tablet";
-
- if (s.Contains("mobi") || s.Contains("iphone") || s.Contains("android"))
- return "mobile";
-
- return "desktop";
- }
-
- private static string? ResolveOperatingSystem(string ua)
- {
- var s = ua.ToLowerInvariant();
-
- if (s.Contains("iphone") || s.Contains("ipad") || s.Contains("cpu os") || s.Contains("ios"))
- return "ios";
-
- if (s.Contains("android"))
- return "android";
-
- if (s.Contains("windows nt"))
- return "windows";
-
- if (s.Contains("mac os x") || s.Contains("macintosh"))
- return "macos";
-
- if (s.Contains("linux"))
- return "linux";
-
- return "unknown";
- }
-
- private static string? ResolveBrowser(string ua)
- {
- var s = ua.ToLowerInvariant();
-
- if (s.Contains("edg/"))
- return "edge";
-
- if (s.Contains("opr/") || s.Contains("opera"))
- return "opera";
-
- if (s.Contains("chrome/") && !s.Contains("chromium/"))
- return "chrome";
-
- if (s.Contains("safari/") && !s.Contains("chrome/") && !s.Contains("crios/"))
- return "safari";
-
- if (s.Contains("firefox/"))
- return "firefox";
-
- return "unknown";
- }
-
private static string? ResolveIp(HttpContext context)
{
var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs
new file mode 100644
index 00000000..9af186da
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs
@@ -0,0 +1,78 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Server.Stores;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Options;
+using System.Text;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+internal class HandleHub
+{
+ internal static async Task HandleHubEntry(HttpContext ctx, IAuthStore store, IClock clock, IOptions options)
+ {
+ var form = await ctx.GetCachedFormAsync();
+
+ if (form is null)
+ return Results.BadRequest("Form content required.");
+
+ var authorizationCode = form["authorization_code"].ToString();
+ var codeVerifier = form["code_verifier"].ToString();
+ var deviceId = form["device_id"].ToString();
+ var returnUrl = form["return_url"].ToString();
+
+ if (!Enum.TryParse(form["__uauth_client_profile"], ignoreCase: true, out var clientProfile))
+ {
+ clientProfile = UAuthClientProfile.NotSpecified;
+ }
+
+ var hubSessionId = HubSessionId.New();
+
+ var payload = new HubFlowPayload();
+ payload.Set("authorization_code", authorizationCode);
+ payload.Set("code_verifier", codeVerifier);
+
+ var tenant = ctx.GetTenant();
+
+ var deviceRaw = form["device"].FirstOrDefault();
+ DeviceContext device;
+
+ if (!string.IsNullOrWhiteSpace(deviceRaw))
+ {
+ try
+ {
+ var bytes = WebEncoders.Base64UrlDecode(deviceRaw);
+ var json = Encoding.UTF8.GetString(bytes);
+
+ device = JsonSerializer.Deserialize(json) ?? DeviceContext.Anonymous();
+ }
+ catch
+ {
+ device = DeviceContext.Anonymous();
+ }
+ }
+ else
+ {
+ device = DeviceContext.Anonymous();
+ }
+
+ var artifact = new HubFlowArtifact(
+ hubSessionId,
+ HubFlowType.Login,
+ clientProfile,
+ tenant,
+ device,
+ returnUrl,
+ payload,
+ clock.UtcNow.Add(options.Value.Hub.FlowLifetime));
+
+ await store.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact);
+ return Results.Redirect($"{options.Value.Hub.LoginPath}?{UAuthConstants.Query.Hub}={hubSessionId.Value}");
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs
index 646e780a..55c96fa4 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs
@@ -31,6 +31,8 @@ public HubFlowReader(IAuthStore store, IClock clock)
FlowType = flow.FlowType,
ClientProfile = flow.ClientProfile,
ReturnUrl = flow.ReturnUrl,
+ Error = flow.Error,
+ AttemptCount = flow.AttemptCount,
IsExpired = flow.IsExpired(now),
IsCompleted = flow.IsCompleted,
IsActive = !flow.IsExpired(now) && !flow.IsCompleted
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs
new file mode 100644
index 00000000..6944fc73
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Runtime;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+public class UAuthHubMarker : IUAuthHubMarker
+{
+ public bool RequiresCors => true;
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs
new file mode 100644
index 00000000..3dbf79af
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs
@@ -0,0 +1,12 @@
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+internal static class OriginHelper
+{
+ public static string Normalize(string origin)
+ {
+ if (string.IsNullOrWhiteSpace(origin))
+ return string.Empty;
+
+ return origin.Trim().TrimEnd('/').ToLowerInvariant();
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs
index f137f88e..68b2e7a5 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs
@@ -100,11 +100,11 @@ private static void ValidateAllowed(string baseAddress, UAuthServerOptions optio
if (options.Hub.AllowedClientOrigins.Count == 0)
return;
- if (!options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress)))
+ var normalized = OriginHelper.Normalize(baseAddress);
+
+ if (!options.Hub.AllowedClientOrigins.Any(o => OriginHelper.Normalize(o) == normalized))
{
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/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs
index 2d904064..96b6f414 100644
--- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs
@@ -10,7 +10,7 @@ public sealed class UAuthHubServerOptions
/// Lifetime of hub flow artifacts (UI orchestration).
/// Should be short-lived.
///
- public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(2);
+ public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(5);
public string? LoginPath { get; set; } = "/login";
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs
new file mode 100644
index 00000000..71185aab
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs
@@ -0,0 +1,12 @@
+using CodeBeam.UltimateAuth.Server.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Services;
+
+public interface IHubFlowService
+{
+ Task BeginLoginAsync(HubBeginRequest request, CancellationToken ct = default);
+
+ Task ContinuePkceAsync(string hubSessionId, string authorizationCode, string codeVerifier, CancellationToken ct = default);
+
+ Task ConsumeAsync(string hubSessionId, CancellationToken ct = default);
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs
new file mode 100644
index 00000000..e2c3c6f0
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs
@@ -0,0 +1,12 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Auth;
+
+namespace CodeBeam.UltimateAuth.Server.Services;
+
+public interface IPkceService
+{
+ Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default);
+ Task CompleteAsync(AuthFlowContext auth, PkceCompleteRequest request, CancellationToken ct = default);
+ Task RefreshAsync(HubFlowArtifact hub, CancellationToken ct = default);
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
similarity index 72%
rename from src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
index 54924e2a..4445c7e1 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
@@ -1,5 +1,6 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Flows;
namespace CodeBeam.UltimateAuth.Server.Services;
@@ -25,3 +26,9 @@ public interface IUAuthFlowService
Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default);
}
+
+internal interface IUAuthInternalFlowService
+{
+ Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default);
+ Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default);
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs
new file mode 100644
index 00000000..6081eb86
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs
@@ -0,0 +1,81 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Contracts;
+using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Server.Stores;
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Services;
+
+internal sealed class HubFlowService : IHubFlowService
+{
+ private readonly IAuthStore _authStore;
+ private readonly IClock _clock;
+ private readonly UAuthServerOptions _options;
+
+ public HubFlowService(
+ IAuthStore authStore,
+ IClock clock,
+ IOptions options)
+ {
+ _authStore = authStore;
+ _clock = clock;
+ _options = options.Value;
+ }
+
+ public async Task BeginLoginAsync(HubBeginRequest request, CancellationToken ct = default)
+ {
+ if (!string.IsNullOrWhiteSpace(request.PreviousHubSessionId))
+ {
+ await _authStore.ConsumeAsync(new AuthArtifactKey(request.PreviousHubSessionId), ct);
+ }
+
+ var hubSessionId = HubSessionId.New();
+
+ var payload = new HubFlowPayload();
+ payload.Set("authorization_code", request.AuthorizationCode);
+ payload.Set("code_verifier", request.CodeVerifier);
+
+ var artifact = new HubFlowArtifact(
+ hubSessionId,
+ HubFlowType.Login,
+ request.ClientProfile,
+ request.Tenant,
+ request.Device,
+ request.ReturnUrl,
+ payload,
+ _clock.UtcNow.Add(_options.Hub.FlowLifetime));
+
+ await _authStore.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact, ct);
+
+ return new HubSessionResult
+ {
+ HubSessionId = hubSessionId.Value
+ };
+ }
+
+ public async Task ContinuePkceAsync(string hubSessionId, string authorizationCode, string codeVerifier, CancellationToken ct = default)
+ {
+ var key = new AuthArtifactKey(hubSessionId);
+
+ var artifact = await _authStore.GetAsync(key, ct) as HubFlowArtifact;
+
+ if (artifact is null)
+ throw new InvalidOperationException("Hub session not found.");
+
+ artifact.Payload.Set("authorization_code", authorizationCode);
+ artifact.Payload.Set("code_verifier", codeVerifier);
+
+ artifact.ClearError();
+
+ await _authStore.StoreAsync(key, artifact, ct);
+ }
+
+ public async Task ConsumeAsync(string hubSessionId, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(hubSessionId))
+ return;
+
+ await _authStore.ConsumeAsync(new AuthArtifactKey(hubSessionId), ct);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
new file mode 100644
index 00000000..c64bff3e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
@@ -0,0 +1,181 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Flows;
+using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Server.Stores;
+using Microsoft.Extensions.Options;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace CodeBeam.UltimateAuth.Server.Services;
+
+internal sealed class PkceService : IPkceService
+{
+ private readonly IAuthStore _authStore;
+ private readonly IPkceAuthorizationValidator _validator;
+ private readonly IUAuthFlowService _flow;
+ private readonly IClock _clock;
+ private readonly UAuthServerOptions _options;
+
+ public PkceService(IAuthStore authStore, IPkceAuthorizationValidator validator, IUAuthFlowService flow, IClock clock, IOptions options)
+ {
+ _authStore = authStore;
+ _validator = validator;
+ _flow = flow;
+ _clock = clock;
+ _options = options.Value;
+ }
+
+ public async Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(command.CodeChallenge))
+ throw new InvalidOperationException("code_challenge is required.");
+
+ if (!string.Equals(command.ChallengeMethod, "S256", StringComparison.Ordinal))
+ throw new InvalidOperationException("Only S256 supported.");
+
+ var authorizationCode = AuthArtifactKey.New();
+
+ var snapshot = new PkceContextSnapshot(
+ clientProfile: command.ClientProfile,
+ tenant: command.Tenant,
+ redirectUri: command.RedirectUri,
+ device: command.Device
+ );
+
+ var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds);
+
+ var artifact = new PkceAuthorizationArtifact(
+ authorizationCode: authorizationCode,
+ codeChallenge: command.CodeChallenge,
+ challengeMethod: PkceChallengeMethod.S256,
+ expiresAt: expiresAt,
+ context: snapshot
+ );
+
+ await _authStore.StoreAsync(authorizationCode, artifact, ct);
+
+ return new PkceAuthorizeResponse
+ {
+ AuthorizationCode = authorizationCode.Value,
+ ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds
+ };
+ }
+
+ public async Task CompleteAsync(AuthFlowContext auth, PkceCompleteRequest request, CancellationToken ct = default)
+ {
+ var key = new AuthArtifactKey(request.AuthorizationCode);
+
+ var artifact = await _authStore.ConsumeAsync(key, ct) as PkceAuthorizationArtifact;
+
+ if (artifact is null)
+ {
+ return new PkceCompleteResult
+ {
+ InvalidPkce = true
+ };
+ }
+
+ var validation = _validator.Validate(
+ artifact,
+ request.CodeVerifier,
+ new PkceContextSnapshot(
+ clientProfile: artifact.Context.ClientProfile,
+ tenant: artifact.Context.Tenant,
+ redirectUri: artifact.Context.RedirectUri,
+ device: artifact.Context.Device),
+ _clock.UtcNow);
+
+ if (!validation.Success)
+ {
+ artifact.RegisterAttempt();
+
+ return new PkceCompleteResult
+ {
+ Success = false,
+ FailureReason = AuthFailureReason.InvalidCredentials
+ };
+ }
+
+ var loginRequest = new LoginRequest
+ {
+ Identifier = request.Identifier!,
+ Secret = request.Secret!,
+ RequestTokens = auth.AllowsTokenIssuance
+ };
+
+ var execution = new AuthExecutionContext
+ {
+ EffectiveClientProfile = artifact.Context.ClientProfile,
+ Device = artifact.Context.Device
+ };
+
+ var result = await _flow.LoginAsync(auth, execution, loginRequest, ct);
+
+ return new PkceCompleteResult
+ {
+ Success = result.IsSuccess,
+ FailureReason = result.FailureReason,
+ LoginResult = result
+ };
+ }
+
+ public async Task RefreshAsync(HubFlowArtifact hub, CancellationToken ct = default)
+ {
+ if (hub.Payload.TryGet("authorization_code", out var oldCode) && !string.IsNullOrWhiteSpace(oldCode))
+ {
+ await _authStore.ConsumeAsync(new AuthArtifactKey(oldCode), ct);
+ }
+
+ var verifier = CreateVerifier();
+ var challenge = CreateChallenge(verifier);
+ var device = hub.Device;
+ var authorizationCode = AuthArtifactKey.New();
+
+ var snapshot = new PkceContextSnapshot(
+ clientProfile: hub.ClientProfile,
+ tenant: hub.Tenant,
+ redirectUri: hub.ReturnUrl,
+ device: device
+ );
+
+ var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds);
+
+ var artifact = new PkceAuthorizationArtifact(
+ authorizationCode,
+ challenge,
+ PkceChallengeMethod.S256,
+ expiresAt,
+ snapshot
+ );
+
+ await _authStore.StoreAsync(authorizationCode, artifact, ct);
+
+ return new HubCredentials
+ {
+ AuthorizationCode = authorizationCode.Value,
+ CodeVerifier = verifier
+ };
+ }
+
+ private static string CreateVerifier()
+ {
+ return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ private static string CreateChallenge(string verifier)
+ {
+ using var sha256 = SHA256.Create();
+ var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
+
+ return Convert.ToBase64String(bytes)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
index cc247a8e..23b7e174 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
@@ -9,11 +9,12 @@
namespace CodeBeam.UltimateAuth.Server.Services;
-internal sealed class UAuthFlowService : IUAuthFlowService
+internal sealed class UAuthFlowService : IUAuthFlowService, IUAuthInternalFlowService
{
private readonly IAuthFlowContextAccessor _authFlow;
private readonly IAuthFlowContextFactory _authFlowContextFactory;
private readonly ILoginOrchestrator _loginOrchestrator;
+ private readonly IInternalLoginOrchestrator _internalLoginOrchestrator;
private readonly ISessionOrchestrator _orchestrator;
private readonly UAuthEventDispatcher _events;
@@ -21,12 +22,14 @@ public UAuthFlowService(
IAuthFlowContextAccessor authFlow,
IAuthFlowContextFactory authFlowContextFactory,
ILoginOrchestrator loginOrchestrator,
+ IInternalLoginOrchestrator internalLoginOrchestrator,
ISessionOrchestrator orchestrator,
UAuthEventDispatcher events)
{
_authFlow = authFlow;
_authFlowContextFactory = authFlowContextFactory;
_loginOrchestrator = loginOrchestrator;
+ _internalLoginOrchestrator = internalLoginOrchestrator;
_orchestrator = orchestrator;
_events = events;
}
@@ -47,6 +50,22 @@ public async Task LoginAsync(AuthFlowContext flow, AuthExecutionCon
return await _loginOrchestrator.LoginAsync(effectiveFlow, request, ct);
}
+ public Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default)
+ {
+ return _internalLoginOrchestrator.LoginAsync(flow, request, loginExecution, ct);
+ }
+
+ public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default)
+ {
+ var effectiveFlow = execution.EffectiveClientProfile is null
+ ? flow
+ : await _authFlowContextFactory.RecreateWithClientProfileAsync(flow, (UAuthClientProfile)execution.EffectiveClientProfile, ct);
+ effectiveFlow = execution.Device is null
+ ? effectiveFlow
+ : await _authFlowContextFactory.RecreateWithDeviceAsync(effectiveFlow, execution.Device, ct);
+ return await _internalLoginOrchestrator.LoginAsync(effectiveFlow, request, loginExecution, ct);
+ }
+
public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default)
{
var authFlow = _authFlow.Current;
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs
similarity index 100%
rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs
rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs
new file mode 100644
index 00000000..48b248c3
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs
@@ -0,0 +1,59 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using Microsoft.AspNetCore.Components;
+
+namespace CodeBeam.UltimateAuth.Client.Blazor;
+
+public abstract class UAuthHubLayoutComponentBase : LayoutComponentBase
+{
+ [Inject] protected NavigationManager Navigation { get; set; } = default!;
+ [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!;
+
+ protected HubFlowState? HubState { get; private set; }
+
+ protected bool HasHub => HubState?.Exists == true;
+ protected bool IsHubAuthorized => HasHub && HubState?.IsActive == true;
+ protected bool IsExpired => HubState?.IsExpired == true;
+ protected HubErrorCode? Error => HubState?.Error;
+
+ private string? _lastHubKey;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ var hubKey = ResolveHubKey();
+
+ if (string.IsNullOrWhiteSpace(hubKey))
+ {
+ HubState = null;
+ return;
+ }
+
+ if (_lastHubKey == hubKey && HubState is not null)
+ return;
+
+ _lastHubKey = hubKey;
+
+ if (HubSessionId.TryParse(hubKey, out var hubId))
+ {
+ HubState = await HubFlowReader.GetStateAsync(hubId);
+ }
+ else
+ {
+ HubState = null;
+ }
+ }
+
+ protected virtual string? ResolveHubKey()
+ {
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
+
+ if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue))
+ return hubValue.ToString();
+
+ return null;
+ }
+}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs
new file mode 100644
index 00000000..2fc629d1
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs
@@ -0,0 +1,44 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using Microsoft.AspNetCore.Components;
+
+namespace CodeBeam.UltimateAuth.Client.Blazor;
+
+public abstract class UAuthHubPageBase : UAuthReactiveComponentBase
+{
+ [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!;
+ [Inject] protected NavigationManager Nav { get; set; } = default!;
+
+ [Parameter]
+ [SupplyParameterFromQuery(Name = UAuthConstants.Query.Hub)]
+ public string? HubKey { get; set; }
+
+ protected HubFlowState? HubState { get; private set; }
+
+ protected bool IsHubAuthorized => HubState is { Exists: true, IsActive: true };
+
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+ await ReloadState();
+ }
+
+ public async Task ReloadState()
+ {
+ if (string.IsNullOrWhiteSpace(HubKey))
+ {
+ HubState = null;
+ return;
+ }
+
+ if (HubSessionId.TryParse(HubKey, out var id))
+ {
+ HubState = await HubFlowReader.GetStateAsync(id);
+ }
+ else
+ {
+ HubState = null;
+ }
+ }
+}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs
similarity index 100%
rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs
rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
index 75ebab7b..93eb2736 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
@@ -12,7 +12,7 @@
@inject IOptions Options
@inject NavigationManager Navigation
-