Flow
+@inject ISnackbar Snackbar
+@inject IFlowCredentialResolver CredentialResolver
+@inject IUAuthClient UAuthClient
+@inject NavigationManager Nav
+@inject IUAuthProductInfoProvider ProductInfo
+@inject AuthenticationStateProvider AuthStateProvider
+@inject UAuthClientDiagnostics Diagnostics
-Home
-Hello, world!
+
+
+ @if (_state == null || !_state.IsActive)
+ {
+
+ This page cannot be accessed directly.
+ UAuthHub login flows can only be initiated by an authorized client application.
+
+ return;
+ }
+
+
+ Welcome to UltimateAuth!
+
+
+ Login
+
+
+
+
+ Programmatic Pkce Login
+
+
+
+ @ProductInfo.Get().ProductName v @ProductInfo.Get().Version
+ Client Profile: @ProductInfo.Get().ClientProfile.ToString()
+
+
+
+ Hub SessionId: @_state?.HubSessionId
+ Client Profile: @_state?.ClientProfile
+ Return Url: @_state?.ReturnUrl
+ Flow Type: @_state?.FlowType
+ IsActive: @_state?.IsActive
+
+
+
-Welcome to your new app.
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
new file mode 100644
index 0000000..7ae27ac
--- /dev/null
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
@@ -0,0 +1,133 @@
+using CodeBeam.UltimateAuth.Client.Contracts;
+using CodeBeam.UltimateAuth.Client.Utilities;
+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;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ if (string.IsNullOrWhiteSpace(HubKey))
+ {
+ _state = null;
+ return;
+ }
+
+ _state = await HubFlowReader.GetStateAsync(new HubSessionId(HubKey));
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ return;
+
+ var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error");
+
+ if (!string.IsNullOrWhiteSpace(currentError))
+ {
+ Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error);
+ await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error");
+ }
+
+ 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))
+ {
+ return;
+ }
+
+ if (_state is null || !_state.Exists)
+ return;
+
+ if (_state?.IsActive != true)
+ {
+ await StartNewPkceAsync();
+ return;
+ }
+ }
+
+ // For testing & debugging
+ private async Task ProgrammaticPkceLogin()
+ {
+ var hub = _state;
+
+ if (hub is null)
+ return;
+
+ var credentials = await HubCredentialResolver.ResolveAsync(new HubSessionId(HubKey));
+
+ var request = new PkceLoginRequest
+ {
+ Identifier = "Admin",
+ Secret = "Password!",
+ AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty,
+ CodeVerifier = credentials?.CodeVerifier ?? string.Empty,
+ ReturnUrl = _state?.ReturnUrl ?? string.Empty
+ };
+ await UAuthClient.CompletePkceLoginAsync(request);
+ }
+
+ private async Task StartNewPkceAsync()
+ {
+ var returnUrl = await ResolveReturnUrlAsync();
+ await UAuthClient.BeginPkceAsync(returnUrl);
+ }
+
+ private async Task ResolveReturnUrlAsync()
+ {
+ var fromContext = _state?.ReturnUrl;
+ if (!string.IsNullOrWhiteSpace(fromContext))
+ return fromContext;
+
+ var uri = Nav.ToAbsoluteUri(Nav.Uri);
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
+
+ if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru))
+ return ru!;
+
+ if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey))
+ {
+ var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!));
+ if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl))
+ 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)
+ {
+ if (errorKey == "invalid")
+ {
+ return "Login failed.";
+ }
+
+ return "Failed attempt.";
+ }
+
+ }
+}
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor
index 105855d..91968d6 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor
@@ -1,6 +1,8 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
index 741144c..09765c2 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor
@@ -5,7 +5,12 @@
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.JSInterop
-@using UltimateAuth.Sample.UAuthHub
-@using UltimateAuth.Sample.UAuthHub.Components
-@using UltimateAuth.Sample.UAuthHub.Components.Layout
+@using CodeBeam.UltimateAuth.Sample.UAuthHub
+@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components
+@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout
+@using CodeBeam.UltimateAuth.Client
+
+@using MudBlazor
+@using MudExtensions
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs
new file mode 100644
index 0000000..30e3261
--- /dev/null
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs
@@ -0,0 +1,53 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+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("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,
+ tenantId: null,
+ 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
new file mode 100644
index 0000000..eb5fe64
--- /dev/null
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs
@@ -0,0 +1,5 @@
+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 f7a8d49..67c4055 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs
@@ -1,13 +1,18 @@
+using CodeBeam.UltimateAuth.Client.Extensions;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Extensions;
+using CodeBeam.UltimateAuth.Core.Runtime;
using CodeBeam.UltimateAuth.Credentials.InMemory;
+using CodeBeam.UltimateAuth.Sample.UAuthHub.Components;
using CodeBeam.UltimateAuth.Security.Argon2;
using CodeBeam.UltimateAuth.Server.Authentication;
using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
using CodeBeam.UltimateAuth.Sessions.InMemory;
using CodeBeam.UltimateAuth.Tokens.InMemory;
using MudBlazor.Services;
using MudExtensions.Services;
-using CodeBeam.UltimateAuth.Sample.UAuthHub.Components;
var builder = WebApplication.CreateBuilder(args);
@@ -15,6 +20,8 @@
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+builder.Services.AddControllers();
+
builder.Services.AddMudServices();
builder.Services.AddMudExtensions();
@@ -44,6 +51,14 @@
.AddUltimateAuthInMemoryTokens()
.AddUltimateAuthArgon2();
+builder.Services.AddUltimateAuthClient(o =>
+{
+ //o.Refresh.Interval = TimeSpan.FromSeconds(5);
+ o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
+});
+
+builder.Services.AddSingleton();
+
builder.Services.AddCors(options =>
{
options.AddPolicy("WasmSample", policy =>
@@ -65,7 +80,7 @@
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
-app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
+//app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseCors("WasmSample");
@@ -76,6 +91,8 @@
app.MapUAuthEndpoints();
app.MapStaticAssets();
+
+app.MapControllers();
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
index b9a0c20..c36676a 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
@@ -2,6 +2,7 @@
@page "/login"
@using CodeBeam.UltimateAuth.Client
@using CodeBeam.UltimateAuth.Client.Authentication
+@using CodeBeam.UltimateAuth.Client.Device
@using CodeBeam.UltimateAuth.Client.Diagnostics
@using CodeBeam.UltimateAuth.Core.Abstractions
@using CodeBeam.UltimateAuth.Core.Runtime
@@ -10,9 +11,9 @@
@using CodeBeam.UltimateAuth.Server.Infrastructure
@using CodeBeam.UltimateAuth.Server.Services
@inject IUAuthStateManager StateManager
-@inject IUAuthFlowService Flow
+@inject IUAuthFlowService Flow
@inject ISnackbar Snackbar
-@inject ISessionQueryService SessionQuery
+@inject ISessionQueryService SessionQuery
@inject IFlowCredentialResolver CredentialResolver
@inject IClock Clock
@inject IUAuthCookieManager CookieManager
@@ -22,6 +23,7 @@
@inject IUAuthProductInfoProvider ProductInfo
@inject AuthenticationStateProvider AuthStateProvider
@inject UAuthClientDiagnostics Diagnostics
+@inject IDeviceIdProvider DeviceIdProvider
@@ -53,7 +55,7 @@
State of Authentication:
@(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name)
- UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserId)
+ UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserKey)
Authorized context is shown. @context.User.Identity.IsAuthenticated
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
index c3d3682..806ffd9 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
@@ -1,5 +1,7 @@
using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Device;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
using Microsoft.AspNetCore.Components.Authorization;
using MudBlazor;
@@ -36,14 +38,14 @@ private void OnDiagnosticsChanged()
private async Task ProgrammaticLogin()
{
+ var deviceId = await DeviceIdProvider.GetOrCreateAsync();
var request = new LoginRequest
{
Identifier = "Admin",
Secret = "Password!",
+ Device = DeviceContext.FromDeviceId(deviceId),
};
await UAuthClient.LoginAsync(request);
- await UAuthClient.ValidateAsync();
- await StateManager.EnsureAsync();
_authState = await AuthStateProvider.GetAuthenticationStateAsync();
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor
index 792148c..7017313 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor
@@ -1,8 +1,10 @@
-
+@using CodeBeam.UltimateAuth.Client.Components
+
+
-
+
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor
index 095f674..343dbef 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor
@@ -1,11 +1,11 @@
-
+@using CodeBeam.UltimateAuth.Client.Components
-
+
@@ -18,4 +18,4 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor
deleted file mode 100644
index ef23cb3..0000000
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor
+++ /dev/null
@@ -1,18 +0,0 @@
-@page "/counter"
-
-Counter
-
-Counter
-
-Current count: @currentCount
-
-
-
-@code {
- private int currentCount = 0;
-
- private void IncrementCount()
- {
- currentCount++;
- }
-}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
index fb58035..e2f5e68 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
@@ -1,7 +1,10 @@
@page "/"
@page "/login"
@using CodeBeam.UltimateAuth.Client.Authentication
+@using CodeBeam.UltimateAuth.Client.Device
@using CodeBeam.UltimateAuth.Client.Diagnostics
+@using CodeBeam.UltimateAuth.Client.Runtime
+@using CodeBeam.UltimateAuth.Core.Abstractions
@using CodeBeam.UltimateAuth.Core.Runtime
@inject IUAuthStateManager StateManager
@inject IHttpClientFactory HttpClientFactory
@@ -12,6 +15,8 @@
@inject IUAuthProductInfoProvider ProductInfo
@inject AuthenticationStateProvider AuthStateProvider
@inject UAuthClientDiagnostics Diagnostics
+@inject IUAuthClientBootstrapper Bootstrapper
+@inject IDeviceIdProvider DeviceIdProvider
@@ -32,6 +37,7 @@
Programmatic Login
+ Start Pkce Login
@@ -40,8 +46,11 @@
+ StateHasChanged
+ Refresh Auth State
State of Authentication:
- @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name)
+ From UltimateAuth: @(Auth?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(Auth?.UserKey)
+ From ASPNET Core: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name)
Authorized context is shown.
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
index 2fdd35b..f4a6fef 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
@@ -1,5 +1,8 @@
using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Authentication;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using MudBlazor;
@@ -7,6 +10,9 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages
{
public partial class Home
{
+ [CascadingParameter]
+ public UAuthState Auth { get; set; }
+
private string? _username;
private string? _password;
@@ -17,7 +23,7 @@ public partial class Home
protected override async Task OnInitializedAsync()
{
Diagnostics.Changed += OnDiagnosticsChanged;
- _authState = await AuthStateProvider.GetAuthenticationStateAsync();
+ //_authState = await AuthStateProvider.GetAuthenticationStateAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -37,13 +43,20 @@ private void OnDiagnosticsChanged()
private async Task ProgrammaticLogin()
{
+ var device = await DeviceIdProvider.GetOrCreateAsync();
var request = new LoginRequest
{
Identifier = "Admin",
Secret = "Password!",
+ Device = DeviceContext.FromDeviceId(device),
};
await UAuthClient.LoginAsync(request);
- _authState = await AuthStateProvider.GetAuthenticationStateAsync();
+ }
+
+ private async Task StartPkceLogin()
+ {
+ await UAuthClient.BeginPkceAsync();
+ //await UAuthClient.NavigateToHubLoginAsync(Nav.Uri);
}
private async Task ValidateAsync()
@@ -66,6 +79,11 @@ private async Task RefreshAsync()
await UAuthClient.RefreshAsync();
}
+ private async Task RefreshAuthState()
+ {
+ await StateManager.OnLoginAsync();
+ }
+
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs
index 7fa3f55..98ba183 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs
@@ -1,8 +1,6 @@
-using CodeBeam.UltimateAuth.Client.Authentication;
using CodeBeam.UltimateAuth.Client.Extensions;
using CodeBeam.UltimateAuth.Core.Extensions;
using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm;
-using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs
index dafe722..efded6e 100644
--- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs
@@ -19,8 +19,10 @@ public interface IBrowserPostClient
///
///
///
- Task FetchPostAsync(string endpoint);
+ Task FetchPostAsync(string endpoint, IDictionary? data = null);
- Task> FetchPostJsonAsync(string url);
+ //Task> FetchPostJsonAsync(string url, IDictionary? data = null);
+
+ Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs
new file mode 100644
index 0000000..f2f18eb
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs
@@ -0,0 +1,12 @@
+using CodeBeam.UltimateAuth.Client.Contracts;
+
+namespace CodeBeam.UltimateAuth.Client.Utilities
+{
+ public interface IBrowserStorage
+ {
+ ValueTask SetAsync(StorageScope scope, string key, string value);
+ ValueTask GetAsync(StorageScope scope, string key);
+ ValueTask RemoveAsync(StorageScope scope, string key);
+ ValueTask ExistsAsync(StorageScope scope, string key);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs
index 8a1a767..fd9dbae 100644
--- a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Client.Runtime;
+using CodeBeam.UltimateAuth.Core.Abstractions;
namespace CodeBeam.UltimateAuth.Client.Authentication
{
@@ -6,26 +7,23 @@ internal sealed class DefaultUAuthStateManager : IUAuthStateManager
{
private readonly IUAuthClient _client;
private readonly IClock _clock;
+ private readonly IUAuthClientBootstrapper _bootstrapper;
public UAuthState State { get; } = UAuthState.Anonymous();
- public DefaultUAuthStateManager(IUAuthClient client, IClock clock)
+ public DefaultUAuthStateManager(IUAuthClient client, IClock clock, IUAuthClientBootstrapper bootstrapper)
{
_client = client;
_clock = clock;
+ _bootstrapper = bootstrapper;
}
public async Task EnsureAsync(CancellationToken ct = default)
- {
- //if (!State.IsAuthenticated)
- // return;
-
- //if (!State.IsStale)
- // return;
-
+ {
if (State.IsAuthenticated && !State.IsStale)
return;
+ await _bootstrapper.EnsureStartedAsync();
var result = await _client.ValidateAsync();
if (!result.IsValid)
@@ -37,24 +35,12 @@ public async Task EnsureAsync(CancellationToken ct = default)
State.ApplySnapshot(result.Snapshot, _clock.UtcNow);
}
- public async Task OnLoginAsync(CancellationToken ct = default)
+ public Task OnLoginAsync()
{
- var result = await _client.ValidateAsync();
-
- if (!result.IsValid || result.Snapshot is null)
- {
- State.Clear();
- return;
- }
-
- var now = _clock.UtcNow;
-
- State.ApplySnapshot(
- result.Snapshot,
- validatedAt: now);
+ State.MarkStale();
+ return Task.CompletedTask;
}
-
public Task OnLogoutAsync()
{
State.Clear();
diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs
index efe49e7..e97c8c8 100644
--- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs
@@ -21,7 +21,7 @@ public interface IUAuthStateManager
///
/// Called after a successful login.
///
- Task OnLoginAsync(CancellationToken ct = default);
+ Task OnLoginAsync();
///
/// Called after logout.
diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs
index d41b1f3..89d48fa 100644
--- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs
@@ -14,13 +14,6 @@ public UAuthAuthenticationStateProvider(IUAuthStateManager stateManager)
_stateManager.State.Changed += _ => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
- //public override Task GetAuthenticationStateAsync()
- //{
- // _stateManager.EnsureAsync();
- // var principal = _stateManager.State.ToClaimsPrincipal();
- // return Task.FromResult(new AuthenticationState(principal));
- //}
-
public override Task GetAuthenticationStateAsync()
{
var principal = _stateManager.State.ToClaimsPrincipal();
diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs
new file mode 100644
index 0000000..5c45d21
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs
@@ -0,0 +1,26 @@
+using Microsoft.AspNetCore.Components;
+
+namespace CodeBeam.UltimateAuth.Client.Authentication
+{
+ internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable
+ {
+ private readonly IUAuthStateManager _stateManager;
+
+ public UAuthCascadingStateProvider(IUAuthStateManager stateManager)
+ : base(() => stateManager.State, isFixed: false)
+ {
+ _stateManager = stateManager;
+ _stateManager.State.Changed += OnStateChanged;
+ }
+
+ private void OnStateChanged(UAuthStateChangeReason _)
+ {
+ NotifyChangedAsync();
+ }
+
+ public void Dispose()
+ {
+ _stateManager.State.Changed -= OnStateChanged;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs
index ffbe01c..10ef9f5 100644
--- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs
@@ -16,7 +16,7 @@ private UAuthState() { }
public bool IsAuthenticated { get; private set; }
- public UserId? UserId { get; private set; }
+ public UserKey? UserKey { get; private set; }
public string? TenantId { get; private set; }
@@ -44,7 +44,13 @@ private UAuthState() { }
internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt)
{
- UserId = snapshot.UserId;
+ if (string.IsNullOrWhiteSpace(snapshot.UserId))
+ {
+ Clear();
+ return;
+ }
+
+ UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(snapshot.UserId);
TenantId = snapshot.TenantId;
Claims = snapshot.Claims;
@@ -82,7 +88,7 @@ internal void Clear()
{
Claims = ClaimsSnapshot.Empty;
- UserId = null;
+ UserKey = null;
TenantId = null;
IsAuthenticated = false;
diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
index ecff96b..3d97996 100644
--- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
+++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
@@ -13,16 +13,19 @@
+
+
+
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor
index 0ab9dba..c7678be 100644
--- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor
@@ -1,6 +1,9 @@
@* TODO: Optional double-submit prevention for native form submit *@
@namespace CodeBeam.UltimateAuth.Client
+@using CodeBeam.UltimateAuth.Client.Device
@using CodeBeam.UltimateAuth.Client.Options
+@using CodeBeam.UltimateAuth.Core.Abstractions
+@using CodeBeam.UltimateAuth.Core.Contracts
@using CodeBeam.UltimateAuth.Core.Options
@using Microsoft.Extensions.Options
@inject IJSRuntime JS
@@ -12,6 +15,15 @@
+
+
+
+ @if (LoginType == UAuthLoginType.Pkce)
+ {
+
+
+
+ }
@ChildContent
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs
index acaa36e..8dbab50 100644
--- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs
@@ -1,12 +1,29 @@
-using CodeBeam.UltimateAuth.Client.Infrastructure;
+using CodeBeam.UltimateAuth.Client.Device;
+using CodeBeam.UltimateAuth.Client.Infrastructure;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Options;
using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.WebUtilities;
using Microsoft.JSInterop;
namespace CodeBeam.UltimateAuth.Client
{
public partial class UALoginForm
{
+ [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!;
+ private DeviceId? _deviceId;
+
+ [Inject]
+ IHubCredentialResolver HubCredentialResolver { get; set; } = null!;
+
+ [Inject]
+ IHubFlowReader HubFlowReader { get; set; } = null!;
+
+ [Inject]
+ IHubCapabilities HubCapabilities { get; set; } = null!;
+
[Parameter]
public string? Identifier { get; set; }
@@ -14,11 +31,23 @@ public partial class UALoginForm
public string? Secret { get; set; }
[Parameter]
- public string? Endpoint { get; set; } = "/auth/login";
+ public string? Endpoint { get; set; }
[Parameter]
public string? ReturnUrl { get; set; }
+ //[Parameter]
+ //public IHubCredentialResolver? HubCredentialResolver { get; set; }
+
+ //[Parameter]
+ //public IHubFlowReader? HubFlowReader { get; set; }
+
+ [Parameter]
+ public HubSessionId? HubSessionId { get; set; }
+
+ [Parameter]
+ public UAuthLoginType LoginType { get; set; } = UAuthLoginType.Password;
+
[Parameter]
public RenderFragment? ChildContent { get; set; }
@@ -27,6 +56,58 @@ public partial class UALoginForm
private ElementReference _form;
+ private HubCredentials? _credentials;
+ private HubFlowState? _flow;
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ await ReloadCredentialsAsync();
+ await ReloadStateAsync();
+
+ if (LoginType == UAuthLoginType.Pkce && !HubCapabilities.SupportsPkce)
+ {
+ throw new InvalidOperationException("PKCE login requires UAuthHub (Blazor Server). " +
+ "PKCE is not supported in this client profile." +
+ "Change LoginType to password or place this component to a server-side project.");
+ }
+
+ //if (LoginType == UAuthLoginType.Pkce && EffectiveHubSessionId is null)
+ //{
+ // throw new InvalidOperationException("PKCE login requires an active Hub flow. " +
+ // "No 'hub' query parameter was found."
+ // );
+ //}
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ return;
+
+ _deviceId = await DeviceIdProvider.GetOrCreateAsync();
+ StateHasChanged();
+ }
+
+ public async Task ReloadCredentialsAsync()
+ {
+ if (LoginType != UAuthLoginType.Pkce)
+ return;
+
+ if (HubCredentialResolver is null || EffectiveHubSessionId is null)
+ return;
+
+ _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value);
+ }
+
+ public async Task ReloadStateAsync()
+ {
+ if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null)
+ return;
+
+ _flow = await HubFlowReader.GetStateAsync(EffectiveHubSessionId.Value);
+ }
+
public async Task SubmitAsync()
{
if (_form.Context is null)
@@ -37,31 +118,51 @@ public async Task SubmitAsync()
private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString();
+ private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce
+ ? Options.Value.Endpoints.PkceComplete
+ : Options.Value.Endpoints.Login;
+
+
private string ResolvedEndpoint
{
get
{
var loginPath = string.IsNullOrWhiteSpace(Endpoint)
- ? Options.Value.Endpoints.Login
+ ? EffectiveEndpoint
: Endpoint;
- var baseUrl = UAuthUrlBuilder.Combine(
- Options.Value.Endpoints.Authority,
- loginPath);
-
+ var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath);
var returnUrl = EffectiveReturnUrl;
if (string.IsNullOrWhiteSpace(returnUrl))
return baseUrl;
- return $"{baseUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}";
+ return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}returnUrl={Uri.EscapeDataString(returnUrl)}";
}
}
- private string EffectiveReturnUrl =>
- !string.IsNullOrWhiteSpace(ReturnUrl)
+ private string EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl)
? ReturnUrl
- : Navigation.Uri;
+ : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri;
+
+ private HubSessionId? EffectiveHubSessionId
+ {
+ get
+ {
+ if (HubSessionId is not null)
+ return HubSessionId;
+
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var query = QueryHelpers.ParseQuery(uri.Query);
+
+ if (query.TryGetValue("hub", out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed))
+ {
+ return parsed;
+ }
+
+ return null;
+ }
+ }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor
new file mode 100644
index 0000000..f076918
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor
@@ -0,0 +1,18 @@
+@using CodeBeam.UltimateAuth.Client.Runtime
+@inject IUAuthClientBootstrapper Bootstrapper
+
+
+ @ChildContent
+
+
+@code {
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ return;
+
+ await Bootstrapper.EnsureStartedAsync();
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor
new file mode 100644
index 0000000..921b35e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor
@@ -0,0 +1,13 @@
+@using CodeBeam.UltimateAuth.Client.Authentication
+@using CodeBeam.UltimateAuth.Client.Runtime
+@using CodeBeam.UltimateAuth.Core.Contracts
+@using Microsoft.AspNetCore.Components.Authorization
+@inject IUAuthStateManager StateManager
+@inject AuthenticationStateProvider AuthStateProvider
+@inject IUAuthClientBootstrapper Bootstrapper
+
+
+
+ @ChildContent
+
+
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs
new file mode 100644
index 0000000..f66787a
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs
@@ -0,0 +1,46 @@
+using CodeBeam.UltimateAuth.Client.Authentication;
+using Microsoft.AspNetCore.Components;
+
+namespace CodeBeam.UltimateAuth.Client.Components
+{
+ public partial class UAuthAuthenticationState
+ {
+ private bool _initialized;
+ private UAuthState _uauthState;
+
+ [Parameter]
+ public RenderFragment ChildContent { get; set; } = default!;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ return;
+
+ if (_initialized)
+ return;
+
+ _initialized = true;
+ //await Bootstrapper.EnsureStartedAsync();
+ await StateManager.EnsureAsync();
+ _uauthState = StateManager.State;
+
+ StateManager.State.Changed += OnStateChanged;
+ }
+
+ private void OnStateChanged(UAuthStateChangeReason _)
+ {
+ //StateManager.EnsureAsync();
+ if (_ == UAuthStateChangeReason.MarkedStale)
+ {
+ StateManager.EnsureAsync();
+ }
+ _uauthState = StateManager.State;
+ InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ StateManager.State.Changed -= OnStateChanged;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor
index 6f5d1de..89aeb7f 100644
--- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor
@@ -1,5 +1,9 @@
@namespace CodeBeam.UltimateAuth.Client
@using CodeBeam.UltimateAuth.Client.Abstractions
+@using CodeBeam.UltimateAuth.Client.Device
+@using CodeBeam.UltimateAuth.Client.Infrastructure
+@inject IDeviceIdProvider DeviceIdProvider
+@inject IBrowserUAuthBridge BrowserUAuthBridge
@inject ISessionCoordinator Coordinator
@implements IAsyncDisposable
diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs
index a958d7b..c3d2827 100644
--- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs
@@ -22,7 +22,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
return;
_started = true;
+ // TODO: Add device id auto creation for MVC, this is only for blazor.
+ var deviceId = await DeviceIdProvider.GetOrCreateAsync();
+ await BrowserUAuthBridge.SetDeviceIdAsync(deviceId.Value);
await Coordinator.StartAsync();
+ StateHasChanged();
}
private async void HandleReauthRequired()
diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs
index e6449d0..643423d 100644
--- a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs
@@ -4,6 +4,7 @@ public sealed record BrowserPostJsonResult
{
public bool Ok { get; init; }
public int Status { get; init; }
+ public string? RefreshOutcome { get; init; }
public T? Body { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs
new file mode 100644
index 0000000..446b982
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs
@@ -0,0 +1,12 @@
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.Client.Contracts
+{
+ public sealed class BrowserPostRawResult
+ {
+ public bool Ok { get; init; }
+ public int Status { get; init; }
+ public string? RefreshOutcome { get; init; }
+ public JsonElement? Body { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs
new file mode 100644
index 0000000..a8fcad4
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Client.Contracts
+{
+ internal sealed class PkceClientState
+ {
+ public string Verifier { get; init; } = default!;
+ public string AuthorizationCode { get; init; } = default!;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs
new file mode 100644
index 0000000..9e823ee
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Client.Contracts
+{
+ public enum StorageScope
+ {
+ Session,
+ Local
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs
new file mode 100644
index 0000000..7bb5f7f
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs
@@ -0,0 +1,28 @@
+using CodeBeam.UltimateAuth.Client.Contracts;
+using CodeBeam.UltimateAuth.Client.Utilities;
+
+namespace CodeBeam.UltimateAuth.Client.Device;
+
+public sealed class BrowserDeviceIdStorage : IDeviceIdStorage
+{
+ private const string Key = "udid";
+ private readonly IBrowserStorage _storage;
+
+ public BrowserDeviceIdStorage(IBrowserStorage storage)
+ {
+ _storage = storage;
+ }
+
+ public async ValueTask LoadAsync(CancellationToken ct = default)
+ {
+ if (!await _storage.ExistsAsync(StorageScope.Local, Key))
+ return null;
+
+ return await _storage.GetAsync(StorageScope.Local, Key);
+ }
+
+ public ValueTask SaveAsync(string deviceId, CancellationToken ct = default)
+ {
+ return _storage.SetAsync(StorageScope.Local, Key, deviceId);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs
new file mode 100644
index 0000000..ccca8c9
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs
@@ -0,0 +1,17 @@
+using CodeBeam.UltimateAuth.Client.Device;
+using CodeBeam.UltimateAuth.Core.Domain;
+using System.Security.Cryptography;
+
+namespace CodeBeam.UltimateAuth.Client.Devices;
+
+public sealed class DefaultDeviceIdGenerator : IDeviceIdGenerator
+{
+ public DeviceId Generate()
+ {
+ Span buffer = stackalloc byte[32];
+ RandomNumberGenerator.Fill(buffer);
+
+ var raw = Convert.ToBase64String(buffer);
+ return DeviceId.Create(raw);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs
new file mode 100644
index 0000000..c1f504a
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs
@@ -0,0 +1,38 @@
+using CodeBeam.UltimateAuth.Client.Device;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Client.Devices;
+
+public sealed class DefaultDeviceIdProvider : IDeviceIdProvider
+{
+ private readonly IDeviceIdStorage _storage;
+ private readonly IDeviceIdGenerator _generator;
+
+ private DeviceId? _cached;
+
+ public DefaultDeviceIdProvider(IDeviceIdStorage storage, IDeviceIdGenerator generator)
+ {
+ _storage = storage;
+ _generator = generator;
+ }
+
+ public async ValueTask GetOrCreateAsync(CancellationToken ct = default)
+ {
+ if (_cached is not null)
+ return _cached.Value;
+
+ var raw = await _storage.LoadAsync(ct);
+
+ if (!string.IsNullOrWhiteSpace(raw))
+ {
+ _cached = DeviceId.Create(raw);
+ return _cached.Value;
+ }
+
+ var generated = _generator.Generate();
+ await _storage.SaveAsync(generated.Value, ct);
+
+ _cached = generated;
+ return generated;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs
new file mode 100644
index 0000000..b19b0dc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Client.Device
+{
+ public interface IDeviceIdGenerator
+ {
+ DeviceId Generate();
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs
new file mode 100644
index 0000000..f8983b5
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Client.Device
+{
+ public interface IDeviceIdProvider
+ {
+ ValueTask GetOrCreateAsync(CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs
new file mode 100644
index 0000000..c355552
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Client.Device
+{
+ public interface IDeviceIdStorage
+ {
+ ValueTask LoadAsync(CancellationToken ct = default);
+ ValueTask SaveAsync(string deviceId, CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs
index 0db7156..752739e 100644
--- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs
@@ -1,10 +1,15 @@
using CodeBeam.UltimateAuth.Client.Abstractions;
using CodeBeam.UltimateAuth.Client.Authentication;
+using CodeBeam.UltimateAuth.Client.Device;
+using CodeBeam.UltimateAuth.Client.Devices;
using CodeBeam.UltimateAuth.Client.Diagnostics;
using CodeBeam.UltimateAuth.Client.Infrastructure;
using CodeBeam.UltimateAuth.Client.Options;
+using CodeBeam.UltimateAuth.Client.Runtime;
+using CodeBeam.UltimateAuth.Client.Utilities;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Options;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -103,11 +108,24 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol
: sp.GetRequiredService();
});
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped>(sp => sp.GetRequiredService());
return services;
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs
index 4bc6e0d..02bc7bc 100644
--- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs
@@ -1,7 +1,6 @@
using CodeBeam.UltimateAuth.Client.Abstractions;
using CodeBeam.UltimateAuth.Client.Contracts;
using CodeBeam.UltimateAuth.Core.Options;
-using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
@@ -29,31 +28,48 @@ public Task NavigatePostAsync(string endpoint, IDictionary? data
}).AsTask();
}
- public async Task FetchPostAsync(string endpoint)
+ public async Task FetchPostAsync(string endpoint, IDictionary? data = null)
{
var result = await _js.InvokeAsync("uauth.post", new
{
url = endpoint,
mode = "fetch",
expectJson = false,
+ data = data,
clientProfile = _coreOptions.ClientProfile.ToString()
});
return result;
}
- public async Task> FetchPostJsonAsync(string endpoint)
+ public async Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null)
{
- var result = await _js.InvokeAsync>("uauth.post", new
- {
- url = endpoint,
- mode = "fetch",
- expectJson = true,
- clientProfile = _coreOptions.ClientProfile.ToString()
- });
-
- return result;
+ var postData = data ?? new Dictionary();
+ return await _js.InvokeAsync("uauth.post",
+ new
+ {
+ url = endpoint,
+ mode = "fetch",
+ expectJson = true,
+ data = postData,
+ clientProfile = _coreOptions.ClientProfile.ToString()
+ });
}
+
+ //public async Task> FetchPostJsonAsync(string endpoint, IDictionary? data = null)
+ //{
+ // var result = await _js.InvokeAsync>("uauth.post", new
+ // {
+ // url = endpoint,
+ // mode = "fetch",
+ // expectJson = true,
+ // data = data,
+ // clientProfile = _coreOptions.ClientProfile.ToString()
+ // });
+
+ // return result;
+ //}
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs
new file mode 100644
index 0000000..b62442d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs
@@ -0,0 +1,30 @@
+using CodeBeam.UltimateAuth.Client.Contracts;
+using Microsoft.JSInterop;
+
+namespace CodeBeam.UltimateAuth.Client.Utilities
+{
+ public sealed class BrowserStorage : IBrowserStorage
+ {
+ private readonly IJSRuntime _js;
+
+ public BrowserStorage(IJSRuntime js)
+ {
+ _js = js;
+ }
+
+ public ValueTask SetAsync(StorageScope scope, string key, string value)
+ => _js.InvokeVoidAsync("uauth.storage.set", Scope(scope), key, value);
+
+ public ValueTask GetAsync(StorageScope scope, string key)
+ => _js.InvokeAsync("uauth.storage.get", Scope(scope), key);
+
+ public ValueTask RemoveAsync(StorageScope scope, string key)
+ => _js.InvokeVoidAsync("uauth.storage.remove", Scope(scope), key);
+
+ public async ValueTask ExistsAsync(StorageScope scope, string key)
+ => await _js.InvokeAsync("uauth.storage.exists", Scope(scope), key);
+
+ private static string Scope(StorageScope scope)
+ => scope == StorageScope.Local ? "local" : "session";
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs
new file mode 100644
index 0000000..2fa8642
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs
@@ -0,0 +1,18 @@
+using Microsoft.JSInterop;
+
+namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+
+internal sealed class BrowserUAuthBridge : IBrowserUAuthBridge
+{
+ private readonly IJSRuntime _js;
+
+ public BrowserUAuthBridge(IJSRuntime js)
+ {
+ _js = js;
+ }
+
+ public ValueTask SetDeviceIdAsync(string deviceId)
+ {
+ return _js.InvokeVoidAsync("uauth.setDeviceId", deviceId);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs
new file mode 100644
index 0000000..d6ed139
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+
+internal interface IBrowserUAuthBridge
+{
+ ValueTask SetDeviceIdAsync(string deviceId);
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs
new file mode 100644
index 0000000..24042f5
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+
+namespace CodeBeam.UltimateAuth.Client.Infrastructure
+{
+ internal sealed class NoOpHubCapabilities : IHubCapabilities
+ {
+ public bool SupportsPkce => false;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs
new file mode 100644
index 0000000..658a865
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Client.Infrastructure
+{
+ internal sealed class NoOpHubCredentialResolver : IHubCredentialResolver
+ {
+ public Task ResolveAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs
new file mode 100644
index 0000000..9b6a776
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Client.Infrastructure
+{
+ internal sealed class NoOpHubFlowReader : IHubFlowReader
+ {
+ public Task GetStateAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs
new file mode 100644
index 0000000..4f8f3ba
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs
@@ -0,0 +1,27 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Client.Options
+{
+ public sealed class PkceLoginOptions
+ {
+ ///
+ /// Enables PKCE login support.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ public string? ReturnUrl { get; init; }
+
+ ///
+ /// Called after authorization_code is issued,
+ /// before redirecting to the Hub.
+ ///
+ public Func? OnAuthorized { get; init; }
+
+ ///
+ /// If false, BeginPkceAsync will NOT redirect automatically.
+ /// Caller is responsible for navigation.
+ ///
+ public bool AutoRedirect { get; init; } = true;
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs
index 0e6ffd1..97a8aee 100644
--- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs
@@ -1,11 +1,11 @@
using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.Options;
namespace CodeBeam.UltimateAuth.Client.Options
{
public sealed class UAuthClientOptions
{
public AuthEndpointOptions Endpoints { get; set; } = new();
+ public LoginOptions Login { get; set; } = new();
public UAuthClientRefreshOptions Refresh { get; set; } = new();
public ReauthOptions Reauth { get; init; } = new();
}
@@ -22,6 +22,28 @@ public sealed class AuthEndpointOptions
public string Refresh { get; set; } = "/auth/refresh";
public string Reauth { get; set; } = "/auth/reauth";
public string Validate { get; set; } = "/auth/validate";
+ public string PkceAuthorize { get; set; } = "/auth/pkce/authorize";
+ public string PkceComplete { get; set; } = "/auth/pkce/complete";
+ public string HubLoginPath { get; set; } = "/uauthhub/login";
+ }
+
+ public sealed class LoginOptions
+ {
+ ///
+ /// Default return URL after a successful login flow.
+ /// If not set, current location will be used.
+ ///
+ public string? DefaultReturnUrl { get; set; }
+
+ ///
+ /// Options related to PKCE-based login flows.
+ ///
+ public PkceLoginOptions Pkce { get; set; } = new();
+
+ ///
+ /// Enables or disables direct credential-based login.
+ ///
+ public bool AllowDirectLogin { get; set; } = true;
}
public sealed class UAuthClientRefreshOptions
diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs
index 0101507..5d3e8a3 100644
--- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Core.Runtime;
using Microsoft.Extensions.DependencyInjection;
namespace CodeBeam.UltimateAuth.Client.Options
@@ -7,6 +8,9 @@ internal sealed class UAuthClientProfileDetector : IClientProfileDetector
{
public UAuthClientProfile Detect(IServiceProvider sp)
{
+ if (sp.GetService() != null)
+ return UAuthClientProfile.UAuthHub;
+
if (Type.GetType("Microsoft.Maui.Controls.Application, Microsoft.Maui.Controls", throwOnError: false) is not null)
return UAuthClientProfile.Maui;
diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs
new file mode 100644
index 0000000..222d8ee
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs
@@ -0,0 +1,9 @@
+using System.Threading.Tasks;
+
+namespace CodeBeam.UltimateAuth.Client.Runtime
+{
+ public interface IUAuthClientBootstrapper
+ {
+ Task EnsureStartedAsync();
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs
new file mode 100644
index 0000000..0acb423
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs
@@ -0,0 +1,53 @@
+using CodeBeam.UltimateAuth.Client.Abstractions;
+using CodeBeam.UltimateAuth.Client.Device;
+using CodeBeam.UltimateAuth.Client.Infrastructure;
+
+// DeviceId is automatically created and managed by UAuthClientProvider. This class is for advanced situations.
+namespace CodeBeam.UltimateAuth.Client.Runtime
+{
+ internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper
+ {
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private bool _started;
+
+ private readonly IDeviceIdProvider _deviceIdProvider;
+ private readonly IBrowserUAuthBridge _browser;
+ private readonly ISessionCoordinator _coordinator;
+
+ public bool IsStarted => _started;
+
+ public UAuthClientBootstrapper(
+ IDeviceIdProvider deviceIdProvider,
+ IBrowserUAuthBridge browser,
+ ISessionCoordinator coordinator)
+ {
+ _deviceIdProvider = deviceIdProvider;
+ _browser = browser;
+ _coordinator = coordinator;
+ }
+
+ public async Task EnsureStartedAsync()
+ {
+ if (_started)
+ return;
+
+ await _gate.WaitAsync();
+ try
+ {
+ if (_started)
+ return;
+
+ var deviceId = await _deviceIdProvider.GetOrCreateAsync();
+ await _browser.SetDeviceIdAsync(deviceId.Value);
+ await _coordinator.StartAsync();
+
+ _started = true;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs
index 3c3416a..5bdcfe5 100644
--- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs
@@ -1,6 +1,5 @@
using CodeBeam.UltimateAuth.Client.Contracts;
using CodeBeam.UltimateAuth.Core.Contracts;
-using System.Security.Claims;
namespace CodeBeam.UltimateAuth.Client
{
@@ -12,5 +11,8 @@ public interface IUAuthClient
Task ReauthAsync();
Task ValidateAsync();
+
+ Task BeginPkceAsync(string? returnUrl = null);
+ Task CompletePkceLoginAsync(PkceLoginRequest request);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs
index 086c3b8..1cf53eb 100644
--- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs
+++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs
@@ -5,10 +5,15 @@
using CodeBeam.UltimateAuth.Client.Extensions;
using CodeBeam.UltimateAuth.Client.Infrastructure;
using CodeBeam.UltimateAuth.Client.Options;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Options;
+using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
-using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Client
{
@@ -16,16 +21,22 @@ internal sealed class UAuthClient : IUAuthClient
{
private readonly IBrowserPostClient _post;
private readonly UAuthClientOptions _options;
+ private readonly UAuthOptions _coreOptions;
private readonly UAuthClientDiagnostics _diagnostics;
+ private readonly NavigationManager _nav;
public UAuthClient(
IBrowserPostClient post,
IOptions options,
- UAuthClientDiagnostics diagnostics)
+ IOptions coreOptions,
+ UAuthClientDiagnostics diagnostics,
+ NavigationManager nav)
{
_post = post;
_options = options.Value;
+ _coreOptions = coreOptions.Value;
_diagnostics = diagnostics;
+ _nav = nav;
}
public async Task LoginAsync(LoginRequest request)
@@ -83,19 +94,131 @@ public async Task ReauthAsync()
public async Task ValidateAsync()
{
var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate);
- var result = await _post.FetchPostJsonAsync(url);
+ var raw = await _post.FetchPostJsonRawAsync(url);
- if (result.Body is null)
- return new AuthValidationResult { IsValid = false, State = "transport" };
+ if (!raw.Ok || raw.Body is null)
+ {
+ return new AuthValidationResult
+ {
+ IsValid = false,
+ State = "transport"
+ };
+ }
+
+ var body = raw.Body.Value.Deserialize(
+ new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
- return new AuthValidationResult
+ return body ?? new AuthValidationResult
{
- IsValid = result.Body.IsValid,
- State = result.Body.State,
- RemainingAttempts = result.Body.RemainingAttempts,
- Snapshot = result.Body.Snapshot,
+ IsValid = false,
+ State = "deserialize"
};
}
+ public async Task BeginPkceAsync(string? returnUrl = null)
+ {
+ var pkce = _options.Login.Pkce;
+
+ if (!pkce.Enabled)
+ throw new InvalidOperationException("PKCE login is disabled by configuration.");
+
+ var verifier = CreateVerifier();
+ var challenge = CreateChallenge(verifier);
+
+ var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize);
+
+ var raw = await _post.FetchPostJsonRawAsync(
+ authorizeUrl,
+ new Dictionary
+ {
+ ["code_challenge"] = challenge,
+ ["challenge_method"] = "S256"
+ });
+
+ if (!raw.Ok || raw.Body is null)
+ throw new InvalidOperationException("PKCE authorize failed.");
+
+ var response = raw.Body.Value.Deserialize(
+ new JsonSerializerOptions{ PropertyNameCaseInsensitive = true });
+
+ if (response is null || string.IsNullOrWhiteSpace(response.AuthorizationCode))
+ throw new InvalidOperationException("Invalid PKCE authorize response.");
+
+ if (pkce.OnAuthorized is not null)
+ await pkce.OnAuthorized(response);
+
+ var resolvedReturnUrl = returnUrl
+ ?? pkce.ReturnUrl
+ ?? _options.Login.DefaultReturnUrl
+ ?? _nav.Uri;
+
+ if (pkce.AutoRedirect)
+ {
+ await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl);
+ }
+ }
+
+ public async Task CompletePkceLoginAsync(PkceLoginRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceComplete);
+
+ var payload = new Dictionary
+ {
+ ["authorization_code"] = request.AuthorizationCode,
+ ["code_verifier"] = request.CodeVerifier,
+ ["return_url"] = request.ReturnUrl,
+
+ ["Identifier"] = request.Identifier ?? string.Empty,
+ ["Secret"] = request.Secret ?? string.Empty
+ };
+
+ await _post.NavigatePostAsync(url, payload);
+ }
+
+ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl)
+ {
+ var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath);
+
+ var data = new Dictionary
+ {
+ ["authorization_code"] = authorizationCode,
+ ["code_verifier"] = codeVerifier,
+ ["return_url"] = returnUrl,
+ ["client_profile"] = _coreOptions.ClientProfile.ToString()
+ };
+
+ return _post.NavigatePostAsync(hubLoginUrl, data);
+ }
+
+
+ // ---------------- PKCE CRYPTO ----------------
+
+ private static string CreateVerifier()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ return Base64UrlEncode(bytes);
+ }
+
+ private static string CreateChallenge(string verifier)
+ {
+ using var sha256 = SHA256.Create();
+ var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier));
+ return Base64UrlEncode(hash);
+ }
+
+ private static string Base64UrlEncode(byte[] input)
+ {
+ return Convert.ToBase64String(input)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js
index 07e521b..94c26bc 100644
--- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js
+++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js
@@ -1,65 +1,141 @@
-window.uauth = {
- submitForm: function (form) {
- if (form) {
- form.submit();
- }
+window.uauth = window.uauth || {};
+
+window.uauth.storage = {
+ set: function (scope, key, value) {
+ const storage = scope === "local"
+ ? window.localStorage
+ : window.sessionStorage;
+
+ storage.setItem(key, value);
+ },
+
+ get: function (scope, key) {
+ const storage = scope === "local"
+ ? window.localStorage
+ : window.sessionStorage;
+
+ return storage.getItem(key);
+ },
+
+ remove: function (scope, key) {
+ const storage = scope === "local"
+ ? window.localStorage
+ : window.sessionStorage;
+
+ storage.removeItem(key);
+ },
+
+ exists: function (scope, key) {
+ const storage = scope === "local"
+ ? window.localStorage
+ : window.sessionStorage;
+
+ return storage.getItem(key) !== null;
}
};
-window.uauth = {
- post: async function (options) {
- const {
- url,
- mode,
- data,
- expectJson,
- clientProfile
- } = options;
-
- if (mode === "navigate") {
- const form = document.createElement("form");
- form.method = "POST";
- form.action = url;
-
- const cp = document.createElement("input");
- cp.type = "hidden";
- cp.name = "__uauth_client_profile";
- cp.value = clientProfile ?? "";
- form.appendChild(cp);
-
- if (data) {
- for (const key in data) {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = key;
- input.value = data[key];
- form.appendChild(input);
- }
- }
+window.uauth.submitForm = function (form) {
+ if (!form)
+ return;
- document.body.appendChild(form);
- form.submit();
- return null;
- }
+ if (!window.uauth.deviceId) {
+ throw new Error("UAuth deviceId is not initialized.");
+ }
+
+ //if (!form.querySelector("input[name='__uauth_device']")) {
+ const udid = document.createElement("input");
+ udid.type = "hidden";
+ udid.name = "__uauth_device";
+ udid.value = window.uauth.deviceId;
+ form.appendChild(udid);
+ //}
+
+ form.submit();
+};
+
+window.uauth.post = async function (options) {
+ const {
+ url,
+ mode,
+ data,
+ expectJson,
+ clientProfile
+ } = options;
- const response = await fetch(url, {
- method: "POST",
- credentials: "include",
- headers: {
- "X-UAuth-ClientProfile": clientProfile
+ if (mode === "navigate") {
+ const form = document.createElement("form");
+ form.method = "POST";
+ form.action = url;
+
+ const cp = document.createElement("input");
+ cp.type = "hidden";
+ cp.name = "__uauth_client_profile";
+ cp.value = clientProfile ?? "";
+ form.appendChild(cp);
+
+ const udid = document.createElement("input");
+ udid.type = "hidden";
+ udid.name = "__uauth_device";
+ udid.value = window.uauth.deviceId;
+ form.appendChild(udid);
+
+ if (data) {
+ for (const key in data) {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = key;
+ input.value = data[key];
+ form.appendChild(input);
}
- });
+ }
+
+ document.body.appendChild(form);
+ form.submit();
+ return null;
+ }
+
+ let body = null;
+ if (!window.uauth.deviceId) {
+ throw new Error("UAuth deviceId is not initialized.");
+ }
+ const headers = {
+ "X-UDID": window.uauth.deviceId,
+ "X-UAuth-ClientProfile": clientProfile
+ };
- let body = null;
- if (expectJson) {
- try { body = await response.json(); } catch { }
+ if (data) {
+ body = new URLSearchParams();
+ for (const key in data) {
+ body.append(key, data[key]);
}
- return {
- ok: response.ok,
- status: response.status,
- refreshOutcome: response.headers.get("X-UAuth-Refresh"),
- body: body
- };
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
}
+
+ const response = await fetch(url, {
+ method: "POST",
+ credentials: "include",
+ headers: headers,
+ body: body
+ });
+
+ let responseBody = null;
+ if (expectJson) {
+ try {
+ responseBody = await response.json();
+ } catch {
+ responseBody = null;
+ }
+ }
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ refreshOutcome: response.headers.get("X-UAuth-Refresh"),
+ body: responseBody
+ };
+};
+
+window.uauth.setDeviceId = function (value) {
+ window.uauth.deviceId = value;
};
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs
new file mode 100644
index 0000000..36bd1b3
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IHubCapabilities
+ {
+ bool SupportsPkce { get; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs
new file mode 100644
index 0000000..78ecb59
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IHubCredentialResolver
+ {
+ Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs
new file mode 100644
index 0000000..82764fb
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IHubFlowReader
+ {
+ Task GetStateAsync(HubSessionId hubSessionId, CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
index 4dcb392..39ec7c5 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
@@ -3,18 +3,18 @@
namespace CodeBeam.UltimateAuth.Core.Abstractions
{
- public interface ISessionIssuer
+ public interface ISessionIssuer
{
- Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
+ Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
- Task> RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default);
+ Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default);
Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default);
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default);
+ Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default);
- Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default);
+ Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default);
- Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at,CancellationToken ct = default);
+ Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at,CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs
index 269cb47..c2c3423 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs
@@ -8,7 +8,7 @@
public interface IUAuthService
{
//IUAuthFlowService Flow { get; }
- IUAuthSessionService Sessions { get; }
+ IUAuthSessionManager Sessions { get; }
//IUAuthTokenService Tokens { get; }
IUAuthUserService Users { get; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs
new file mode 100644
index 0000000..2f759e9
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs
@@ -0,0 +1,27 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ ///
+ /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation.
+ ///
+ public interface IUAuthSessionManager
+ {
+ Task> GetChainsAsync(string? tenantId, UserKey userKey);
+
+ Task> GetSessionsAsync(string? tenantId, SessionChainId chainId);
+
+ Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId);
+
+ Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
+
+ Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at);
+
+ Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId);
+
+ Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at);
+
+ // Hard revoke - admin
+ Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs
deleted file mode 100644
index 73228f1..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Core.Abstractions
-{
- ///
- /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation.
- ///
- /// The type used to uniquely identify the user.
- public interface IUAuthSessionService
- {
- Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
-
- Task>> GetChainsAsync(string? tenantId, TUserId userId);
-
- Task>> GetSessionsAsync(string? tenantId, ChainId chainId);
-
- Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId);
-
- Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
-
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at);
-
- Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId);
-
- Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at);
-
- // Hard revoke - admin
- Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at);
-
- Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs
index 543ec2f..25fac76 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs
@@ -1,21 +1,20 @@
-namespace CodeBeam.UltimateAuth.Core.Abstractions
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
{
///
/// Default session store factory that throws until a real store implementation is registered.
///
- public sealed class DefaultSessionStoreFactory : ISessionStoreFactory
+ internal sealed class DefaultSessionStoreFactory : ISessionStoreKernelFactory
{
- /// Creates a session store instance for the given user ID type, but always throws because no store has been registered.
- /// The tenant identifier, or null in single-tenant mode.
- /// The type used to uniquely identify the user.
- /// Never returns; always throws.
- /// Thrown when no session store implementation has been configured.
- public ISessionStoreKernel Create(string? tenantId)
+ private readonly IServiceProvider _sp;
+
+ public DefaultSessionStoreFactory(IServiceProvider sp)
{
- throw new InvalidOperationException(
- "No session store has been configured." +
- "Call AddUltimateAuthServer().AddSessionStore(...) to register one."
- );
+ _sp = sp;
}
+
+ public ISessionStoreKernel Create(string? tenantId)
+ => _sp.GetRequiredService();
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs
index 6e52d30..edf2d58 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs
@@ -6,21 +6,10 @@
///
public interface IAccessTokenIdStore
{
- Task StoreAsync(
- string? tenantId,
- string jti,
- DateTimeOffset expiresAt,
- CancellationToken ct = default);
+ Task StoreAsync(string? tenantId, string jti, DateTimeOffset expiresAt, CancellationToken ct = default);
- Task IsRevokedAsync(
- string? tenantId,
- string jti,
- CancellationToken ct = default);
+ Task IsRevokedAsync(string? tenantId, string jti, CancellationToken ct = default);
- Task RevokeAsync(
- string? tenantId,
- string jti,
- DateTimeOffset revokedAt,
- CancellationToken ct = default);
+ Task RevokeAsync(string? tenantId, string jti, DateTimeOffset revokedAt, CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs
index cb36059..eb6e52c 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs
@@ -6,33 +6,17 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions;
/// Low-level persistence abstraction for refresh tokens.
/// NO validation logic. NO business rules.
///
-public interface IRefreshTokenStore
+public interface IRefreshTokenStore
{
- Task StoreAsync(string? tenantId,
- StoredRefreshToken token,
- CancellationToken ct = default);
+ Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default);
- Task?> FindByHashAsync(string? tenantId,
- string tokenHash,
- CancellationToken ct = default);
+ Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default);
- Task RevokeAsync(string? tenantId,
- string tokenHash,
- DateTimeOffset revokedAt,
- CancellationToken ct = default);
+ Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default);
- Task RevokeBySessionAsync(string? tenantId,
- AuthSessionId sessionId,
- DateTimeOffset revokedAt,
- CancellationToken ct = default);
+ Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default);
- Task RevokeByChainAsync(string? tenantId,
- ChainId chainId,
- DateTimeOffset revokedAt,
- CancellationToken ct = default);
+ Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default);
- Task RevokeAllForUserAsync(string? tenantId,
- TUserId userId,
- DateTimeOffset revokedAt,
- CancellationToken ct = default);
+ Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default);
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs
deleted file mode 100644
index 8a2c910..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Core.Abstractions
-{
- public interface ISessionActivityWriter
- {
- Task TouchAsync(string? tenantId, ISession session, CancellationToken ct);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
index 902b905..cca6ea0 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
@@ -7,22 +7,24 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions
/// High-level session store abstraction used by UltimateAuth.
/// Encapsulates session, chain, and root orchestration.
///
- public interface ISessionStore
+ public interface ISessionStore
{
///
/// Retrieves an active session by id.
///
- Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default);
+ Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default);
///
/// Creates a new session and associates it with the appropriate chain and root.
///
- Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default);
+ Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default);
///
/// Refreshes (rotates) the active session within its chain.
///
- Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default);
+ Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default);
+
+ Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default);
///
/// Revokes a single session.
@@ -32,11 +34,13 @@ public interface ISessionStore
///
/// Revokes all sessions for a specific user (all devices).
///
- Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default);
+ Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default);
///
/// Revokes all sessions within a specific chain (single device).
///
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default);
+ Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default);
+
+ Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
index 34e9afe..578f242 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
@@ -2,137 +2,35 @@
namespace CodeBeam.UltimateAuth.Core.Abstractions
{
- ///
- /// Defines the low-level persistence operations for sessions, session chains, and session roots in a multi-tenant or single-tenant environment.
- /// Store implementations provide durable and atomic data access.
- ///
- public interface ISessionStoreKernel
+ public interface ISessionStoreKernel
{
- ///
- /// Executes multiple store operations as a single atomic unit.
- /// Implementations must ensure transactional consistency where supported.
- ///
- Task ExecuteAsync(Func action);
-
- ///
- /// Retrieves a session by its identifier within the given tenant context.
- ///
- /// The tenant identifier, or null for single-tenant mode.
- /// The session identifier.
- /// The session instance or null if not found.
- Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId);
-
- ///
- /// Persists a new session or updates an existing one within the tenant scope.
- /// Implementations must ensure atomic writes.
- ///
- /// The tenant identifier, or null.
- /// The session to persist.
- Task SaveSessionAsync(string? tenantId, ISession session);
-
- ///
- /// Marks the specified session as revoked, preventing future authentication.
- /// Revocation timestamp must be stored reliably.
- ///
- /// The tenant identifier, or null.
- /// The session identifier.
- /// The UTC timestamp of revocation.
- Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
-
- ///
- /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules.
- ///
- /// The tenant identifier, or null.
- /// The chain identifier.
- /// A read-only list of sessions.
- Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId);
-
- ///
- /// Retrieves a session chain by identifier. Returns null if the chain does not exist in the provided tenant context.
- ///
- /// The tenant identifier, or null.
- /// The chain identifier.
- /// The chain or null.
- Task?> GetChainAsync(string? tenantId, ChainId chainId);
-
- ///
- /// Inserts a new session chain into the store. Implementations must ensure consistency with the related sessions and session root.
- ///
- /// The tenant identifier, or null.
- /// The chain to save.
- Task SaveChainAsync(string? tenantId, ISessionChain chain);
-
- ///
- /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family.
- ///
- /// The tenant identifier, or null.
- /// The chain to revoke.
- /// The UTC timestamp of revocation.
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at);
-
- ///
- /// Retrieves the active session identifier for the specified chain.
- /// This is typically an O(1) lookup and used for session rotation.
- ///
- /// The tenant identifier, or null.
- /// The chain whose active session is requested.
- /// The active session identifier or null.
- Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId);
-
- ///
- /// Sets or replaces the active session identifier for the specified chain.
- /// Must be atomic to prevent race conditions during refresh.
- ///
- /// The tenant identifier, or null.
- /// The chain whose active session is being set.
- /// The new active session identifier.
- Task SetActiveSessionIdAsync(string? tenantId, ChainId chainId, AuthSessionId sessionId);
-
- ///
- /// Retrieves all session chains belonging to the specified user within the tenant scope.
- ///
- /// The tenant identifier, or null.
- /// The user whose chains are being retrieved.
- /// A read-only list of session chains.
- Task>> GetChainsByUserAsync(string? tenantId, TUserId userId);
-
- ///
- /// Retrieves the session root for the user, which represents the full set of chains and their associated security metadata.
- /// Returns null if the root does not exist.
- ///
- /// The tenant identifier, or null.
- /// The user identifier.
- /// The session root or null.
- Task?> GetSessionRootAsync(string? tenantId, TUserId userId);
-
- ///
- /// Persists a session root structure, usually after chain creation, rotation, or security operations.
- ///
- /// The tenant identifier, or null.
- /// The session root to save.
- Task SaveSessionRootAsync(string? tenantId, ISessionRoot root);
-
- ///
- /// Revokes the session root, invalidating all chains and sessions belonging to the specified user in the tenant scope.
- ///
- /// The tenant identifier, or null.
- /// The user whose root should be revoked.
- /// The UTC timestamp of revocation.
- Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTimeOffset at);
-
- ///
- /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation.
- ///
- /// The tenant identifier, or null.
- /// The current UTC timestamp.
- Task DeleteExpiredSessionsAsync(string? tenantId, DateTimeOffset at);
-
- ///
- /// Retrieves the chain identifier associated with the specified session.
- ///
- /// The tenant identifier, or null.
- /// The session identifier.
- /// The chain identifier or null.
- Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId);
+ Task ExecuteAsync(Func action, CancellationToken ct = default);
+ //string? TenantId { get; }
+
+ // Session
+ Task GetSessionAsync(AuthSessionId sessionId);
+ Task SaveSessionAsync(ISession session);
+ Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at);
+
+ // Chain
+ Task GetChainAsync(SessionChainId chainId);
+ Task SaveChainAsync(ISessionChain chain);
+ Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at);
+ Task GetActiveSessionIdAsync(SessionChainId chainId);
+ Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId);
+
+ // Root
+ Task GetSessionRootByUserAsync(UserKey userKey);
+ Task GetSessionRootByIdAsync(SessionRootId rootId);
+ Task SaveSessionRootAsync(ISessionRoot root);
+ Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at);
+
+ // Helpers
+ Task GetChainIdBySessionAsync(AuthSessionId sessionId);
+ Task> GetChainsByUserAsync(UserKey userKey);
+ Task> GetSessionsByChainAsync(SessionChainId chainId);
+
+ // Maintenance
+ Task DeleteExpiredSessionsAsync(DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs
similarity index 55%
rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs
rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs
index a49165d..b529fa6 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs
@@ -3,23 +3,19 @@
///
/// Provides a factory abstraction for creating tenant-scoped session store
/// instances capable of persisting sessions, chains, and session roots.
- /// Implementations typically resolve concrete types from the dependency injection container.
+ /// Implementations typically resolve concrete types from the dependency injection container.
///
- public interface ISessionStoreFactory
+ public interface ISessionStoreKernelFactory
{
///
/// Creates and returns a session store instance for the specified user ID type within the given tenant context.
///
- /// The type used to uniquely identify users.
///
/// The tenant identifier for multi-tenant environments, or null for single-tenant mode.
///
///
- /// An implementation able to perform session persistence operations.
+ /// An implementation able to perform session persistence operations.
///
- ///
- /// Thrown if no compatible session store implementation is registered.
- ///
- ISessionStoreKernel Create(string? tenantId);
+ ISessionStoreKernel Create(string? tenantId);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs
new file mode 100644
index 0000000..2a90b92
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface ITenantAwareSessionStore
+ {
+ void BindTenant(string? tenantId);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs
index e8a181b..96f4f54 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs
@@ -18,7 +18,6 @@ public interface IUAuthUserStore
/// Retrieves a user by a login credential such as username or email.
/// Returns null if no matching user exists.
///
- /// The login value used to locate the user.
/// The user instance or null if not found.
Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default);
@@ -27,16 +26,13 @@ public interface IUAuthUserStore
/// in password-based authentication. Returns null for passwordless users
/// (e.g., external login or passkey-only accounts).
///
- /// The user identifier.
/// The password hash or null.
Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken token = default);
///
/// Updates the password hash for the specified user. This method is invoked by
- /// password management services and not by .
+ /// password management services and not by .
///
- /// The user identifier.
- /// The new password hash value.
Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default);
///
@@ -44,7 +40,6 @@ public interface IUAuthUserStore
/// This value increments whenever critical security actions occur, such as:
/// password reset, MFA reset, external login removal, or account recovery.
///
- /// The user identifier.
/// The current security version.
Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default);
@@ -52,7 +47,6 @@ public interface IUAuthUserStore
/// Increments the user's security version, invalidating all existing sessions.
/// This is typically called after sensitive security events occur.
///
- /// The user identifier.
Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs
index 78c6c8e..e30f68f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs
@@ -2,11 +2,7 @@
namespace CodeBeam.UltimateAuth.Core.Abstractions;
-public interface IRefreshTokenValidator
+public interface IRefreshTokenValidator
{
- Task> ValidateAsync(
- string? tenantId,
- string refreshToken,
- DateTimeOffset now,
- CancellationToken ct = default);
+ Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default);
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
index bce952e..d31c6e2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
@@ -1,4 +1,6 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
{
public sealed record AuthContext
{
@@ -8,54 +10,10 @@ public sealed record AuthContext
public UAuthMode Mode { get; init; }
- public SessionAccessContext? Session { get; init; }
+ public SessionSecurityContext? Session { get; init; }
- public DeviceContext Device { get; init; }
+ public required DeviceContext Device { get; init; }
public DateTimeOffset At { get; init; }
-
- private AuthContext() { }
-
- public static AuthContext System(string? tenantId, AuthOperation operation, DateTimeOffset at, UAuthMode mode = UAuthMode.Hybrid)
- {
- return new AuthContext
- {
- TenantId = tenantId,
- Operation = operation,
- Mode = mode,
- At = at,
- Session = null,
- Device = null
- };
- }
-
- public static AuthContext ForAuthenticatedUser(string? tenantId, AuthOperation operation, DateTimeOffset at, DeviceContext device, UAuthMode mode = UAuthMode.Hybrid)
- {
- return new AuthContext
- {
- TenantId = tenantId,
- Operation = operation,
- Mode = mode,
- At = at,
- Device = device,
- Session = null
- };
- }
-
- public static AuthContext ForSession(string? tenantId, AuthOperation operation, SessionAccessContext session, DateTimeOffset at,
- DeviceContext device, UAuthMode mode = UAuthMode.Hybrid)
- {
- return new AuthContext
- {
- TenantId = tenantId,
- Operation = operation,
- Mode = mode,
- At = at,
- Session = session,
- Device = device
- };
- }
-
-
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs
index 76145d2..53027da 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs
@@ -1,4 +1,6 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
{
public sealed record AuthenticationContext
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs
deleted file mode 100644
index 8cbdeff..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record DeviceContext
- {
- public string DeviceId { get; init; } = default!;
-
- public bool IsKnownDevice { get; init; }
-
- public bool IsTrusted { get; init; }
-
- public string? Platform { get; init; }
-
- public string? UserAgent { get; init; }
-
- public static DeviceContext From(DeviceInfo info)
- {
- return new DeviceContext
- {
- DeviceId = info.DeviceId,
- Platform = info.Platform,
- UserAgent = info.UserAgent
- };
- }
- }
-
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs
new file mode 100644
index 0000000..3876041
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs
@@ -0,0 +1,45 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class DeviceInfo
+{
+ public required DeviceId DeviceId { get; init; }
+
+ ///
+ /// High-level platform classification (web, mobile, desktop, iot).
+ /// Used for analytics and policy decisions.
+ ///
+ public string? Platform { get; init; }
+
+ ///
+ /// Operating system information (e.g. iOS 17, Android 14, Windows 11).
+ ///
+ public string? OperatingSystem { get; init; }
+
+ ///
+ /// Browser name/version for web clients.
+ ///
+ public string? Browser { get; init; }
+
+ ///
+ /// Raw user-agent string (optional).
+ ///
+ public string? UserAgent { get; init; }
+
+ ///
+ /// Client IP address at session creation or last validation.
+ ///
+ public string? IpAddress { get; init; }
+
+ ///
+ /// Optional fingerprint hash provided by client.
+ /// Not trusted by default.
+ ///
+ public string? Fingerprint { get; init; }
+
+ ///
+ /// Arbitrary metadata for future extensions.
+ ///
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs
deleted file mode 100644
index 4a32bf3..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record SessionAccessContext
- {
- public SessionState State { get; init; }
-
- public bool IsExpired { get; init; }
-
- public bool IsRevoked { get; init; }
-
- public string? ChainId { get; init; }
-
- public string? BoundDeviceId { get; init; }
- }
-
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
index 69a742a..3ff02bd 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
@@ -8,7 +8,7 @@ public sealed record LoginRequest
public string Identifier { get; init; } = default!; // username, email etc.
public string Secret { get; init; } = default!; // password
public DateTimeOffset? At { get; init; }
- public DeviceInfo DeviceInfo { get; init; }
+ public required DeviceContext Device { get; init; }
public IReadOnlyDictionary? Metadata { get; init; }
///
@@ -18,6 +18,6 @@ public sealed record LoginRequest
public bool RequestTokens { get; init; } = true;
// Optional
- public ChainId? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs
new file mode 100644
index 0000000..4263a08
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum UAuthLoginType
+ {
+ Password, // /auth/login
+ Pkce // /auth/pkce/complete
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs
new file mode 100644
index 0000000..152afca
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class PkceAuthorizeResponse
+{
+ public string AuthorizationCode { get; init; } = default!;
+ public int ExpiresIn { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs
deleted file mode 100644
index 1a4d986..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record PkceChallengeResult
- {
- public string Challenge { get; init; } = default!;
- public string Method { get; init; } = "S256";
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
new file mode 100644
index 0000000..12a1036
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
@@ -0,0 +1,11 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ internal sealed class PkceCompleteRequest
+ {
+ public string AuthorizationCode { get; init; } = default!;
+ public string CodeVerifier { get; init; } = default!;
+ public string Identifier { get; init; } = default!;
+ public string Secret { get; init; } = default!;
+ public string ReturnUrl { get; init; } = default!;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs
deleted file mode 100644
index 153e865..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record PkceConsumeRequest
- {
- public string Challenge { get; init; } = default!;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs
deleted file mode 100644
index bd8eb88..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record PkceCreateRequest
- {
- public string ClientId { get; init; } = default!;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs
new file mode 100644
index 0000000..6c0a2f8
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs
@@ -0,0 +1,13 @@
+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 string? TenantId { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs
deleted file mode 100644
index c094b0a..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record PkceVerificationResult
- {
- public bool IsValid { get; init; }
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs
deleted file mode 100644
index 9a1d588..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record PkceVerifyRequest
- {
- public string Challenge { get; init; } = default!;
- public string Verifier { get; init; } = default!;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs
new file mode 100644
index 0000000..21b180e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed class RefreshFlowRequest
+ {
+ public AuthSessionId? SessionId { get; init; }
+ public string? RefreshToken { get; init; }
+ public required DeviceContext Device { get; init; }
+ public DateTimeOffset Now { get; init; }
+ public SessionTouchMode TouchMode { get; init; } = SessionTouchMode.IfNeeded;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs
new file mode 100644
index 0000000..7c1f26e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs
@@ -0,0 +1,40 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed class RefreshFlowResult
+ {
+ public bool Succeeded { get; init; }
+ public RefreshOutcome Outcome { get; init; }
+
+ public AuthSessionId? SessionId { get; init; }
+ public AccessToken? AccessToken { get; init; }
+ public RefreshToken? RefreshToken { get; init; }
+
+ public static RefreshFlowResult ReauthRequired()
+ {
+ return new RefreshFlowResult
+ {
+ Succeeded = false,
+ Outcome = RefreshOutcome.ReauthRequired
+ };
+ }
+
+ public static RefreshFlowResult Success(
+ RefreshOutcome outcome,
+ AuthSessionId? sessionId = null,
+ AccessToken? accessToken = null,
+ RefreshToken? refreshToken = null)
+ {
+ return new RefreshFlowResult
+ {
+ Succeeded = true,
+ Outcome = outcome,
+ SessionId = sessionId,
+ AccessToken = accessToken,
+ RefreshToken = refreshToken
+ };
+ }
+
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs
new file mode 100644
index 0000000..e4352d0
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs
@@ -0,0 +1,11 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum RefreshStrategy
+ {
+ NotSupported,
+ SessionOnly, // PureOpaque
+ TokenOnly, // PureJwt
+ TokenWithSessionCheck, // SemiHybrid
+ SessionAndToken // Hybrid
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs
new file mode 100644
index 0000000..dc5891c
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs
@@ -0,0 +1,18 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum RefreshTokenPersistence
+ {
+ ///
+ /// Refresh token store'a yazılır.
+ /// Login, first-issue gibi normal akışlar için.
+ ///
+ Persist,
+
+ ///
+ /// Refresh token store'a yazılmaz.
+ /// Rotation gibi özel akışlarda,
+ /// caller tarafından kontrol edilir.
+ ///
+ DoNotPersist
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs
new file mode 100644
index 0000000..6b9375d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record RefreshTokenValidationContext
+ {
+ public string? TenantId { get; init; }
+ public string RefreshToken { get; init; } = default!;
+ public DateTimeOffset Now { get; init; }
+
+ // For Hybrid & Advanced
+ public required DeviceContext Device { get; init; }
+ public AuthSessionId? ExpectedSessionId { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs
index f004575..704398a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs
@@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts
{
public sealed record AuthStateSnapshot
{
+ // It's not UserId type
public string? UserId { get; init; }
public string? TenantId { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
index a46570e..08890b7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
@@ -6,21 +6,21 @@ namespace CodeBeam.UltimateAuth.Core.Contracts
/// Represents the context in which a session is issued
/// (login, refresh, reauthentication).
///
- public sealed class AuthenticatedSessionContext
+ public sealed class AuthenticatedSessionContext
{
public string? TenantId { get; init; }
- public required TUserId UserId { get; init; }
- public DeviceInfo DeviceInfo { get; init; }
+ public required UserKey UserKey { get; init; }
+ public required DeviceContext Device { get; init; }
public DateTimeOffset Now { get; init; }
public ClaimsSnapshot? Claims { get; init; }
- public SessionMetadata Metadata { get; init; }
+ public required SessionMetadata Metadata { get; init; }
///
/// Optional chain identifier.
/// If null, a new chain will be created.
/// If provided, session will be issued under the existing chain.
///
- public ChainId? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
///
/// Indicates that authentication has already been completed.
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs
index cc2f0f8..0d1622d 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs
@@ -5,12 +5,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts
///
/// Represents the result of a session issuance operation.
///
- public sealed class IssuedSession
+ public sealed class IssuedSession
{
///
/// The issued domain session.
///
- public required ISession Session { get; init; }
+ public required ISession Session { get; init; }
///
/// Opaque session identifier returned to the client.
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
index 9189664..ece8d80 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
@@ -2,32 +2,32 @@
namespace CodeBeam.UltimateAuth.Core.Contracts
{
- public sealed record ResolvedRefreshSession
+ public sealed record ResolvedRefreshSession
{
public bool IsValid { get; init; }
public bool IsReuseDetected { get; init; }
- public ISession? Session { get; init; }
- public ISessionChain? Chain { get; init; }
+ public ISession? Session { get; init; }
+ public ISessionChain? Chain { get; init; }
private ResolvedRefreshSession() { }
- public static ResolvedRefreshSession Invalid()
+ public static ResolvedRefreshSession Invalid()
=> new()
{
IsValid = false
};
- public static ResolvedRefreshSession Reused()
+ public static ResolvedRefreshSession Reused()
=> new()
{
IsValid = false,
IsReuseDetected = true
};
- public static ResolvedRefreshSession Valid(
- ISession session,
- ISessionChain chain)
+ public static ResolvedRefreshSession Valid(
+ ISession session,
+ ISessionChain chain)
=> new()
{
IsValid = true,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
index 8edd168..d06a854 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
@@ -6,25 +6,22 @@ public sealed record SessionRefreshResult
{
public SessionRefreshStatus Status { get; init; }
- public PrimaryToken? PrimaryToken { get; init; }
-
- public RefreshToken? RefreshToken { get; init; }
+ public AuthSessionId? SessionId { get; init; }
public bool DidTouch { get; init; }
public bool IsSuccess => Status == SessionRefreshStatus.Success;
+ public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired;
private SessionRefreshResult() { }
public static SessionRefreshResult Success(
- PrimaryToken primaryToken,
- RefreshToken? refreshToken = null,
+ AuthSessionId sessionId,
bool didTouch = false)
=> new()
{
Status = SessionRefreshStatus.Success,
- PrimaryToken = primaryToken,
- RefreshToken = refreshToken,
+ SessionId = sessionId,
DidTouch = didTouch
};
@@ -46,7 +43,5 @@ public static SessionRefreshResult Failed()
Status = SessionRefreshStatus.Failed
};
- public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired;
-
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs
index cb43f4e..8517fcc 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs
@@ -16,25 +16,25 @@ namespace CodeBeam.UltimateAuth.Core.Contracts
/// token services, event emitters, logging pipelines, or application-level
/// consumers — can easily access all updated authentication structures.
///
- public sealed class SessionResult
+ public sealed class SessionResult
{
///
/// Gets the active session produced by the operation.
/// This is the newest session and the one that should be used when issuing tokens.
///
- public required ISession Session { get; init; }
+ public required ISession Session { get; init; }
///
/// Gets the session chain associated with the session.
/// The chain may be newly created (login) or updated (session rotation).
///
- public required ISessionChain Chain { get; init; }
+ public required ISessionChain Chain { get; init; }
///
/// Gets the user's session root.
/// This structure may be updated when new chains are added or when security
/// properties change.
///
- public required ISessionRoot Root { get; init; }
+ public required ISessionRoot Root { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
index 874e4b7..0d23664 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
@@ -2,14 +2,14 @@
namespace CodeBeam.UltimateAuth.Core.Contracts
{
- public sealed record SessionRotationContext
+ public sealed record SessionRotationContext
{
public string? TenantId { get; init; }
public AuthSessionId CurrentSessionId { get; init; }
- public TUserId UserId { get; init; }
+ public UserKey UserKey { get; init; }
public DateTimeOffset Now { get; init; }
- public DeviceInfo Device { get; init; }
- public ClaimsSnapshot Claims { get; init; }
- public SessionMetadata Metadata { get; init; }
+ public required DeviceContext Device { get; init; }
+ public ClaimsSnapshot? Claims { get; init; }
+ public required SessionMetadata Metadata { get; init; } = SessionMetadata.Empty;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs
new file mode 100644
index 0000000..a16d81c
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record SessionSecurityContext
+ {
+ public required UserKey? UserKey { get; init; }
+
+ public required AuthSessionId SessionId { get; init; }
+
+ public SessionState State { get; init; }
+
+ public SessionChainId? ChainId { get; init; }
+
+ public DeviceId? BoundDeviceId { get; init; }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs
index 78910d4..76b089a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs
@@ -6,12 +6,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts
/// Context information required by the session store when
/// creating or rotating sessions.
///
- public sealed class SessionStoreContext
+ public sealed class SessionStoreContext
{
///
/// The authenticated user identifier.
///
- public required TUserId UserId { get; init; }
+ public required UserKey UserKey { get; init; }
///
/// The tenant identifier, if multi-tenancy is enabled.
@@ -22,7 +22,7 @@ public sealed class SessionStoreContext
/// Optional chain identifier.
/// If null, a new chain should be created.
///
- public ChainId? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
///
/// Indicates whether the session is metadata-only
@@ -38,6 +38,6 @@ public sealed class SessionStoreContext
///
/// Optional device or client identifier.
///
- public DeviceInfo? DeviceInfo { get; init; }
+ public required DeviceContext Device { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs
new file mode 100644
index 0000000..f7f4226
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs
@@ -0,0 +1,15 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum SessionTouchMode
+ {
+ ///
+ /// Touch only if store policy allows (interval, throttling, etc.)
+ ///
+ IfNeeded,
+
+ ///
+ /// Always update session activity, ignoring store heuristics.
+ ///
+ Force
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
index 85a3996..bcae901 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
@@ -7,6 +7,6 @@ public sealed record SessionValidationContext
public string? TenantId { get; init; }
public AuthSessionId SessionId { get; init; }
public DateTimeOffset Now { get; init; }
- public DeviceInfo Device { get; init; }
+ public required DeviceContext Device { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
index f862000..d760b28 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
@@ -2,49 +2,65 @@
namespace CodeBeam.UltimateAuth.Core.Contracts
{
- public sealed class SessionValidationResult
+ public sealed class SessionValidationResult
{
- public string? TenantId { get; }
- public SessionState State { get; }
- public ISession? Session { get; }
- public ISessionChain? Chain { get; }
- public ISessionRoot? Root { get; }
+ public string? TenantId { get; init; }
- private SessionValidationResult(
- string? tenantId,
- SessionState state,
- ISession? session,
- ISessionChain? chain,
- ISessionRoot? root)
- {
- TenantId = tenantId;
- State = state;
- Session = session;
- Chain = chain;
- Root = root;
- }
+ public required SessionState State { get; init; }
+
+ public UserKey? UserKey { get; init; }
+
+ public AuthSessionId? SessionId { get; init; }
+
+ public SessionChainId? ChainId { get; init; }
+
+ public SessionRootId? RootId { get; init; }
+
+ public DeviceId? BoundDeviceId { get; init; }
+
+ public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty;
public bool IsValid => State == SessionState.Active;
- public static SessionValidationResult Active(
+ private SessionValidationResult() { }
+
+ public static SessionValidationResult Active(
string? tenantId,
- ISession session,
- ISessionChain chain,
- ISessionRoot root)
- => new(
- tenantId,
- SessionState.Active,
- session,
- chain,
- root);
-
- public static SessionValidationResult Invalid(
- SessionState state)
- => new(
- tenantId: null,
- state,
- session: null,
- chain: null,
- root: null);
+ UserKey? userId,
+ AuthSessionId sessionId,
+ SessionChainId chainId,
+ SessionRootId rootId,
+ ClaimsSnapshot claims,
+ DeviceId? boundDeviceId = null)
+ => new()
+ {
+ TenantId = tenantId,
+ State = SessionState.Active,
+ UserKey = userId,
+ SessionId = sessionId,
+ ChainId = chainId,
+ RootId = rootId,
+ Claims = claims,
+ BoundDeviceId = boundDeviceId
+ };
+
+ public static SessionValidationResult Invalid(
+ SessionState state,
+ UserKey? userId = null,
+ AuthSessionId? sessionId = null,
+ SessionChainId? chainId = null,
+ SessionRootId? rootId = null,
+ DeviceId? boundDeviceId = null)
+ => new()
+ {
+ TenantId = null,
+ State = state,
+ UserKey = userId,
+ SessionId = sessionId,
+ ChainId = chainId,
+ RootId = rootId,
+ Claims = ClaimsSnapshot.Empty,
+ BoundDeviceId = boundDeviceId
+ };
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
index 59f5de0..cb43d69 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
@@ -14,7 +14,7 @@ private PrimaryToken(PrimaryTokenKind kind, string value)
}
public static PrimaryToken FromSession(AuthSessionId sessionId)
- => new(PrimaryTokenKind.Session, sessionId.Value);
+ => new(PrimaryTokenKind.Session, sessionId.ToString());
public static PrimaryToken FromAccessToken(AccessToken token)
=> new(PrimaryTokenKind.AccessToken, token.Token);
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs
index d4453a8..d60ea27 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs
@@ -1,7 +1,11 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
-public sealed record RefreshTokenRotationContext
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed record RefreshTokenRotationContext
{
public string RefreshToken { get; init; } = default!;
public DateTimeOffset Now { get; init; }
+ public required DeviceContext Device { get; init; }
+ public AuthSessionId? ExpectedSessionId { get; init; } // For Hybrid
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs
new file mode 100644
index 0000000..e565b32
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record RefreshTokenRotationExecution
+ {
+ public RefreshTokenRotationResult Result { get; init; } = default!;
+
+ // INTERNAL – flow/orchestrator only
+ public UserKey? UserKey { get; init; }
+ public AuthSessionId? SessionId { get; init; }
+ public SessionChainId? ChainId { get; init; }
+ public string? TenantId { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
index b0efbe2..b1c50d0 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
@@ -1,10 +1,14 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record RefreshTokenRotationResult
{
public bool IsSuccess { get; init; }
public bool ReauthRequired { get; init; }
+ public bool IsReuseDetected { get; init; } // internal use
+ public AuthSessionId? SessionId { get; init; }
public AccessToken? AccessToken { get; init; }
public RefreshToken? RefreshToken { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs
deleted file mode 100644
index 93f5a44..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts
-{
- public sealed record RefreshTokenValidationContext
- {
- public string TenantId { get; init; } = default!;
- public AuthSessionId SessionId { get; init; }
- public string ProvidedRefreshToken { get; init; } = default!;
- public DateTimeOffset Now { get; init; }
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs
index 3c0699f..e942350 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs
@@ -2,63 +2,62 @@
namespace CodeBeam.UltimateAuth.Core.Contracts
{
- public sealed record RefreshTokenValidationResult
+ public sealed record RefreshTokenValidationResult
{
public bool IsValid { get; init; }
-
public bool IsReuseDetected { get; init; }
- public string? TenantId { get; init; }
- public TUserId? UserId { get; init; }
+ public string? TokenHash { get; init; }
+ public string? TenantId { get; init; }
+ public UserKey? UserKey { get; init; }
public AuthSessionId? SessionId { get; init; }
-
- public ChainId? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
private RefreshTokenValidationResult() { }
- // ----------------------------
- // FACTORIES
- // ----------------------------
-
- public static RefreshTokenValidationResult Invalid()
+ public static RefreshTokenValidationResult Invalid()
=> new()
{
IsValid = false,
IsReuseDetected = false
};
- public static RefreshTokenValidationResult ReuseDetected(
- string? tenantId = null,
- AuthSessionId? sessionId = null,
- ChainId? chainId = null,
- TUserId? userId = default)
+ public static RefreshTokenValidationResult ReuseDetected(
+ string? tenantId = null,
+ AuthSessionId? sessionId = null,
+ string? tokenHash = null,
+ SessionChainId? chainId = null,
+ UserKey? userKey = default)
=> new()
{
IsValid = false,
IsReuseDetected = true,
TenantId = tenantId,
SessionId = sessionId,
+ TokenHash = tokenHash,
ChainId = chainId,
- UserId = userId
+ UserKey = userKey,
};
- public static RefreshTokenValidationResult Valid(
- string? tenantId,
- TUserId userId,
- AuthSessionId sessionId,
- ChainId? chainId = null)
+ public static RefreshTokenValidationResult Valid(
+ string? tenantId,
+ UserKey userKey,
+ AuthSessionId sessionId,
+ string? tokenHash,
+ SessionChainId? chainId = null)
=> new()
{
IsValid = true,
IsReuseDetected = false,
TenantId = tenantId,
- UserId = userId,
+ UserKey = userKey,
SessionId = sessionId,
- ChainId = chainId
+ ChainId = chainId,
+ TokenHash = tokenHash
};
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
index fde5c8a..f070cd1 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
@@ -1,11 +1,14 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
{
public sealed record TokenIssuanceContext
{
- public string UserId { get; init; } = default!;
+ public required UserKey UserKey { get; init; }
public string? TenantId { get; init; }
public IReadOnlyDictionary Claims { get; set; } = new Dictionary();
- public string? SessionId { get; init; }
+ public AuthSessionId? SessionId { get; init; }
+ public SessionChainId? ChainId { get; init; }
public DateTimeOffset IssuedAt { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
index c4cb22f..d7428ae 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
@@ -2,10 +2,10 @@
namespace CodeBeam.UltimateAuth.Core.Contracts
{
- public sealed record TokenIssueContext
+ public sealed record TokenIssueContext
{
public string? TenantId { get; init; }
- public ISession Session { get; init; } = default!;
+ public ISession Session { get; init; } = default!;
public DateTimeOffset At { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs
new file mode 100644
index 0000000..e345dfb
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs
@@ -0,0 +1,27 @@
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public sealed class DeviceContext
+ {
+ public DeviceId? DeviceId { get; init; }
+
+ public bool HasDeviceId => DeviceId is not null;
+
+ private DeviceContext(DeviceId? deviceId)
+ {
+ DeviceId = deviceId;
+ }
+
+ public static DeviceContext Anonymous()
+ => new(null);
+
+ public static DeviceContext FromDeviceId(DeviceId deviceId)
+ => new(deviceId);
+
+ // DeviceInfo is a transport object.
+ // AuthFlowContextFactory changes it to a useable DeviceContext
+ // DeviceContext doesn't have fields like IsTrusted etc. It's authority layer's responsibility.
+ // IP, Geo, Fingerprint, Platform, UA will be added here.
+
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs
new file mode 100644
index 0000000..7da55a1
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs
@@ -0,0 +1,69 @@
+using System.Security;
+
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public readonly record struct DeviceId
+{
+ public const int MinLength = 16;
+ public const int MaxLength = 256;
+
+ private readonly string _value;
+
+ public string Value => _value;
+
+ private DeviceId(string value)
+ {
+ _value = value;
+ }
+
+ public static DeviceId Create(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new SecurityException("DeviceId is required.");
+
+ raw = raw.Trim();
+
+ if (raw == "undefined" || raw == "null")
+ throw new SecurityException("Invalid DeviceId.");
+
+ if (raw.Length < MinLength)
+ throw new SecurityException("DeviceId entropy is too low.");
+
+ if (raw.Length > MaxLength)
+ throw new SecurityException("DeviceId is too long.");
+
+ return new DeviceId(raw);
+ }
+
+ public static bool TryCreate(string? raw, out DeviceId deviceId)
+ {
+ deviceId = default;
+
+ if (string.IsNullOrWhiteSpace(raw))
+ return false;
+
+ raw = raw.Trim();
+
+ if (raw == "undefined" || raw == "null")
+ return false;
+
+ if (raw.Length < MinLength || raw.Length > MaxLength)
+ return false;
+
+ deviceId = new DeviceId(raw);
+ return true;
+ }
+
+ public static DeviceId CreateFromBytes(ReadOnlySpan bytes)
+ {
+ if (bytes.Length < 32)
+ throw new SecurityException("DeviceId entropy is too low.");
+
+ var raw = Convert.ToBase64String(bytes);
+ return new DeviceId(raw);
+ }
+
+ public override string ToString() => _value;
+
+ public static explicit operator string(DeviceId id) => id._value;
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs
new file mode 100644
index 0000000..0c05934
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs
@@ -0,0 +1,11 @@
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public sealed class HubCredentials
+ {
+ public string AuthorizationCode { get; init; } = default!;
+ public string CodeVerifier { get; init; } = default!;
+ public UAuthClientProfile ClientProfile { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs
new file mode 100644
index 0000000..98704a1
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs
@@ -0,0 +1,33 @@
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public sealed class HubFlowArtifact : AuthArtifact
+{
+ public HubSessionId HubSessionId { get; }
+ public HubFlowType FlowType { get; }
+
+ public UAuthClientProfile ClientProfile { get; }
+ public string? TenantId { get; }
+ public string? ReturnUrl { get; }
+
+ public HubFlowPayload Payload { get; }
+
+ public HubFlowArtifact(
+ HubSessionId hubSessionId,
+ HubFlowType flowType,
+ UAuthClientProfile clientProfile,
+ string? tenantId,
+ string? returnUrl,
+ HubFlowPayload payload,
+ DateTimeOffset expiresAt)
+ : base(AuthArtifactType.HubFlow, expiresAt, maxAttempts: 1)
+ {
+ HubSessionId = hubSessionId;
+ FlowType = flowType;
+ ClientProfile = clientProfile;
+ TenantId = tenantId;
+ ReturnUrl = returnUrl;
+ Payload = payload;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs
new file mode 100644
index 0000000..c983373
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs
@@ -0,0 +1,22 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public sealed class HubFlowPayload
+{
+ private readonly Dictionary _items = new();
+
+ public IReadOnlyDictionary Items => _items;
+
+ public void Set(string key, T value) => _items[key] = value;
+
+ public bool TryGet(string key, out T? value)
+ {
+ if (_items.TryGetValue(key, out var raw) && raw is T t)
+ {
+ value = t;
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs
new file mode 100644
index 0000000..344f1a6
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public sealed class HubFlowState
+ {
+ public HubSessionId HubSessionId { get; init; }
+ public HubFlowType FlowType { get; init; }
+ public UAuthClientProfile ClientProfile { get; init; }
+ public string? ReturnUrl { get; init; }
+
+ public bool IsActive { get; init; }
+ public bool IsExpired { get; init; }
+ public bool IsCompleted { get; init; }
+ public bool Exists { get; init; }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs
new file mode 100644
index 0000000..3d3980c
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs
@@ -0,0 +1,13 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public enum HubFlowType
+{
+ None = 0,
+
+ Login = 1,
+ Mfa = 2,
+ Reauthentication = 3,
+ Consent = 4,
+
+ Custom = 1000
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs
new file mode 100644
index 0000000..7f34bcf
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs
@@ -0,0 +1,23 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+// TODO: Bind id with IP and UA
+public readonly record struct HubSessionId(string Value)
+{
+ public static HubSessionId New() => new(Guid.NewGuid().ToString("N"));
+
+ public override string ToString() => Value;
+
+ public static bool TryParse(string? value, out HubSessionId sessionId)
+ {
+ sessionId = default;
+
+ if (string.IsNullOrWhiteSpace(value))
+ return false;
+
+ if (!Guid.TryParseExact(value, "N", out _))
+ return false;
+
+ sessionId = new HubSessionId(value);
+ return true;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs
new file mode 100644
index 0000000..a0c69a4
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs
@@ -0,0 +1,34 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public abstract class AuthArtifact
+{
+ protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt, int maxAttempts)
+ {
+ Type = type;
+ ExpiresAt = expiresAt;
+ MaxAttempts = maxAttempts;
+ }
+
+ public AuthArtifactType Type { get; }
+
+ public DateTimeOffset ExpiresAt { get; internal set; }
+
+ public int MaxAttempts { get; }
+
+ public int AttemptCount { get; private set; }
+ public bool IsCompleted { get; private set; }
+
+ public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
+
+ public bool CanAttempt() => AttemptCount < MaxAttempts;
+
+ public void RegisterAttempt()
+ {
+ AttemptCount++;
+ }
+
+ public void MarkCompleted()
+ {
+ IsCompleted = true;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs
new file mode 100644
index 0000000..70512e6
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs
@@ -0,0 +1,13 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public enum AuthArtifactType
+{
+ PkceAuthorizationCode,
+ HubFlow,
+ HubLogin,
+ MfaChallenge,
+ PasswordReset,
+ MagicLink,
+ OAuthState,
+ Custom = 1000
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs
new file mode 100644
index 0000000..02751dc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs
@@ -0,0 +1,18 @@
+namespace CodeBeam.UltimateAuth.Core.Domain;
+
+public sealed class HubLoginArtifact : AuthArtifact
+{
+ public string AuthorizationCode { get; }
+ public string CodeVerifier { get; }
+
+ public HubLoginArtifact(
+ string authorizationCode,
+ string codeVerifier,
+ DateTimeOffset expiresAt,
+ int maxAttempts = 3)
+ : base(AuthArtifactType.HubLogin, expiresAt, maxAttempts)
+ {
+ AuthorizationCode = authorizationCode;
+ CodeVerifier = codeVerifier;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
index e6261ca..8663562 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
@@ -1,69 +1,35 @@
namespace CodeBeam.UltimateAuth.Core.Domain
{
- ///
- /// Represents a strongly typed identifier for an authentication session.
- /// Wraps a value and provides type safety across the UltimateAuth session management system.
- ///
- public readonly struct AuthSessionId : IEquatable
+ // AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid.
+ public readonly record struct AuthSessionId
{
- // TODO: Change this private
- public AuthSessionId(string value)
- {
- if (string.IsNullOrWhiteSpace(value))
- throw new ArgumentException("SessionId cannot be empty.", nameof(value));
+ public string Value { get; }
+ private AuthSessionId(string value)
+ {
Value = value;
}
- public static bool TryCreate(string raw, out AuthSessionId sessionId)
+ public static bool TryCreate(string raw, out AuthSessionId id)
{
if (string.IsNullOrWhiteSpace(raw))
{
- sessionId = default;
+ id = default;
+ return false;
+ }
+
+ if (raw.Length < 32)
+ {
+ id = default;
return false;
}
- sessionId = new AuthSessionId(raw);
+ id = new AuthSessionId(raw);
return true;
}
- ///
- /// Gets the underlying GUID value of the session identifier.
- ///
- public string Value { get; }
-
- public static AuthSessionId From(string value) => new(value);
-
- ///
- /// Determines whether the specified is equal to the current instance.
- ///
- /// The session identifier to compare with.
- /// true if the identifiers match; otherwise, false.
- public bool Equals(AuthSessionId other) => Value.Equals(other.Value);
-
- ///
- /// Determines whether the specified object is equal to the current session identifier.
- ///
- /// The object to compare with.
- /// true if the object is an with the same value.
- public override bool Equals(object? obj) => obj is AuthSessionId other && Equals(other);
-
- ///
- /// Returns a hash code based on the underlying GUID value.
- ///
- public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
-
- ///
- /// Returns the string representation of the underlying GUID value.
- ///
- /// The GUID as a string.
public override string ToString() => Value;
- ///
- /// Converts the to its underlying .
- ///
- /// The session identifier.
- /// The underlying GUID value.
public static implicit operator string(AuthSessionId id) => id.Value;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs
deleted file mode 100644
index 486c80c..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Domain
-{
- ///
- /// Represents a strongly typed identifier for a session chain.
- /// A session chain groups multiple rotated sessions belonging to the same
- /// device or application family, providing type safety across the UltimateAuth session system.
- ///
- public readonly struct ChainId : IEquatable
- {
- ///
- /// Initializes a new with the specified GUID value.
- ///
- /// The underlying GUID representing the chain identifier.
- public ChainId(Guid value)
- {
- Value = value;
- }
-
- ///
- /// Gets the underlying GUID value of the chain identifier.
- ///
- public Guid Value { get; }
-
- ///
- /// Generates a new chain identifier using a newly created GUID.
- ///
- /// A new instance.
- public static ChainId New() => new ChainId(Guid.NewGuid());
-
- public static ChainId From(Guid value) => new(value);
-
- ///
- /// Determines whether the specified is equal to the current instance.
- ///
- /// The chain identifier to compare with.
- /// true if both identifiers represent the same chain.
- public bool Equals(ChainId other) => Value.Equals(other.Value);
-
- ///
- /// Determines whether the specified object is equal to the current chain identifier.
- ///
- /// The object to compare with.
- /// true if the object is a with the same value.
- public override bool Equals(object? obj) => obj is ChainId other && Equals(other);
-
- public static bool operator ==(ChainId left, ChainId right) => left.Equals(right);
-
- public static bool operator !=(ChainId left, ChainId right) => !left.Equals(right);
-
- ///
- /// Returns a hash code based on the underlying GUID value.
- ///
- public override int GetHashCode() => Value.GetHashCode();
-
- ///
- /// Returns the string representation of the underlying GUID value.
- ///
- /// The GUID as a string.
- public override string ToString() => Value.ToString();
-
- ///
- /// Converts the to its underlying value.
- ///
- /// The chain identifier.
- /// The underlying GUID value.
- public static implicit operator Guid(ChainId id) => id.Value;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs
deleted file mode 100644
index 969b474..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Domain
-{
- ///
- /// Represents metadata describing the device or client environment initiating
- /// an authentication session. Used for security analytics, session management,
- /// fraud detection, and device-specific login policies.
- ///
- public sealed class DeviceInfo
- {
- // TODO: Implement DeviceId and makes it first-class citizen in security policies.
- ///
- /// Gets the unique identifier for the device.
- /// No session should be created without a device id.
- ///
- public string DeviceId { get; init; } = default!;
-
- ///
- /// Gets the high-level platform identifier, such as web, mobile,
- /// tablet or iot.
- /// Used for platform-based session limits and analytics.
- ///
- public string? Platform { get; init; }
-
- ///
- /// Gets the operating system of the client device, such as iOS 17,
- /// Android 14, Windows 11, or macOS Sonoma.
- ///
- public string? OperatingSystem { get; init; }
-
- ///
- /// Gets the browser name and version when the client is web-based,
- /// such as Edge, Chrome, Safari, or Firefox.
- /// May be null for native applications.
- ///
- public string? Browser { get; init; }
-
- ///
- /// Gets the IP address of the client device.
- /// Used for IP-binding, geolocation checks, and anomaly detection.
- ///
- public string? IpAddress { get; init; }
-
- ///
- /// Gets the raw user-agent string for web clients.
- /// Used when deeper parsing of browser or device details is needed.
- ///
- public string? UserAgent { get; init; }
-
- ///
- /// Gets a device fingerprint or unique client identifier if provided by the
- /// application. Useful for advanced session policies or fraud analysis.
- ///
- public string? Fingerprint { get; init; }
-
- ///
- /// Indicates whether the device is considered trusted by the user or system.
- /// Applications may update this value when implementing trusted-device flows.
- ///
- public bool? IsTrusted { get; init; }
-
- ///
- /// Gets optional custom metadata supplied by the application.
- /// Allows additional device attributes not covered by standard fields.
- ///
- public Dictionary? Custom { get; init; }
-
- public static DeviceInfo Unknown { get; } = new()
- {
- DeviceId = "unknown",
- Platform = null,
- Browser = null,
- IpAddress = null,
- UserAgent = null,
- IsTrusted = null
- };
-
- // TODO: Empty may not be good approach, make strict security here
- public static DeviceInfo Empty { get; } = new()
- {
- DeviceId = "",
- Platform = null,
- Browser = null,
- IpAddress = null,
- UserAgent = null,
- IsTrusted = null
- };
-
- ///
- /// Determines whether the current device information matches the specified device information based on device
- /// identifiers.
- ///
- /// The device information to compare with the current instance. Cannot be null.
- /// true if the device identifiers are equal; otherwise, false.
- public bool Matches(DeviceInfo other)
- {
- if (other is null)
- return false;
-
- if (DeviceId != other.DeviceId)
- return false;
-
- // TODO: UA / IP drift policy
- return true;
- }
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
index 42da335..ac2f975 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
@@ -1,11 +1,13 @@
-namespace CodeBeam.UltimateAuth.Core.Domain
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Domain
{
///
/// Represents a single authentication session belonging to a user.
/// Sessions are immutable, security-critical units used for validation,
/// sliding expiration, revocation, and device analytics.
///
- public interface ISession
+ public interface ISession
{
///
/// Gets the unique identifier of the session.
@@ -17,9 +19,9 @@ public interface ISession
///
/// Gets the identifier of the user who owns this session.
///
- TUserId UserId { get; }
+ UserKey UserKey { get; }
- ChainId ChainId { get; }
+ SessionChainId ChainId { get; }
///
/// Gets the timestamp when this session was originally created.
@@ -58,7 +60,7 @@ public interface ISession
/// Gets metadata describing the client device that created the session.
/// Includes platform, OS, IP address, fingerprint, and more.
///
- DeviceInfo Device { get; }
+ DeviceContext Device { get; }
ClaimsSnapshot Claims { get; }
@@ -75,8 +77,10 @@ public interface ISession
/// The evaluated of this session.
SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout);
- ISession Touch(DateTimeOffset now);
- ISession Revoke(DateTimeOffset at);
+ ISession Touch(DateTimeOffset now);
+ ISession Revoke(DateTimeOffset at);
+
+ ISession WithChain(SessionChainId chainId);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
index c7faf4a..7659f47 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
@@ -5,12 +5,14 @@
/// A chain groups all rotated sessions belonging to a single logical login
/// (e.g., a browser instance, mobile app installation, or device fingerprint).
///
- public interface ISessionChain
+ public interface ISessionChain
{
///
/// Gets the unique identifier of the session chain.
///
- ChainId ChainId { get; }
+ SessionChainId ChainId { get; }
+
+ SessionRootId RootId { get; }
string? TenantId { get; }
@@ -18,7 +20,7 @@ public interface ISessionChain
/// Gets the identifier of the user who owns this chain.
/// Each chain represents one device/login family for this user.
///
- TUserId UserId { get; }
+ UserKey UserKey { get; }
///
/// Gets the number of refresh token rotations performed within this chain.
@@ -56,9 +58,9 @@ public interface ISessionChain
///
DateTimeOffset? RevokedAt { get; }
- ISessionChain AttachSession(AuthSessionId sessionId);
- ISessionChain RotateSession(AuthSessionId sessionId);
- ISessionChain Revoke(DateTimeOffset at);
+ ISessionChain AttachSession(AuthSessionId sessionId);
+ ISessionChain RotateSession(AuthSessionId sessionId);
+ ISessionChain Revoke(DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
index c51ba8e..b839292 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
@@ -5,8 +5,10 @@
/// A session root is tenant-scoped and acts as the authoritative security boundary,
/// controlling global revocation, security versioning, and device/login families.
///
- public interface ISessionRoot
+ public interface ISessionRoot
{
+ SessionRootId RootId { get; }
+
///
/// Gets the tenant identifier associated with this session root.
/// Used to isolate authentication domains in multi-tenant systems.
@@ -17,7 +19,7 @@ public interface ISessionRoot
/// Gets the identifier of the user who owns this session root.
/// Each user has one root per tenant.
///
- TUserId UserId { get; }
+ UserKey UserKey { get; }
///
/// Gets a value indicating whether the entire session root is revoked.
@@ -43,7 +45,7 @@ public interface ISessionRoot
/// Each chain represents a device or login-family (browser instance, mobile app, etc.).
/// The root is immutable; modifications must go through SessionService or SessionStore.
///
- IReadOnlyList> Chains { get; }
+ IReadOnlyList Chains { get; }
///
/// Gets the timestamp when this root structure was last updated.
@@ -51,8 +53,8 @@ public interface ISessionRoot
///
DateTimeOffset LastUpdatedAt { get; }
- ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at);
+ ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at);
- ISessionRoot Revoke(DateTimeOffset at);
+ ISessionRoot Revoke(DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs
new file mode 100644
index 0000000..5c253e6
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs
@@ -0,0 +1,33 @@
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public readonly record struct SessionChainId(Guid Value)
+ {
+ public static SessionChainId New() => new(Guid.NewGuid());
+
+ ///
+ /// Indicates that the chain must be assigned by the store.
+ ///
+ public static readonly SessionChainId Unassigned = new(Guid.Empty);
+
+ public bool IsUnassigned => Value == Guid.Empty;
+
+ public static SessionChainId From(Guid value)
+ => value == Guid.Empty
+ ? throw new ArgumentException("ChainId cannot be empty.", nameof(value))
+ : new SessionChainId(value);
+
+ public static bool TryCreate(string raw, out SessionChainId id)
+ {
+ if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty)
+ {
+ id = new SessionChainId(guid);
+ return true;
+ }
+
+ id = default;
+ return false;
+ }
+
+ public override string ToString() => Value.ToString("N");
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs
new file mode 100644
index 0000000..68d595a
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs
@@ -0,0 +1,26 @@
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public readonly record struct SessionRootId(Guid Value)
+ {
+ public static SessionRootId New() => new(Guid.NewGuid());
+
+ public static SessionRootId From(Guid value)
+ => value == Guid.Empty
+ ? throw new ArgumentException("SessionRootId cannot be empty.", nameof(value))
+ : new SessionRootId(value);
+
+ public static bool TryCreate(string raw, out SessionRootId id)
+ {
+ if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty)
+ {
+ id = new SessionRootId(guid);
+ return true;
+ }
+
+ id = default;
+ return false;
+ }
+
+ public override string ToString() => Value.ToString("N");
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
index d7f5c5a..78786ef 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
@@ -1,39 +1,41 @@
-namespace CodeBeam.UltimateAuth.Core.Domain
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Domain
{
- public sealed class UAuthSession : ISession
+ public sealed class UAuthSession : ISession
{
public AuthSessionId SessionId { get; }
public string? TenantId { get; }
- public TUserId UserId { get; }
- public ChainId ChainId { get; }
+ public UserKey UserKey { get; }
+ public SessionChainId ChainId { get; }
public DateTimeOffset CreatedAt { get; }
public DateTimeOffset ExpiresAt { get; }
public DateTimeOffset? LastSeenAt { get; }
public bool IsRevoked { get; }
public DateTimeOffset? RevokedAt { get; }
public long SecurityVersionAtCreation { get; }
- public DeviceInfo Device { get; }
+ public DeviceContext Device { get; }
public ClaimsSnapshot Claims { get; }
public SessionMetadata Metadata { get; }
private UAuthSession(
AuthSessionId sessionId,
string? tenantId,
- TUserId userId,
- ChainId chainId,
+ UserKey userKey,
+ SessionChainId chainId,
DateTimeOffset createdAt,
DateTimeOffset expiresAt,
DateTimeOffset? lastSeenAt,
bool isRevoked,
DateTimeOffset? revokedAt,
long securityVersionAtCreation,
- DeviceInfo device,
+ DeviceContext device,
ClaimsSnapshot claims,
SessionMetadata metadata)
{
SessionId = sessionId;
TenantId = tenantId;
- UserId = userId;
+ UserKey = userKey;
ChainId = chainId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
@@ -46,21 +48,21 @@ private UAuthSession(
Metadata = metadata;
}
- public static UAuthSession Create(
+ public static UAuthSession Create(
AuthSessionId sessionId,
string? tenantId,
- TUserId userId,
- ChainId chainId,
+ UserKey userKey,
+ SessionChainId chainId,
DateTimeOffset now,
DateTimeOffset expiresAt,
- DeviceInfo device,
+ DeviceContext device,
ClaimsSnapshot claims,
SessionMetadata metadata)
{
return new(
sessionId,
tenantId,
- userId,
+ userKey,
chainId,
createdAt: now,
expiresAt: expiresAt,
@@ -74,15 +76,15 @@ public static UAuthSession Create(
);
}
- public UAuthSession WithSecurityVersion(long version)
+ public UAuthSession WithSecurityVersion(long version)
{
if (SecurityVersionAtCreation == version)
return this;
- return new UAuthSession(
+ return new UAuthSession(
SessionId,
TenantId,
- UserId,
+ UserKey,
ChainId,
CreatedAt,
ExpiresAt,
@@ -96,12 +98,12 @@ public UAuthSession WithSecurityVersion(long version)
);
}
- public ISession Touch(DateTimeOffset at)
+ public ISession Touch(DateTimeOffset at)
{
- return new UAuthSession(
+ return new UAuthSession(
SessionId,
TenantId,
- UserId,
+ UserKey,
ChainId,
CreatedAt,
ExpiresAt,
@@ -115,14 +117,14 @@ public ISession Touch(DateTimeOffset at)
);
}
- public ISession Revoke(DateTimeOffset at)
+ public ISession Revoke(DateTimeOffset at)
{
if (IsRevoked) return this;
- return new UAuthSession(
+ return new UAuthSession(
SessionId,
TenantId,
- UserId,
+ UserKey,
ChainId,
CreatedAt,
ExpiresAt,
@@ -136,25 +138,25 @@ public ISession Revoke(DateTimeOffset at)
);
}
- internal static UAuthSession FromProjection(
- AuthSessionId sessionId,
- string? tenantId,
- TUserId userId,
- ChainId chainId,
- DateTimeOffset createdAt,
- DateTimeOffset expiresAt,
- DateTimeOffset? lastSeenAt,
- bool isRevoked,
- DateTimeOffset? revokedAt,
- long securityVersionAtCreation,
- DeviceInfo device,
- ClaimsSnapshot claims,
- SessionMetadata metadata)
+ internal static UAuthSession FromProjection(
+ AuthSessionId sessionId,
+ string? tenantId,
+ UserKey userKey,
+ SessionChainId chainId,
+ DateTimeOffset createdAt,
+ DateTimeOffset expiresAt,
+ DateTimeOffset? lastSeenAt,
+ bool isRevoked,
+ DateTimeOffset? revokedAt,
+ long securityVersionAtCreation,
+ DeviceContext device,
+ ClaimsSnapshot claims,
+ SessionMetadata metadata)
{
- return new UAuthSession(
+ return new UAuthSession(
sessionId,
tenantId,
- userId,
+ userKey,
chainId,
createdAt,
expiresAt,
@@ -181,6 +183,29 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout)
return SessionState.Active;
}
+
+ public ISession WithChain(SessionChainId chainId)
+ {
+ if (!ChainId.IsUnassigned)
+ throw new InvalidOperationException("Chain already assigned.");
+
+ return new UAuthSession(
+ sessionId: SessionId,
+ tenantId: TenantId,
+ userKey: UserKey,
+ chainId: chainId,
+ createdAt: CreatedAt,
+ expiresAt: ExpiresAt,
+ lastSeenAt: LastSeenAt,
+ isRevoked: IsRevoked,
+ revokedAt: RevokedAt,
+ securityVersionAtCreation: SecurityVersionAtCreation,
+ device: Device,
+ claims: Claims,
+ metadata: Metadata
+ );
+ }
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
index 91ecbce..9403f95 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
@@ -1,10 +1,11 @@
namespace CodeBeam.UltimateAuth.Core.Domain
{
- public sealed class UAuthSessionChain : ISessionChain
+ public sealed class UAuthSessionChain : ISessionChain
{
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
+ public SessionRootId RootId { get; }
public string? TenantId { get; }
- public TUserId UserId { get; }
+ public UserKey UserKey { get; }
public int RotationCount { get; }
public long SecurityVersionAtCreation { get; }
public ClaimsSnapshot ClaimsSnapshot { get; }
@@ -13,9 +14,10 @@ public sealed class UAuthSessionChain : ISessionChain
public DateTimeOffset? RevokedAt { get; }
private UAuthSessionChain(
- ChainId chainId,
+ SessionChainId chainId,
+ SessionRootId rootId,
string? tenantId,
- TUserId userId,
+ UserKey userKey,
int rotationCount,
long securityVersionAtCreation,
ClaimsSnapshot claimsSnapshot,
@@ -24,8 +26,9 @@ private UAuthSessionChain(
DateTimeOffset? revokedAt)
{
ChainId = chainId;
+ RootId = rootId;
TenantId = tenantId;
- UserId = userId;
+ UserKey = userKey;
RotationCount = rotationCount;
SecurityVersionAtCreation = securityVersionAtCreation;
ClaimsSnapshot = claimsSnapshot;
@@ -34,17 +37,19 @@ private UAuthSessionChain(
RevokedAt = revokedAt;
}
- public static UAuthSessionChain Create(
- ChainId chainId,
+ public static UAuthSessionChain Create(
+ SessionChainId chainId,
+ SessionRootId rootId,
string? tenantId,
- TUserId userId,
+ UserKey userKey,
long securityVersion,
ClaimsSnapshot claimsSnapshot)
{
- return new UAuthSessionChain(
+ return new UAuthSessionChain(
chainId,
+ rootId,
tenantId,
- userId,
+ userKey,
rotationCount: 0,
securityVersionAtCreation: securityVersion,
claimsSnapshot: claimsSnapshot,
@@ -54,15 +59,16 @@ public static UAuthSessionChain Create(
);
}
- public ISessionChain AttachSession(AuthSessionId sessionId)
+ public ISessionChain AttachSession(AuthSessionId sessionId)
{
if (IsRevoked)
return this;
- return new UAuthSessionChain(
+ return new UAuthSessionChain(
ChainId,
+ RootId,
TenantId,
- UserId,
+ UserKey,
RotationCount, // Unchanged on first attach
SecurityVersionAtCreation,
ClaimsSnapshot,
@@ -72,15 +78,16 @@ public ISessionChain AttachSession(AuthSessionId sessionId)
);
}
- public ISessionChain RotateSession(AuthSessionId sessionId)
+ public ISessionChain RotateSession(AuthSessionId sessionId)
{
if (IsRevoked)
return this;
- return new UAuthSessionChain(
+ return new UAuthSessionChain(
ChainId,
+ RootId,
TenantId,
- UserId,
+ UserKey,
RotationCount + 1,
SecurityVersionAtCreation,
ClaimsSnapshot,
@@ -90,15 +97,16 @@ public ISessionChain RotateSession(AuthSessionId sessionId)
);
}
- public ISessionChain Revoke(DateTimeOffset at)
+ public ISessionChain Revoke(DateTimeOffset at)
{
if (IsRevoked)
return this;
- return new UAuthSessionChain(
+ return new UAuthSessionChain(
ChainId,
+ RootId,
TenantId,
- UserId,
+ UserKey,
RotationCount,
SecurityVersionAtCreation,
ClaimsSnapshot,
@@ -108,21 +116,23 @@ public ISessionChain Revoke(DateTimeOffset at)
);
}
- internal static UAuthSessionChain FromProjection(
- ChainId chainId,
- string? tenantId,
- TUserId userId,
- int rotationCount,
- long securityVersionAtCreation,
- ClaimsSnapshot claimsSnapshot,
- AuthSessionId? activeSessionId,
- bool isRevoked,
- DateTimeOffset? revokedAt)
+ internal static UAuthSessionChain FromProjection(
+ SessionChainId chainId,
+ SessionRootId rootId,
+ string? tenantId,
+ UserKey userKey,
+ int rotationCount,
+ long securityVersionAtCreation,
+ ClaimsSnapshot claimsSnapshot,
+ AuthSessionId? activeSessionId,
+ bool isRevoked,
+ DateTimeOffset? revokedAt)
{
- return new UAuthSessionChain(
+ return new UAuthSessionChain(
chainId,
+ rootId,
tenantId,
- userId,
+ userKey,
rotationCount,
securityVersionAtCreation,
claimsSnapshot,
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
index ae41b27..0153210 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
@@ -1,26 +1,29 @@
namespace CodeBeam.UltimateAuth.Core.Domain
{
- public sealed class UAuthSessionRoot : ISessionRoot
+ public sealed class UAuthSessionRoot : ISessionRoot
{
- public TUserId UserId { get; }
+ public SessionRootId RootId { get; }
+ public UserKey UserKey { get; }
public string? TenantId { get; }
public bool IsRevoked { get; }
public DateTimeOffset? RevokedAt { get; }
public long SecurityVersion { get; }
- public IReadOnlyList> Chains { get; }
+ public IReadOnlyList Chains { get; }
public DateTimeOffset LastUpdatedAt { get; }
private UAuthSessionRoot(
+ SessionRootId rootId,
string? tenantId,
- TUserId userId,
+ UserKey userKey,
bool isRevoked,
DateTimeOffset? revokedAt,
long securityVersion,
- IReadOnlyList> chains,
+ IReadOnlyList chains,
DateTimeOffset lastUpdatedAt)
{
+ RootId = rootId;
TenantId = tenantId;
- UserId = userId;
+ UserKey = userKey;
IsRevoked = isRevoked;
RevokedAt = revokedAt;
SecurityVersion = securityVersion;
@@ -28,30 +31,32 @@ private UAuthSessionRoot(
LastUpdatedAt = lastUpdatedAt;
}
- public static ISessionRoot Create(
+ public static ISessionRoot Create(
string? tenantId,
- TUserId userId,
+ UserKey userKey,
DateTimeOffset issuedAt)
{
- return new UAuthSessionRoot(
+ return new UAuthSessionRoot(
+ SessionRootId.New(),
tenantId,
- userId,
+ userKey,
isRevoked: false,
revokedAt: null,
securityVersion: 0,
- chains: Array.Empty>(),
+ chains: Array.Empty(),
lastUpdatedAt: issuedAt
);
}
- public ISessionRoot Revoke(DateTimeOffset at)
+ public ISessionRoot Revoke(DateTimeOffset at)
{
if (IsRevoked)
return this;
- return new UAuthSessionRoot(
+ return new UAuthSessionRoot(
+ RootId,
TenantId,
- UserId,
+ UserKey,
isRevoked: true,
revokedAt: at,
securityVersion: SecurityVersion,
@@ -60,14 +65,15 @@ public ISessionRoot Revoke(DateTimeOffset at)
);
}
- public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at)
+ public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at)
{
if (IsRevoked)
return this;
- return new UAuthSessionRoot(
+ return new UAuthSessionRoot(
+ RootId,
TenantId,
- UserId,
+ UserKey,
IsRevoked,
RevokedAt,
SecurityVersion,
@@ -76,18 +82,20 @@ public ISessionRoot AttachChain(ISessionChain chain, DateTimeO
);
}
- internal static UAuthSessionRoot FromProjection(
- string? tenantId,
- TUserId userId,
- bool isRevoked,
- DateTimeOffset? revokedAt,
- long securityVersion,
- IReadOnlyList> chains,
- DateTimeOffset lastUpdatedAt)
+ internal static UAuthSessionRoot FromProjection(
+ SessionRootId rootId,
+ string? tenantId,
+ UserKey userKey,
+ bool isRevoked,
+ DateTimeOffset? revokedAt,
+ long securityVersion,
+ IReadOnlyList chains,
+ DateTimeOffset lastUpdatedAt)
{
- return new UAuthSessionRoot(
+ return new UAuthSessionRoot(
+ rootId,
tenantId,
- userId,
+ userKey,
isRevoked,
revokedAt,
securityVersion,
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs
index 0019720..f1592b6 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs
@@ -6,16 +6,16 @@ namespace CodeBeam.UltimateAuth.Core.Domain
/// Represents a persisted refresh token bound to a session.
/// Stored as a hashed value for security reasons.
///
- public sealed record StoredRefreshToken
+ public sealed record StoredRefreshToken
{
public string TokenHash { get; init; } = default!;
public string? TenantId { get; init; }
- public TUserId UserId { get; init; } = default!;
+ public required UserKey UserKey { get; init; }
public AuthSessionId SessionId { get; init; } = default!;
- public ChainId? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs
deleted file mode 100644
index 962885f..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Domain
-{
- ///
- /// Strongly typed identifier for a user.
- /// Default user id implementation for UltimateAuth.
- ///
- public readonly record struct UserId(string Value)
- {
- public override string ToString() => Value;
-
- public static UserId New() => new(Guid.NewGuid().ToString("N"));
-
- public static implicit operator string(UserId id) => id.Value;
- public static implicit operator UserId(string value) => new(value);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs
new file mode 100644
index 0000000..f696d21
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs
@@ -0,0 +1,39 @@
+namespace CodeBeam.UltimateAuth.Core.Domain
+{
+ public readonly record struct UserKey
+ {
+ public string Value { get; }
+
+ private UserKey(string value)
+ {
+ Value = value;
+ }
+
+ ///
+ /// Creates a UserKey from a GUID (default and recommended).
+ ///
+ public static UserKey FromGuid(Guid value) => new(value.ToString("N"));
+
+ ///
+ /// Creates a UserKey from a canonical string.
+ /// Caller is responsible for stability and uniqueness.
+ ///
+ public static UserKey FromString(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException("UserKey cannot be empty.", nameof(value));
+
+ return new UserKey(value);
+ }
+
+ ///
+ /// Generates a new GUID-based UserKey.
+ ///
+ public static UserKey New() => FromGuid(Guid.NewGuid());
+
+ public override string ToString() => Value;
+
+ public static implicit operator string(UserKey key) => key.Value;
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs
index 2238697..a62b507 100644
--- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs
@@ -4,10 +4,10 @@ namespace CodeBeam.UltimateAuth.Core.Errors
{
public abstract class UAuthChainException : UAuthDomainException
{
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
protected UAuthChainException(
- ChainId chainId,
+ SessionChainId chainId,
string message)
: base(message)
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs
index 758b7ef..91d0baf 100644
--- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs
@@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Errors
{
public sealed class UAuthSessionChainNotFoundException : UAuthChainException
{
- public UAuthSessionChainNotFoundException(ChainId chainId)
+ public UAuthSessionChainNotFoundException(SessionChainId chainId)
: base(chainId, $"Session chain '{chainId}' was not found.")
{
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs
index c755b93..bd880af 100644
--- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs
@@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Errors
{
public sealed class UAuthSessionChainRevokedException : UAuthChainException
{
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
- public UAuthSessionChainRevokedException(ChainId chainId)
+ public UAuthSessionChainRevokedException(SessionChainId chainId)
: base(chainId, $"Session chain '{chainId}' has been revoked.")
{
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs
index bb8660f..07e425e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
namespace CodeBeam.UltimateAuth.Core.Errors
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
index a989b86..544b2eb 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
@@ -27,7 +27,7 @@ public sealed class SessionCreatedContext : IAuthEventContext
///
/// Gets the identifier of the session chain to which this session belongs.
///
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
///
/// Gets the timestamp on which the session was created.
@@ -37,7 +37,7 @@ public sealed class SessionCreatedContext : IAuthEventContext
///
/// Initializes a new instance of the class.
///
- public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTimeOffset createdAt)
+ public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt)
{
UserId = userId;
SessionId = sessionId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
index 0c0ce5f..3472048 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
@@ -33,7 +33,7 @@ public sealed class SessionRefreshedContext : IAuthEventContext
///
/// Gets the identifier of the session chain to which both sessions belong.
///
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
///
/// Gets the timestamp at which the refresh occurred.
@@ -47,7 +47,7 @@ public SessionRefreshedContext(
TUserId userId,
AuthSessionId oldSessionId,
AuthSessionId newSessionId,
- ChainId chainId,
+ SessionChainId chainId,
DateTimeOffset refreshedAt)
{
UserId = userId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
index ee7e98a..fc5167a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
@@ -31,7 +31,7 @@ public sealed class SessionRevokedContext : IAuthEventContext
///
/// Gets the identifier of the session chain containing the revoked session.
///
- public ChainId ChainId { get; }
+ public SessionChainId ChainId { get; }
///
/// Gets the timestamp at which the session revocation occurred.
@@ -44,7 +44,7 @@ public sealed class SessionRevokedContext : IAuthEventContext
public SessionRevokedContext(
TUserId userId,
AuthSessionId sessionId,
- ChainId chainId,
+ SessionChainId chainId,
DateTimeOffset revokedAt)
{
UserId = userId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs
index 8d3cb46..3d604bc 100644
--- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Infrastructure;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Core.Runtime;
@@ -86,7 +87,6 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio
services.AddSingleton();
services.TryAddSingleton();
-
return services;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs
index 3f9d93f..cf96f95 100644
--- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs
@@ -1,102 +1,102 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
+//using CodeBeam.UltimateAuth.Core.Abstractions;
+//using Microsoft.Extensions.DependencyInjection;
+//using Microsoft.Extensions.DependencyInjection.Extensions;
-namespace CodeBeam.UltimateAuth.Core.Extensions
-{
- ///
- /// Provides extension methods for registering a concrete
- /// implementation into the application's dependency injection container.
- ///
- /// UltimateAuth requires exactly one session store implementation that determines
- /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB).
- /// This extension performs automatic generic type resolution and registers the correct
- /// ISessionStore<TUserId> for the application's user ID type.
- ///
- /// The method enforces that the provided store implements ISessionStore'TUserId';.
- /// If the type cannot be determined, an exception is thrown to prevent misconfiguration.
- ///
- public static class UltimateAuthSessionStoreExtensions
- {
- ///
- /// Registers a custom session store implementation for UltimateAuth.
- /// The supplied must implement ISessionStore'TUserId';
- /// exactly once with a single TUserId generic argument.
- ///
- /// After registration, the internal session store factory resolves the correct
- /// ISessionStore instance at runtime for the active tenant and TUserId type.
- ///
- /// The concrete session store implementation.
- public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services)
- where TStore : class
- {
- var storeInterface = typeof(TStore)
- .GetInterfaces()
- .FirstOrDefault(i =>
- i.IsGenericType &&
- i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>));
+//namespace CodeBeam.UltimateAuth.Core.Extensions
+//{
+// ///
+// /// Provides extension methods for registering a concrete
+// /// implementation into the application's dependency injection container.
+// ///
+// /// UltimateAuth requires exactly one session store implementation that determines
+// /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB).
+// /// This extension performs automatic generic type resolution and registers the correct
+// /// ISessionStore<TUserId> for the application's user ID type.
+// ///
+// /// The method enforces that the provided store implements ISessionStore'TUserId';.
+// /// If the type cannot be determined, an exception is thrown to prevent misconfiguration.
+// ///
+// public static class UltimateAuthSessionStoreExtensions
+// {
+// ///
+// /// Registers a custom session store implementation for UltimateAuth.
+// /// The supplied must implement ISessionStore'TUserId';
+// /// exactly once with a single TUserId generic argument.
+// ///
+// /// After registration, the internal session store factory resolves the correct
+// /// ISessionStore instance at runtime for the active tenant and TUserId type.
+// ///
+// /// The concrete session store implementation.
+// public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services)
+// where TStore : class
+// {
+// var storeInterface = typeof(TStore)
+// .GetInterfaces()
+// .FirstOrDefault(i =>
+// i.IsGenericType &&
+// i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>));
- if (storeInterface is null)
- {
- throw new InvalidOperationException(
- $"{typeof(TStore).Name} must implement ISessionStoreKernel.");
- }
+// if (storeInterface is null)
+// {
+// throw new InvalidOperationException(
+// $"{typeof(TStore).Name} must implement ISessionStoreKernel.");
+// }
- var userIdType = storeInterface.GetGenericArguments()[0];
- var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType);
+// var userIdType = storeInterface.GetGenericArguments()[0];
+// var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType);
- services.TryAddScoped(typedInterface, typeof(TStore));
+// services.TryAddScoped(typedInterface, typeof(TStore));
- services.AddSingleton(sp =>
- new GenericSessionStoreFactory(sp, userIdType));
+// services.AddSingleton(sp =>
+// new GenericSessionStoreFactory(sp, userIdType));
- return services;
- }
- }
+// return services;
+// }
+// }
- ///
- /// Default session store factory used by UltimateAuth to dynamically create
- /// the correct ISessionStore<TUserId> implementation at runtime.
- ///
- /// This factory ensures type safety by validating the requested TUserId against
- /// the registered session store’s user ID type. Attempting to resolve a mismatched
- /// TUserId results in a descriptive exception to prevent silent misconfiguration.
- ///
- /// Tenant ID is passed through so that multi-tenant implementations can perform
- /// tenant-aware routing, filtering, or partition-based selection.
- ///
- internal sealed class GenericSessionStoreFactory : ISessionStoreFactory
- {
- private readonly IServiceProvider _sp;
- private readonly Type _userIdType;
+// ///
+// /// Default session store factory used by UltimateAuth to dynamically create
+// /// the correct ISessionStore<TUserId> implementation at runtime.
+// ///
+// /// This factory ensures type safety by validating the requested TUserId against
+// /// the registered session store’s user ID type. Attempting to resolve a mismatched
+// /// TUserId results in a descriptive exception to prevent silent misconfiguration.
+// ///
+// /// Tenant ID is passed through so that multi-tenant implementations can perform
+// /// tenant-aware routing, filtering, or partition-based selection.
+// ///
+// internal sealed class GenericSessionStoreFactory : ISessionStoreFactory
+// {
+// private readonly IServiceProvider _sp;
+// private readonly Type _userIdType;
- ///
- /// Initializes a new instance of the class.
- ///
- public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType)
- {
- _sp = sp;
- _userIdType = userIdType;
- }
+// ///
+// /// Initializes a new instance of the class.
+// ///
+// public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType)
+// {
+// _sp = sp;
+// _userIdType = userIdType;
+// }
- ///
- /// Creates and returns the registered ISessionStore<TUserId> implementation
- /// for the specified tenant and user ID type.
- /// Throws if the requested TUserId does not match the registered store's type.
- ///
- public ISessionStoreKernel Create(string? tenantId)
- {
- if (typeof(TUserId) != _userIdType)
- {
- throw new InvalidOperationException(
- $"SessionStore registered for TUserId='{_userIdType.Name}', " +
- $"but requested with TUserId='{typeof(TUserId).Name}'.");
- }
+// ///
+// /// Creates and returns the registered ISessionStore<TUserId> implementation
+// /// for the specified tenant and user ID type.
+// /// Throws if the requested TUserId does not match the registered store's type.
+// ///
+// public ISessionStoreKernel Create(string? tenantId)
+// {
+// if (typeof(TUserId) != _userIdType)
+// {
+// throw new InvalidOperationException(
+// $"SessionStore registered for TUserId='{_userIdType.Name}', " +
+// $"but requested with TUserId='{typeof(TUserId).Name}'.");
+// }
- var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType);
- var store = _sp.GetRequiredService(typed);
+// var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType);
+// var store = _sp.GetRequiredService(typed);
- return (ISessionStoreKernel)store;
- }
- }
-}
+// return (ISessionStoreKernel)store;
+// }
+// }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs
new file mode 100644
index 0000000..d8472f8
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs
@@ -0,0 +1,32 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class DeviceMismatchPolicy : IAuthorityPolicy
+ {
+ public bool AppliesTo(AuthContext context)
+ => context.Device is not null;
+
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ var device = context.Device;
+
+ //if (device.IsKnownDevice)
+ // return AuthorizationResult.Allow();
+
+ return context.Operation switch
+ {
+ AuthOperation.Access =>
+ AuthorizationResult.Deny("Access from unknown device."),
+
+ AuthOperation.Refresh =>
+ AuthorizationResult.Challenge("Device verification required."),
+
+ AuthOperation.Login => AuthorizationResult.Allow(), // login establishes device
+
+ _ => AuthorizationResult.Allow()
+ };
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs
new file mode 100644
index 0000000..a2fc709
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs
@@ -0,0 +1,20 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class DevicePresenceInvariant : IAuthorityInvariant
+ {
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ if (context.Operation is AuthOperation.Login or AuthOperation.Refresh)
+ {
+ if (context.Device is null)
+ return AuthorizationResult.Deny("Device information is required.");
+ }
+
+ return AuthorizationResult.Allow();
+ }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs
deleted file mode 100644
index 8bc3abc..0000000
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-
-namespace CodeBeam.UltimateAuth.Core.Infrastructure
-{
- public sealed class DeviceTrustPolicy : IAuthorityPolicy
- {
- public bool AppliesTo(AuthContext context) => context.Device is not null;
-
- public AuthorizationResult Decide(AuthContext context)
- {
- var device = context.Device;
-
- if (device.IsTrusted)
- return AuthorizationResult.Allow();
-
- return context.Operation switch
- {
- AuthOperation.Login =>
- AuthorizationResult.Challenge("Login from untrusted device requires additional verification."),
-
- AuthOperation.Refresh =>
- AuthorizationResult.Challenge("Token refresh from untrusted device requires additional verification."),
-
- AuthOperation.Access =>
- AuthorizationResult.Deny("Access from untrusted device is not allowed."),
-
- _ => AuthorizationResult.Allow()
- };
- }
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs
index 75d026c..6f2b08f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs
@@ -3,49 +3,52 @@
namespace CodeBeam.UltimateAuth.Core.Infrastructure;
-public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator
+public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator
{
- private readonly IRefreshTokenStore _store;
+ private readonly IRefreshTokenStore _store;
private readonly ITokenHasher _hasher;
- public DefaultRefreshTokenValidator(
- IRefreshTokenStore store,
- ITokenHasher hasher)
+ public DefaultRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher)
{
_store = store;
_hasher = hasher;
}
- public async Task> ValidateAsync(
- string? tenantId,
- string refreshToken,
- DateTimeOffset now,
- CancellationToken ct = default)
+ public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default)
{
- var hash = _hasher.Hash(refreshToken);
-
- var stored = await _store.FindByHashAsync(tenantId, hash, ct);
+ var hash = _hasher.Hash(context.RefreshToken);
+ var stored = await _store.FindByHashAsync(context.TenantId, hash, ct);
if (stored is null)
- return RefreshTokenValidationResult.Invalid();
+ return RefreshTokenValidationResult.Invalid();
if (stored.IsRevoked)
- return RefreshTokenValidationResult.ReuseDetected(
+ return RefreshTokenValidationResult.ReuseDetected(
tenantId: stored.TenantId,
sessionId: stored.SessionId,
chainId: stored.ChainId,
- userId: stored.UserId);
+ userKey: stored.UserKey);
- if (stored.IsExpired(now))
+ if (stored.IsExpired(context.Now))
{
- await _store.RevokeAsync(tenantId, hash, now, ct);
- return RefreshTokenValidationResult.Invalid();
+ await _store.RevokeAsync(context.TenantId, hash, context.Now, null, ct);
+ return RefreshTokenValidationResult.Invalid();
}
- return RefreshTokenValidationResult.Valid(
+ if (context.ExpectedSessionId.HasValue && stored.SessionId != context.ExpectedSessionId)
+ {
+ return RefreshTokenValidationResult.Invalid();
+ }
+
+ // TODO: Add device binding
+ // if (context.Device != null && !stored.MatchesDevice(context.Device))
+ // return Invalid();
+
+ return RefreshTokenValidationResult.Valid(
tenantId: stored.TenantId,
- stored.UserId,
+ stored.UserKey,
stored.SessionId,
+ hash,
stored.ChainId);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs
index 01085ff..26d0d1e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
using System.Globalization;
using System.Text;
@@ -65,6 +66,7 @@ public TUserId FromString(string value)
Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture),
Type t when t == typeof(Guid) => (TUserId)(object)Guid.Parse(value),
Type t when t == typeof(string) => (TUserId)(object)value,
+ Type t when t == typeof(UserKey) => (TUserId)(object)UserKey.FromString(value),
_ => JsonSerializer.Deserialize(value)
?? throw new UAuthInternalException("Cannot deserialize TUserId")
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs
index 7a14b54..8872df7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs
@@ -3,8 +3,8 @@
namespace CodeBeam.UltimateAuth.Core.Infrastructure
{
- public sealed class UserIdFactory : IUserIdFactory
+ public sealed class UserIdFactory : IUserIdFactory
{
- public UserId Create() => new UserId(Guid.NewGuid().ToString("N"));
+ public UserKey Create() => UserKey.New();
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs
index 3f74c77..f8c75a2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs
@@ -7,6 +7,7 @@ public enum UAuthClientProfile
BlazorServer,
Maui,
WebServer,
- Api
+ Api,
+ UAuthHub = 1000
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs
index 593fed2..b66d85c 100644
--- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs
@@ -16,9 +16,12 @@ public sealed class UAuthPkceOptions
///
public int AuthorizationCodeLifetimeSeconds { get; set; } = 120;
+ public int MaxVerificationAttempts { get; set; } = 5;
+
internal UAuthPkceOptions Clone() => new()
{
- AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds
+ AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds,
+ MaxVerificationAttempts = MaxVerificationAttempts,
};
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs
new file mode 100644
index 0000000..495e3cc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Runtime
+{
+ ///
+ /// Marker interface indicating that the current application
+ /// hosts an UltimateAuth Hub.
+ ///
+ public interface IUAuthHubMarker
+ {
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs
index c195989..950b8fa 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs
@@ -1,10 +1,13 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
using Microsoft.AspNetCore.Http;
namespace CodeBeam.UltimateAuth.Server.Abstractions
{
public interface ICredentialResponseWriter
{
- void Write(HttpContext context, CredentialKind kind, string value);
+ void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId);
+ void Write(HttpContext context, CredentialKind kind, AccessToken accessToken);
+ void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
index adb05d4..06b0998 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs
@@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Http;
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
namespace CodeBeam.UltimateAuth.Server.Abstractions
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs
index 962be54..75edff5 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs
@@ -10,10 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions
/// Extends the core ISessionIssuer contract with HttpContext-bound
/// operations required for cookie-based session binding.
///
- public interface IHttpSessionIssuer : ISessionIssuer
+ public interface IHttpSessionIssuer : ISessionIssuer
{
- Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
+ Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default);
- Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken cancellationToken = default);
+ Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
index ff3b281..885b37e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
@@ -10,6 +10,6 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions
public interface ITokenIssuer
{
Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default);
- Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default);
+ Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs
index 417e805..6c03004 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
namespace CodeBeam.UltimateAuth.Server.Abstractions
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs
index 856ec26..6313300 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs
@@ -1,14 +1,47 @@
-namespace CodeBeam.UltimateAuth.Server.Auth
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Auth
{
internal sealed class DefaultAuthFlowContextAccessor : IAuthFlowContextAccessor
{
- private static readonly AsyncLocal _current = new();
+ private static readonly object Key = new();
+
+ private readonly IHttpContextAccessor _http;
+
+ public DefaultAuthFlowContextAccessor(IHttpContextAccessor http)
+ {
+ _http = http;
+ }
+
+ public AuthFlowContext Current
+ {
+ get
+ {
+ var ctx = _http.HttpContext
+ ?? throw new InvalidOperationException("No HttpContext.");
- public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request.");
+ if (!ctx.Items.TryGetValue(Key, out var value) || value is not AuthFlowContext flow)
+ throw new InvalidOperationException("AuthFlowContext is not available for this request.");
+
+ return flow;
+ }
+ }
internal void Set(AuthFlowContext context)
{
- _current.Value = context;
+ var ctx = _http.HttpContext
+ ?? throw new InvalidOperationException("No HttpContext.");
+
+ ctx.Items[Key] = context;
}
+
+ //private static readonly AsyncLocal _current = new();
+
+ //public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request.");
+
+ //internal void Set(AuthFlowContext context)
+ //{
+ // _current.Value = context;
+ //}
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs
new file mode 100644
index 0000000..bb1fff7
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Auth
+{
+ public sealed record AuthExecutionContext
+ {
+ public required UAuthClientProfile? EffectiveClientProfile { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
index ad1f31c..253b0c6 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
@@ -12,18 +12,16 @@ public sealed class AuthFlowContext
public AuthFlowType FlowType { get; }
public UAuthClientProfile ClientProfile { get; }
public UAuthMode EffectiveMode { get; }
-
+ public DeviceContext Device { get; }
public string? TenantId { get; }
+ public SessionSecurityContext? Session { get; }
public bool IsAuthenticated { get; }
- public UserId? UserId { get; }
- public AuthSessionId? SessionId { get; }
-
+ public UserKey? UserKey { get; }
public UAuthServerOptions OriginalOptions { get; }
public EffectiveUAuthServerOptions EffectiveOptions { get; }
-
public EffectiveAuthResponse Response { get; }
public PrimaryTokenKind PrimaryTokenKind { get; }
@@ -36,10 +34,11 @@ internal AuthFlowContext(
AuthFlowType flowType,
UAuthClientProfile clientProfile,
UAuthMode effectiveMode,
+ DeviceContext device,
string? tenantId,
bool isAuthenticated,
- UserId? userId,
- AuthSessionId? sessionId,
+ UserKey? userKey,
+ SessionSecurityContext? session,
UAuthServerOptions originalOptions,
EffectiveUAuthServerOptions effectiveOptions,
EffectiveAuthResponse response,
@@ -48,11 +47,12 @@ internal AuthFlowContext(
FlowType = flowType;
ClientProfile = clientProfile;
EffectiveMode = effectiveMode;
+ Device = device;
TenantId = tenantId;
+ Session = session;
IsAuthenticated = isAuthenticated;
- UserId = userId;
- SessionId = sessionId;
+ UserKey = userKey;
OriginalOptions = originalOptions;
EffectiveOptions = effectiveOptions;
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
index d064978..b2c02f8 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
@@ -1,5 +1,8 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Http;
@@ -7,7 +10,7 @@ namespace CodeBeam.UltimateAuth.Server.Auth
{
public interface IAuthFlowContextFactory
{
- AuthFlowContext Create(HttpContext httpContext, AuthFlowType flowType);
+ ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default);
}
internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory
@@ -16,23 +19,32 @@ internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory
private readonly IPrimaryTokenResolver _primaryTokenResolver;
private readonly IEffectiveServerOptionsProvider _serverOptionsProvider;
private readonly IAuthResponseResolver _authResponseResolver;
+ private readonly IDeviceResolver _deviceResolver;
+ private readonly IDeviceContextFactory _deviceContextFactory;
+ private readonly ISessionQueryService _sessionQueryService;
public DefaultAuthFlowContextFactory(
IClientProfileReader clientProfileReader,
IPrimaryTokenResolver primaryTokenResolver,
IEffectiveServerOptionsProvider serverOptionsProvider,
- IAuthResponseResolver authResponseResolver)
+ IAuthResponseResolver authResponseResolver,
+ IDeviceResolver deviceResolver,
+ IDeviceContextFactory deviceContextFactory,
+ ISessionQueryService sessionQueryService)
{
_clientProfileReader = clientProfileReader;
_primaryTokenResolver = primaryTokenResolver;
_serverOptionsProvider = serverOptionsProvider;
_authResponseResolver = authResponseResolver;
+ _deviceResolver = deviceResolver;
+ _deviceContextFactory = deviceContextFactory;
+ _sessionQueryService = sessionQueryService;
}
- public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType)
+ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowType flowType, CancellationToken ct = default)
{
var tenant = ctx.GetTenantContext();
- var session = ctx.GetSessionContext();
+ var sessionCtx = ctx.GetSessionContext();
var user = ctx.GetUserContext();
var clientProfile = _clientProfileReader.Read(ctx);
@@ -44,6 +56,26 @@ public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType)
var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions);
+ var deviceInfo = _deviceResolver.Resolve(ctx);
+ var deviceContext = _deviceContextFactory.Create(deviceInfo);
+
+ SessionSecurityContext? sessionSecurityContext = null;
+
+ if (!sessionCtx.IsAnonymous)
+ {
+ var validation = await _sessionQueryService.ValidateSessionAsync(
+ new SessionValidationContext
+ {
+ TenantId = sessionCtx.TenantId,
+ SessionId = sessionCtx.SessionId!.Value,
+ Device = deviceContext,
+ Now = DateTimeOffset.UtcNow
+ },
+ ct);
+
+ sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation);
+ }
+
// TODO: Implement invariant checker
//_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions);
@@ -51,10 +83,11 @@ public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType)
flowType,
clientProfile,
effectiveMode,
+ deviceContext,
tenant?.TenantId,
user?.IsAuthenticated ?? false,
user?.UserId,
- session?.SessionId,
+ sessionSecurityContext,
originalOptions,
effectiveOptions,
response,
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs
index a3586c2..f1e4bce 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs
@@ -17,9 +17,8 @@ public AuthFlowEndpointFilter(IAuthFlow authFlow)
if (metadata != null)
{
- _authFlow.Begin(metadata.FlowType);
+ await _authFlow.BeginAsync(metadata.FlowType);
}
-
return await next(context);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs
index 891abf0..ca13cf1 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs
@@ -19,11 +19,11 @@ public DefaultAuthFlow(
_accessor = (DefaultAuthFlowContextAccessor)accessor;
}
- public AuthFlowContext Begin(AuthFlowType flowType)
+ public async ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default)
{
var ctx = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext.");
- var flowContext = _factory.Create(ctx, flowType);
+ var flowContext = await _factory.CreateAsync(ctx, flowType);
_accessor.Set(flowContext);
return flowContext;
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs
index e4fbc7c..41c6e1e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs
@@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Server.Auth
{
public interface IAuthFlow
{
- AuthFlowContext Begin(AuthFlowType flowType);
+ ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs
index 11c039b..bccddfa 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs
@@ -13,11 +13,8 @@ namespace CodeBeam.UltimateAuth.Server.Authentication;
internal sealed class UAuthAuthenticationHandler : AuthenticationHandler
{
private readonly ITransportCredentialResolver _transportCredentialResolver;
- private readonly IFlowCredentialResolver _credentialResolver;
- private readonly ISessionQueryService _sessionQuery;
- private readonly IAuthFlowContextFactory _flowFactory;
- private readonly IAuthResponseResolver _responseResolver;
-
+ private readonly ISessionQueryService _sessionQuery;
+ private readonly IDeviceContextFactory _deviceContextFactory;
private readonly IClock _clock;
public UAuthAuthenticationHandler(
@@ -26,18 +23,14 @@ public UAuthAuthenticationHandler(
ILoggerFactory logger,
System.Text.Encodings.Web.UrlEncoder encoder,
ISystemClock clock,
- IFlowCredentialResolver credentialResolver,
- ISessionQueryService sessionQuery,
- IAuthFlowContextFactory flowFactory,
- IAuthResponseResolver responseResolver,
+ ISessionQueryService sessionQuery,
+ IDeviceContextFactory deviceContextFactory,
IClock uauthClock)
: base(options, logger, encoder, clock)
{
_transportCredentialResolver = transportCredentialResolver;
- _credentialResolver = credentialResolver;
_sessionQuery = sessionQuery;
- _flowFactory = flowFactory;
- _responseResolver = responseResolver;
+ _deviceContextFactory = deviceContextFactory;
_clock = uauthClock;
}
protected override async Task HandleAuthenticateAsync()
@@ -55,11 +48,11 @@ protected override async Task HandleAuthenticateAsync()
{
TenantId = credential.TenantId,
SessionId = sessionId,
- Device = credential.Device,
+ Device = _deviceContextFactory.Create(credential.Device),
Now = _clock.UtcNow
});
- if (!result.IsValid)
+ if (!result.IsValid || result.UserKey is null)
return AuthenticateResult.NoResult();
var principal = CreatePrincipal(result);
@@ -68,12 +61,12 @@ protected override async Task HandleAuthenticateAsync()
return AuthenticateResult.Success(ticket);
}
- private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result)
+ private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result)
{
var claims = new List
{
- new Claim(ClaimTypes.NameIdentifier, result.Session.UserId.Value),
- new Claim("uauth:session_id", result.Session.SessionId.Value)
+ new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value),
+ new Claim("uauth:session_id", result.SessionId.ToString())
};
if (!string.IsNullOrEmpty(result.TenantId))
@@ -82,7 +75,7 @@ private static ClaimsPrincipal CreatePrincipal(SessionValidationResult r
}
// Session claims (snapshot)
- foreach (var (key, value) in result.Session.Claims.AsDictionary())
+ foreach (var (key, value) in result.Claims.AsDictionary())
{
claims.Add(new Claim(key, value));
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs
index 8424440..780d68f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs
@@ -15,7 +15,7 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder)
if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>))))
throw new InvalidOperationException("No credential store registered.");
- if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore<>))))
+ if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore))))
throw new InvalidOperationException("No session store registered.");
return services;
diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs
index bd5f6da..088b17f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs
@@ -1,4 +1,6 @@
-using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Http;
@@ -6,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Cookies;
internal sealed class DefaultUAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder
{
- public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime)
+ public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind)
{
if (response.Cookie is null)
throw new InvalidOperationException("Cookie policy requested but Cookie options are null.");
@@ -18,11 +20,11 @@ public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext c
HttpOnly = src.HttpOnly,
Secure = src.SecurePolicy == CookieSecurePolicy.Always,
Path = src.Path,
- Domain = src.Domain
+ Domain = src.Domain,
+ SameSite = ResolveSameSite(src, context)
};
- options.SameSite = ResolveSameSite(src, context);
- ApplyLifetime(options, src, logicalLifetime);
+ ApplyLifetime(options, src, context, kind);
return options;
}
@@ -41,30 +43,51 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC
};
}
- private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, TimeSpan? logicalLifetime)
+ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, CredentialKind kind)
{
var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero;
- TimeSpan? baseLifetime = null;
+ var baseLifetime = ResolveBaseLifetime(context, kind, src);
- // 1️⃣ Hard MaxAge override (base)
- if (src.MaxAge is not null)
- {
- baseLifetime = src.MaxAge;
- }
- // 2️⃣ Absolute lifetime override (base)
- else if (src.Lifetime.AbsoluteLifetimeOverride is not null)
+ if (baseLifetime is not null)
{
- baseLifetime = src.Lifetime.AbsoluteLifetimeOverride;
+ target.MaxAge = baseLifetime.Value + buffer;
}
- // 3️⃣ Logical lifetime (effective)
- else if (logicalLifetime is not null)
+ }
+
+ private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, CredentialKind kind, UAuthCookieOptions src)
+ {
+ if (src.MaxAge is not null)
+ return src.MaxAge;
+
+ if (src.Lifetime.AbsoluteLifetimeOverride is not null)
+ return src.Lifetime.AbsoluteLifetimeOverride;
+
+ return kind switch
{
- baseLifetime = logicalLifetime;
- }
+ CredentialKind.Session => ResolveSessionLifetime(context),
+ CredentialKind.RefreshToken => context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime,
+ CredentialKind.AccessToken => context.EffectiveOptions.Options.Tokens.AccessTokenLifetime,
+ _ => null
+ };
+ }
- if (baseLifetime is not null)
+ private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context)
+ {
+ var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout;
+ var refresh = context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime;
+
+ return context.EffectiveMode switch
{
- target.MaxAge = baseLifetime.Value + buffer;
- }
+ UAuthMode.PureOpaque => sessionIdle,
+ UAuthMode.Hybrid => Max(sessionIdle, refresh),
+ _ => sessionIdle
+ };
+ }
+
+ private static TimeSpan? Max(TimeSpan? a, TimeSpan? b)
+ {
+ if (a is null) return b;
+ if (b is null) return a;
+ return a > b ? a : b;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs
index f80a2bc..f90df8e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs
@@ -2,10 +2,11 @@
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
namespace CodeBeam.UltimateAuth.Server.Cookies;
public interface IUAuthCookiePolicyBuilder
{
- CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime);
+ CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
index d9324a4..f26dc4a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs
@@ -4,8 +4,18 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints
{
public interface IPkceEndpointHandler
{
- Task CreateAsync(HttpContext ctx);
- Task VerifyAsync(HttpContext ctx);
- Task ConsumeAsync(HttpContext ctx);
+ ///
+ /// Starts the PKCE authorization flow.
+ /// Creates and stores a PKCE authorization artifact
+ /// and returns an authorization code or redirect instruction.
+ ///
+ Task AuthorizeAsync(HttpContext ctx);
+
+ ///
+ /// Completes the PKCE flow.
+ /// Atomically validates and consumes the authorization code,
+ /// then issues a session or token.
+ ///
+ Task CompleteAsync(HttpContext ctx);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
index d44f90a..9131458 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
@@ -1,43 +1,34 @@
-using CodeBeam.UltimateAuth.Core;
-using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
-using CodeBeam.UltimateAuth.Server.Contracts;
-using CodeBeam.UltimateAuth.Server.Cookies;
-using CodeBeam.UltimateAuth.Server.Endpoints;
using CodeBeam.UltimateAuth.Server.Extensions;
using CodeBeam.UltimateAuth.Server.Infrastructure;
-using CodeBeam.UltimateAuth.Server.MultiTenancy;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Services;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Options;
namespace CodeBeam.UltimateAuth.Server.Endpoints;
public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler
{
- private readonly IAuthFlowContextAccessor _authContext;
- private readonly IUAuthFlowService _flow;
- private readonly IDeviceResolver _deviceResolver;
+ private readonly IAuthFlowContextAccessor _authFlow;
+ private readonly IUAuthFlowService _flowService;
private readonly IClock _clock;
private readonly ICredentialResponseWriter _credentialResponseWriter;
private readonly AuthRedirectResolver _redirectResolver;
public DefaultLoginEndpointHandler(
- IAuthFlowContextAccessor authContext,
- IUAuthFlowService flow,
- IDeviceResolver deviceResolver,
+ IAuthFlowContextAccessor authFlow,
+ IUAuthFlowService flowService,
IClock clock,
ICredentialResponseWriter credentialResponseWriter,
AuthRedirectResolver redirectResolver)
{
- _authContext = authContext;
- _flow = flow;
- _deviceResolver = deviceResolver;
+ _authFlow = authFlow;
+ _flowService = flowService;
_clock = clock;
_credentialResponseWriter = credentialResponseWriter;
_redirectResolver = redirectResolver;
@@ -45,11 +36,11 @@ public DefaultLoginEndpointHandler(
public async Task LoginAsync(HttpContext ctx)
{
- var auth = _authContext.Current;
+ var authFlow = _authFlow.Current;
var shouldIssueTokens =
- auth.Response.AccessTokenDelivery.Mode != TokenResponseMode.None ||
- auth.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None;
+ authFlow.Response.AccessTokenDelivery.Mode != TokenResponseMode.None ||
+ authFlow.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None;
if (!ctx.Request.HasFormContentType)
return Results.BadRequest("Invalid content type.");
@@ -60,7 +51,7 @@ public async Task LoginAsync(HttpContext ctx)
var secret = form["Secret"].ToString();
if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret))
- return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, auth.OriginalOptions);
+ return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions);
var tenantCtx = ctx.GetTenantContext();
@@ -70,41 +61,36 @@ public async Task LoginAsync(HttpContext ctx)
Secret = secret,
TenantId = tenantCtx.TenantId,
At = _clock.UtcNow,
- DeviceInfo = _deviceResolver.Resolve(ctx),
+ Device = authFlow.Device,
RequestTokens = shouldIssueTokens
};
- var result = await _flow.LoginAsync(auth, flowRequest, ctx.RequestAborted);
+ var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted);
if (!result.IsSuccess)
- return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, auth.OriginalOptions);
+ return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, authFlow.OriginalOptions);
- if (result.SessionId is not null)
+ if (result.SessionId is AuthSessionId sessionId)
{
- _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value);
+ _credentialResponseWriter.Write(ctx, CredentialKind.Session, sessionId);
}
if (result.AccessToken is not null)
{
- _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token);
+ _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken);
}
if (result.RefreshToken is not null)
{
- _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token);
+ _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken);
}
- if (auth.Response.Login.RedirectEnabled)
+ if (authFlow.Response.Login.RedirectEnabled)
{
- var redirectUrl =
- _redirectResolver.ResolveRedirect(
- ctx,
- auth.Response.Login.SuccessPath);
-
+ var redirectUrl = _redirectResolver.ResolveRedirect(ctx, authFlow.Response.Login.SuccessPath);
return Results.Redirect(redirectUrl);
}
- // PKCE / API login
return Results.Ok();
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs
index d8127e9..9a99f0e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs
@@ -32,13 +32,13 @@ public async Task LogoutAsync(HttpContext ctx)
{
var auth = _authContext.Current;
- if (auth.SessionId != null)
+ if (auth.Session is SessionSecurityContext session)
{
var request = new LogoutRequest
{
TenantId = auth.TenantId,
- SessionId = auth.SessionId.Value,
- At = _clock.UtcNow
+ SessionId = session.SessionId,
+ At = _clock.UtcNow,
};
await _flow.LogoutAsync(request, ctx.RequestAborted);
@@ -65,6 +65,9 @@ private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery)
if (delivery.Mode != TokenResponseMode.Cookie)
return;
+ if (delivery.Cookie == null)
+ return;
+
_cookieManager.Delete(ctx, delivery.Cookie.Name);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs
index 873a6fb..a4db8cf 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs
@@ -1,16 +1,249 @@
-using Microsoft.AspNetCore.Http;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Abstractions;
+using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Server.Services;
+using CodeBeam.UltimateAuth.Server.Stores;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
-namespace CodeBeam.UltimateAuth.Server.Endpoints
+namespace CodeBeam.UltimateAuth.Server.Endpoints;
+
+internal sealed class DefaultPkceEndpointHandler : IPkceEndpointHandler
{
- public class DefaultPkceEndpointHandler : IPkceEndpointHandler
+ private readonly IAuthFlowContextAccessor _authContext;
+ private readonly IUAuthFlowService _flow;
+ private readonly IAuthStore _authStore;
+ private readonly IPkceAuthorizationValidator _validator;
+ private readonly IClock _clock;
+ private readonly UAuthPkceOptions _pkceOptions;
+ private readonly ICredentialResponseWriter _credentialResponseWriter;
+ private readonly AuthRedirectResolver _redirectResolver;
+
+ public DefaultPkceEndpointHandler(
+ IAuthFlowContextAccessor authContext,
+ IUAuthFlowService flow,
+ IAuthStore authStore,
+ IPkceAuthorizationValidator validator,
+ IClock clock,
+ IOptions pkceOptions,
+ ICredentialResponseWriter credentialResponseWriter,
+ AuthRedirectResolver redirectResolver)
+ {
+ _authContext = authContext;
+ _flow = flow;
+ _authStore = authStore;
+ _validator = validator;
+ _clock = clock;
+ _pkceOptions = pkceOptions.Value;
+ _credentialResponseWriter = credentialResponseWriter;
+ _redirectResolver = redirectResolver;
+ }
+
+ public async Task AuthorizeAsync(HttpContext ctx)
+ {
+ var authContext = _authContext.Current;
+
+ if (authContext.FlowType != AuthFlowType.Login)
+ return Results.BadRequest("PKCE is only supported for login flow.");
+
+ 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,
+ tenantId: authContext.TenantId,
+ redirectUri: request.RedirectUri,
+ deviceId: string.Empty // TODO: Fix here with device binding
+ );
+
+ var expiresAt = _clock.UtcNow.AddSeconds(_pkceOptions.AuthorizationCodeLifetimeSeconds);
+
+ var artifact = new PkceAuthorizationArtifact(
+ authorizationCode: authorizationCode,
+ codeChallenge: request.CodeChallenge,
+ challengeMethod: PkceChallengeMethod.S256,
+ expiresAt: expiresAt,
+ maxAttempts: _pkceOptions.MaxVerificationAttempts,
+ context: snapshot
+ );
+
+ await _authStore.StoreAsync(authorizationCode, artifact, ctx.RequestAborted);
+
+ return Results.Ok(new PkceAuthorizeResponse
+ {
+ AuthorizationCode = authorizationCode.Value,
+ ExpiresIn = _pkceOptions.AuthorizationCodeLifetimeSeconds
+ });
+ }
+
+ public async Task CompleteAsync(HttpContext ctx)
+ {
+ var authContext = _authContext.Current;
+
+ if (authContext.FlowType != AuthFlowType.Login)
+ return Results.BadRequest("PKCE is only supported for login flow.");
+
+ var request = await ReadPkceCompleteRequestAsync(ctx);
+ if (request is null)
+ return Results.BadRequest("Invalid PKCE completion 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;
+
+ if (artifact is null)
+ return Results.Unauthorized(); // replay / expired / unknown code
+
+ var validation = _validator.Validate(artifact, request.CodeVerifier,
+ new PkceContextSnapshot(
+ clientProfile: authContext.ClientProfile,
+ tenantId: authContext.TenantId,
+ redirectUri: null,
+ deviceId: string.Empty),
+ _clock.UtcNow);
+
+ if (!validation.Success)
+ {
+ artifact.RegisterAttempt();
+ return RedirectToLoginWithError(ctx, authContext, "invalid");
+ }
+
+ var loginRequest = new LoginRequest
+ {
+ Identifier = request.Identifier,
+ Secret = request.Secret,
+ TenantId = authContext.TenantId,
+ At = _clock.UtcNow,
+ Device = authContext.Device,
+ RequestTokens = authContext.AllowsTokenIssuance
+ };
+
+ var execution = new AuthExecutionContext
+ {
+ EffectiveClientProfile = artifact.Context.ClientProfile,
+ };
+
+ var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted);
+
+ if (!result.IsSuccess)
+ return RedirectToLoginWithError(ctx, authContext, "invalid");
+
+ if (result.SessionId is not null)
+ {
+ _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value);
+ }
+
+ if (result.AccessToken is not null)
+ {
+ _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken);
+ }
+
+ if (result.RefreshToken is not null)
+ {
+ _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken);
+ }
+
+ if (authContext.Response.Login.RedirectEnabled)
+ {
+ var redirectUrl = request.ReturnUrl ?? _redirectResolver.ResolveRedirect(ctx, authContext.Response.Login.SuccessPath);
+ return Results.Redirect(redirectUrl);
+ }
+
+ return Results.Ok();
+ }
+
+ private static async Task ReadPkceAuthorizeRequestAsync(HttpContext ctx)
{
- public Task CreateAsync(HttpContext ctx)
- => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented));
+ if (ctx.Request.HasJsonContentType())
+ {
+ return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted);
+ }
+
+ if (ctx.Request.HasFormContentType)
+ {
+ var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted);
- public Task VerifyAsync(HttpContext ctx)
- => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented));
+ var codeChallenge = form["code_challenge"].ToString();
+ var challengeMethod = form["challenge_method"].ToString();
+ var redirectUri = form["redirect_uri"].ToString();
- public Task ConsumeAsync(HttpContext ctx)
- => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented));
+ return new PkceAuthorizeRequest
+ {
+ CodeChallenge = codeChallenge,
+ ChallengeMethod = challengeMethod,
+ RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri
+ };
+ }
+
+ return null;
}
+
+ private static async Task ReadPkceCompleteRequestAsync(HttpContext ctx)
+ {
+ if (ctx.Request.HasJsonContentType())
+ {
+ return await ctx.Request.ReadFromJsonAsync(
+ cancellationToken: ctx.RequestAborted);
+ }
+
+ if (ctx.Request.HasFormContentType)
+ {
+ var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted);
+
+ 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();
+
+ return new PkceCompleteRequest
+ {
+ AuthorizationCode = authorizationCode,
+ CodeVerifier = codeVerifier,
+ Identifier = identifier,
+ Secret = secret,
+ ReturnUrl = returnUrl
+ };
+ }
+
+ return null;
+ }
+
+ private IResult RedirectToLoginWithError(HttpContext ctx, AuthFlowContext auth, string error)
+ {
+ var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login";
+
+ var hubKey = ctx.Request.Query["hub"].ToString();
+
+ if (!string.IsNullOrWhiteSpace(hubKey))
+ {
+ var key = new AuthArtifactKey(hubKey);
+ var artifact = _authStore.GetAsync(key, ctx.RequestAborted).Result;
+
+ if (artifact is HubFlowArtifact hub)
+ {
+ hub.MarkCompleted();
+ _authStore.StoreAsync(key, hub, ctx.RequestAborted);
+ }
+ return Results.Redirect($"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}");
+ }
+
+ return Results.Redirect($"{basePath}?__uauth_error={Uri.EscapeDataString(error)}");
+ }
+
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs
index 6ee312b..83b703a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs
@@ -8,111 +8,74 @@
namespace CodeBeam.UltimateAuth.Server.Endpoints
{
- public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler where TUserId : notnull
+ public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler
{
private readonly IAuthFlowContextAccessor _authContext;
- private readonly ISessionRefreshService _sessionRefresh;
- private readonly IRefreshTokenRotationService _tokenRotation;
+ private readonly IRefreshFlowService _refreshFlow;
private readonly ICredentialResponseWriter _credentialWriter;
private readonly IRefreshResponseWriter _refreshWriter;
- private readonly ISessionQueryService _sessionQueries;
private readonly IRefreshTokenResolver _refreshTokenResolver;
+ private readonly IRefreshResponsePolicy _refreshPolicy;
public DefaultRefreshEndpointHandler(
IAuthFlowContextAccessor authContext,
- ISessionRefreshService sessionRefresh,
- IRefreshTokenRotationService tokenRotation,
+ IRefreshFlowService refreshFlow,
ICredentialResponseWriter credentialWriter,
IRefreshResponseWriter refreshWriter,
- ISessionQueryService sessionQueries,
- IRefreshTokenResolver refreshTokenResolver)
+ IRefreshTokenResolver refreshTokenResolver,
+ IRefreshResponsePolicy refreshPolicy)
{
_authContext = authContext;
- _sessionRefresh = sessionRefresh;
- _tokenRotation = tokenRotation;
+ _refreshFlow = refreshFlow;
_credentialWriter = credentialWriter;
_refreshWriter = refreshWriter;
- _sessionQueries = sessionQueries;
_refreshTokenResolver = refreshTokenResolver;
+ _refreshPolicy = refreshPolicy;
}
public async Task RefreshAsync(HttpContext ctx)
{
- var auth = _authContext.Current;
- var decision = RefreshDecisionResolver.Resolve(auth.EffectiveMode);
+ var flow = _authContext.Current;
- return decision switch
+ if (flow.Session is not SessionSecurityContext session)
{
- RefreshDecision.SessionTouch => await HandleSessionTouchAsync(ctx, auth, auth.Response),
- RefreshDecision.TokenRotation => await HandleTokenRotationAsync(ctx, auth, auth.Response),
+ //_logger.LogDebug("Refresh called without active session.");
+ return Results.Ok(RefreshOutcome.None);
+ }
- _ => Results.StatusCode(StatusCodes.Status409Conflict)
+ var request = new RefreshFlowRequest
+ {
+ SessionId = session.SessionId,
+ RefreshToken = _refreshTokenResolver.Resolve(ctx),
+ Device = flow.Device,
+ Now = DateTimeOffset.UtcNow
};
- }
-
- private async Task HandleSessionTouchAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response)
- {
- if (flow.SessionId is null)
- return Results.Unauthorized();
- var now = DateTimeOffset.UtcNow;
- var validation = await _sessionQueries.ValidateSessionAsync(
- new SessionValidationContext
- {
- TenantId = flow.TenantId,
- SessionId = flow.SessionId.Value,
- Now = now,
- Device = DeviceInfoFactory.FromHttpContext(ctx)
- },
- ctx.RequestAborted);
+ var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted);
- if (!validation.IsValid)
+ if (!result.Succeeded)
{
WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired);
return Results.Unauthorized();
}
- var result = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted);
+ var primary = _refreshPolicy.SelectPrimary(flow, request, result);
- if (!result.IsSuccess || result.PrimaryToken is null)
+ if (primary == CredentialKind.Session && result.SessionId is not null)
{
- WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired);
- return Results.Unauthorized();
+ _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value);
}
-
- _credentialWriter.Write(ctx, CredentialKind.Session, result.PrimaryToken.Value);
- WriteRefreshHeader(ctx, flow, result.DidTouch ? RefreshOutcome.Touched : RefreshOutcome.NoOp);
-
- return Results.NoContent();
- }
-
- private async Task HandleTokenRotationAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response)
- {
- var refreshToken = _refreshTokenResolver.Resolve(ctx);
- if (refreshToken is null)
- return Results.Unauthorized();
-
- var now = DateTimeOffset.UtcNow;
-
- var result = await _tokenRotation.RotateAsync(
- flow,
- new RefreshTokenRotationContext
- {
- RefreshToken = refreshToken,
- Now = DateTimeOffset.UtcNow
- },
- ctx.RequestAborted);
-
- if (!result.IsSuccess)
+ else if (primary == CredentialKind.AccessToken && result.AccessToken is not null)
{
- WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired);
- return Results.Unauthorized();
+ _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken);
}
- _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token);
- _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token);
- WriteRefreshHeader(ctx, flow, RefreshOutcome.Rotated);
+ if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null)
+ {
+ _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken);
+ }
+ WriteRefreshHeader(ctx, flow, result.Outcome);
return Results.NoContent();
}
@@ -123,5 +86,6 @@ private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOu
_refreshWriter.Write(ctx, outcome);
}
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs
index 33ab725..5bd10ac 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs
@@ -8,17 +8,17 @@
namespace CodeBeam.UltimateAuth.Server.Endpoints
{
- internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler
+ internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler
{
private readonly IAuthFlowContextAccessor _authContext;
private readonly IFlowCredentialResolver _credentialResolver;
- private readonly ISessionQueryService _sessionValidator;
+ private readonly ISessionQueryService _sessionValidator;
private readonly IClock _clock;
public DefaultValidateEndpointHandler(
IAuthFlowContextAccessor authContext,
IFlowCredentialResolver credentialResolver,
- ISessionQueryService sessionValidator,
+ ISessionQueryService sessionValidator,
IClock clock)
{
_authContext = authContext;
@@ -64,7 +64,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
TenantId = credential.TenantId,
SessionId = sessionId,
Now = _clock.UtcNow,
- Device = credential.Device
+ Device = auth.Device
},
ct);
@@ -74,10 +74,10 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(),
Snapshot = new AuthStateSnapshot
{
- UserId = result?.Session?.UserId?.ToString(),
- TenantId = result?.TenantId,
- Claims = result?.Session?.Claims ?? ClaimsSnapshot.Empty,
- AuthenticatedAt = result?.Session?.CreatedAt,
+ UserId = result.UserKey,
+ TenantId = result.TenantId,
+ Claims = result.Claims,
+ AuthenticatedAt = _clock.UtcNow,
}
});
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs
index 48c1d39..7b769f1 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs
@@ -5,15 +5,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints
{
internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler
{
- private readonly DefaultLoginEndpointHandler _inner;
+ private readonly DefaultLoginEndpointHandler _inner;
- public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner)
+ public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner)
{
_inner = inner;
}
- public Task LoginAsync(HttpContext ctx)
- => _inner.LoginAsync(ctx);
+ public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx);
}
-
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs
index 54095f2..f35f05c 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs
@@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints
{
internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler
{
- private readonly DefaultLogoutEndpointHandler _inner;
+ private readonly DefaultLogoutEndpointHandler _inner;
- public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner)
+ public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner)
{
_inner = inner;
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs
new file mode 100644
index 0000000..8f8f803
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs
@@ -0,0 +1,19 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Endpoints
+{
+ internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler
+ {
+ private readonly DefaultPkceEndpointHandler _inner;
+
+ public PkceEndpointHandlerBridge(DefaultPkceEndpointHandler inner)
+ {
+ _inner = inner;
+ }
+
+ public Task AuthorizeAsync(HttpContext ctx) => _inner.AuthorizeAsync(ctx);
+
+ public Task CompleteAsync(HttpContext ctx) => _inner.CompleteAsync(ctx);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs
index 28a885a..22e776f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs
@@ -1,19 +1,16 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http;
namespace CodeBeam.UltimateAuth.Server.Endpoints
{
internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler
{
- private readonly DefaultRefreshEndpointHandler _inner;
+ private readonly DefaultRefreshEndpointHandler _inner;
- public RefreshEndpointHandlerBridge(
- DefaultRefreshEndpointHandler inner)
+ public RefreshEndpointHandlerBridge(DefaultRefreshEndpointHandler inner)
{
_inner = inner;
}
- public Task RefreshAsync(HttpContext ctx)
- => _inner.RefreshAsync(ctx);
+ public Task RefreshAsync(HttpContext ctx) => _inner.RefreshAsync(ctx);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
index 728a8a6..0d3ccc9 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs
@@ -21,10 +21,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
{
// Base: /auth
string basePrefix = options.RoutePrefix.TrimStart('/');
-
- bool useRouteTenant =
- options.MultiTenant.Enabled &&
- options.MultiTenant.EnableRoute;
+ bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute;
RouteGroupBuilder group = useRouteTenant
? rootGroup.MapGroup("/{tenant}/" + basePrefix)
@@ -32,23 +29,6 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
group.AddEndpointFilter();
- if (options.EnablePkceEndpoints != false)
- {
- var pkce = group.MapGroup("/pkce");
-
- pkce.MapPost("/create",
- async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
- => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
-
- pkce.MapPost("/verify",
- async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
- => await h.VerifyAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
-
- pkce.MapPost("/consume",
- async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
- => await h.ConsumeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
- }
-
if (options.EnableLoginEndpoints != false)
{
group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx)
@@ -67,6 +47,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
=> await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication));
}
+ if (options.EnablePkceEndpoints != false)
+ {
+ var pkce = group.MapGroup("/pkce");
+
+ pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
+ => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
+
+ pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx)
+ => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login));
+ }
+
if (options.EnableTokenEndpoints != false)
{
var token = group.MapGroup("");
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs
index a5df803..f9a2be5 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs
@@ -5,14 +5,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints
{
internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler
{
- private readonly DefaultValidateEndpointHandler _inner;
+ private readonly DefaultValidateEndpointHandler _inner;
- public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner)
+ public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner)
{
_inner = inner;
}
- public Task ValidateAsync(HttpContext context, CancellationToken ct = default)
- => _inner.ValidateAsync(context, ct);
+ public Task ValidateAsync(HttpContext context, CancellationToken ct = default) => _inner.ValidateAsync(context, ct);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs
new file mode 100644
index 0000000..fb276f1
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs
@@ -0,0 +1,40 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Auth;
+
+namespace CodeBeam.UltimateAuth.Server.Extensions
+{
+ public static class AuthFlowContextExtensions
+ {
+ public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffset now)
+ {
+ return new AuthContext
+ {
+ TenantId = flow.TenantId,
+ Operation = flow.FlowType.ToAuthOperation(),
+ Mode = flow.EffectiveMode,
+ At = now,
+ Device = flow.Device,
+ Session = flow.Session
+ };
+ }
+
+ public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile)
+ {
+ return new AuthFlowContext(
+ flow.FlowType,
+ profile,
+ flow.EffectiveMode,
+ flow.Device,
+ flow.TenantId,
+ flow.IsAuthenticated,
+ flow.UserKey,
+ flow.Session,
+ flow.OriginalOptions,
+ flow.EffectiveOptions,
+ flow.Response,
+ flow.PrimaryTokenKind);
+ }
+
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs
new file mode 100644
index 0000000..ea1803c
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs
@@ -0,0 +1,33 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Extensions
+{
+ public static class AuthFlowTypeExtensions
+ {
+ public static AuthOperation ToAuthOperation(this AuthFlowType flowType)
+ => flowType switch
+ {
+ AuthFlowType.Login => AuthOperation.Login,
+ AuthFlowType.Reauthentication => AuthOperation.Login,
+
+ AuthFlowType.ApiAccess => AuthOperation.Access,
+ AuthFlowType.ValidateSession => AuthOperation.Access,
+ AuthFlowType.UserInfo => AuthOperation.Access,
+ AuthFlowType.PermissionQuery => AuthOperation.Access,
+ AuthFlowType.IssueToken => AuthOperation.Access,
+ AuthFlowType.IntrospectToken => AuthOperation.Access,
+
+ AuthFlowType.RefreshSession => AuthOperation.Refresh,
+ AuthFlowType.RefreshToken => AuthOperation.Refresh,
+
+ AuthFlowType.Logout => AuthOperation.Logout,
+ AuthFlowType.RevokeSession => AuthOperation.Revoke,
+ AuthFlowType.RevokeToken => AuthOperation.Revoke,
+
+ AuthFlowType.QuerySession => AuthOperation.System,
+
+ _ => throw new InvalidOperationException($"Unsupported flow type: {flowType}")
+ };
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
index 84f1a4f..e6fbcf6 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
@@ -1,4 +1,4 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Abstractions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs
index 2a2d25a..ed59824 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs
@@ -7,14 +7,14 @@ namespace CodeBeam.UltimateAuth.Server.Extensions
{
public static class HttpContextUserExtensions
{
- public static AuthUserSnapshot GetUserContext(this HttpContext ctx)
+ public static AuthUserSnapshot GetUserContext(this HttpContext ctx)
{
- if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user)
+ if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user)
{
return user;
}
- return AuthUserSnapshot.Anonymous();
+ return AuthUserSnapshot.Anonymous();
}
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs
index 2233584..daba2ba 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs
@@ -10,11 +10,13 @@
using CodeBeam.UltimateAuth.Server.Cookies;
using CodeBeam.UltimateAuth.Server.Endpoints;
using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Server.Infrastructure.Hub;
using CodeBeam.UltimateAuth.Server.Infrastructure.Session;
using CodeBeam.UltimateAuth.Server.Issuers;
using CodeBeam.UltimateAuth.Server.MultiTenancy;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Services;
+using CodeBeam.UltimateAuth.Server.Stores;
using CodeBeam.UltimateAuth.Server.Users;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -126,9 +128,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.AddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>));
- services.AddScoped(typeof(IUAuthSessionService<>), typeof(UAuthSessionService<>));
+ services.AddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService));
+ services.AddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager));
services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>));
- services.AddScoped(typeof(IUAuthTokenService<>), typeof(UAuthTokenService<>));
services.AddSingleton();
@@ -146,18 +148,18 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
// -----------------------------
// SESSION / TOKEN ISSUERS
// -----------------------------
- services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>));
- services.TryAddScoped();
+ services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer));
+ services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer));
- services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor));
+ services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor));
services.TryAddScoped();
services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>));
- services.TryAddScoped(typeof(ISessionOrchestrator<>), typeof(UAuthSessionOrchestrator<>));
+ services.TryAddScoped(typeof(ISessionOrchestrator), typeof(UAuthSessionOrchestrator));
services.TryAddScoped();
- services.TryAddScoped(typeof(ISessionQueryService<>), typeof(UAuthSessionQueryService<>));
+ services.TryAddScoped(typeof(ISessionQueryService), typeof(UAuthSessionQueryService));
services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver));
- services.TryAddScoped(typeof(ISessionRefreshService<>), typeof(DefaultSessionRefreshService<>));
+ services.TryAddScoped(typeof(ISessionTouchService), typeof(DefaultSessionTouchService));
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
@@ -176,8 +178,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddSingleton();
services.AddScoped();
- services.AddScoped(typeof(IRefreshTokenValidator<>), typeof(DefaultRefreshTokenValidator<>));
- services.AddScoped(typeof(IRefreshTokenRotationService<>), typeof(RefreshTokenRotationService<>));
+ services.AddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator));
+ services.AddScoped();
+ services.AddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService));
services.AddSingleton();
services.AddSingleton();
@@ -185,6 +188,14 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.AddScoped();
services.AddSingleton();
+ services.AddScoped();
+
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
// -----------------------------
// ENDPOINTS
@@ -199,21 +210,23 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddSingleton