diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index e91af37..f0dc5ec 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -9,11 +9,12 @@ - + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor index 96fbbe6..74eaceb 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -1,4 +1,12 @@ -@inherits LayoutComponentBase +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Server.Infrastructure +@inherits LayoutComponentBase + + + + + + @Body diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs new file mode 100644 index 0000000..d9123d5 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout +{ + public partial class MainLayout + { + + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css deleted file mode 100644 index 38d1f25..0000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,98 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - -#blazor-error-ui { - color-scheme: light only; - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor deleted file mode 100644 index ef23cb3..0000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 9001e0b..29c1404 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -1,7 +1,68 @@ @page "/" +@page "/login" +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Utilities +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Core.Runtime +@using CodeBeam.UltimateAuth.Server.Abstractions +@using CodeBeam.UltimateAuth.Server.Cookies +@using CodeBeam.UltimateAuth.Server.Infrastructure +@using CodeBeam.UltimateAuth.Server.Services +@using CodeBeam.UltimateAuth.Server.Stores +@inject IUAuthStateManager StateManager +@inject IHubFlowReader HubFlowReader +@inject IHubCredentialResolver HubCredentialResolver +@inject IAuthStore AuthStore +@inject IBrowserStorage BrowserStorage +@inject IUAuthFlowService Flow +@inject ISnackbar Snackbar +@inject IFlowCredentialResolver CredentialResolver +@inject IUAuthClient UAuthClient +@inject NavigationManager Nav +@inject IUAuthProductInfoProvider ProductInfo +@inject AuthenticationStateProvider AuthStateProvider +@inject UAuthClientDiagnostics Diagnostics -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(); + // Endpoint handlers - //services.TryAddScoped(typeof(ILoginEndpointHandler), typeof(DefaultLoginEndpointHandler<>)); - services.AddScoped>(); + services.AddScoped>(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped>(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped(); services.TryAddScoped(); + + services.AddScoped>(); + services.TryAddScoped(); //services.TryAddScoped(); - //services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); @@ -227,8 +240,8 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); // Issuers - services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>)); - services.TryAddScoped(); + services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); + services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); // User service services.TryAddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs index 5eb344c..657f99b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs index 93e78b3..361b3ac 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -8,6 +8,6 @@ public sealed class TransportCredential public required string Value { get; init; } public string? TenantId { get; init; } - public DeviceInfo? Device { get; init; } + public required DeviceInfo Device { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs index 5a087d7..31f07ba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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; @@ -28,7 +29,16 @@ public DefaultCredentialResponseWriter( _headerPolicy = headerPolicy; } - public void Write(HttpContext context, CredentialKind kind, string value) + public void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId) + => WriteInternal(context, kind, sessionId.ToString()); + + public void Write(HttpContext context, CredentialKind kind, AccessToken token) + => WriteInternal(context, kind, token.Token); + + public void Write(HttpContext context, CredentialKind kind, RefreshToken token) + => WriteInternal(context, kind, token.Token); + + public void WriteInternal(HttpContext context, CredentialKind kind, string value) { var auth = _authContext.Current; var delivery = ResolveDelivery(auth.Response, kind); @@ -58,9 +68,7 @@ private void WriteCookie(HttpContext context, CredentialKind kind, string value, if (options.Cookie is null) throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); - var logicalLifetime = ResolveLogicalLifetime(auth, kind); - var cookieOptions = _cookiePolicy.Build(options, auth, logicalLifetime); - + var cookieOptions = _cookiePolicy.Build(options, auth, kind); _cookieManager.Write(context, options.Cookie.Name, value, cookieOptions); } @@ -80,22 +88,4 @@ private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse r CredentialKind.RefreshToken => response.RefreshTokenDelivery, _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; - - private static TimeSpan? ResolveLogicalLifetime(AuthFlowContext auth, CredentialKind kind) - { - // TODO: Move this method to policy on implementing - return kind switch - { - CredentialKind.Session - => auth.EffectiveOptions.Options.Session.IdleTimeout + auth.OriginalOptions.Cookie.Session.Lifetime.IdleBuffer, - - CredentialKind.RefreshToken - => auth.EffectiveOptions.Options.Tokens.RefreshTokenLifetime + auth.OriginalOptions.Cookie.RefreshToken.Lifetime.IdleBuffer, - - CredentialKind.AccessToken - => auth.EffectiveOptions.Options.Tokens.AccessTokenLifetime + auth.OriginalOptions.Cookie.AccessToken.Lifetime.IdleBuffer, - - _ => null - }; - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs new file mode 100644 index 0000000..5a69b57 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultDeviceContextFactory : IDeviceContextFactory + { + public DeviceContext Create(DeviceInfo device) + { + if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + return DeviceContext.Anonymous(); + + return DeviceContext.FromDeviceId(device.DeviceId); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs similarity index 59% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs index 15ef29a..a8c7cc9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs @@ -1,6 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -10,27 +12,33 @@ public DeviceInfo Resolve(HttpContext context) { var request = context.Request; + var rawDeviceId = ResolveRawDeviceId(context); + DeviceId.TryCreate(rawDeviceId, out var deviceId); + return new DeviceInfo { - DeviceId = ResolveDeviceId(context), + DeviceId = deviceId, Platform = ResolvePlatform(request), - OperatingSystem = null, // optional UA parsing later - Browser = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString(), UserAgent = request.Headers.UserAgent.ToString(), - IsTrusted = null + IpAddress = context.Connection.RemoteIpAddress?.ToString() }; } - private static string ResolveDeviceId(HttpContext context) + + private static string? ResolveRawDeviceId(HttpContext context) { - if (context.Request.Headers.TryGetValue("X-Device-Id", out var header)) + if (context.Request.Headers.TryGetValue("X-UDID", out var header)) return header.ToString(); - if (context.Request.Cookies.TryGetValue("ua_device", out var cookie)) + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue("__uauth_device", out var formValue) && !StringValues.IsNullOrEmpty(formValue)) + { + return formValue.ToString(); + } + + if (context.Request.Cookies.TryGetValue("udid", out var cookie)) return cookie; - return "unknown"; + return null; } private static string? ResolvePlatform(HttpRequest request) @@ -45,5 +53,6 @@ private static string ResolveDeviceId(HttpContext context) return "web"; } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs new file mode 100644 index 0000000..6ca4c19 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IDeviceContextFactory + { + DeviceContext Create(DeviceInfo requestDevice); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs deleted file mode 100644 index 39b906b..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public static class DeviceInfoFactory - { - public static DeviceInfo FromHttpContext(HttpContext context) - { - return new DeviceInfo - { - DeviceId = ResolveDeviceId(context), - Platform = context.Request.Headers.UserAgent.ToString(), - UserAgent = context.Request.Headers.UserAgent.ToString() - }; - } - - private static string ResolveDeviceId(HttpContext context) - { - // TODO: cookie / fingerprint / header in future - return context.Request.Headers.UserAgent.ToString(); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs new file mode 100644 index 0000000..bfc20d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Hub +{ + internal sealed class DefaultHubCredentialResolver : IHubCredentialResolver + { + private readonly IAuthStore _store; + + public DefaultHubCredentialResolver(IAuthStore store) + { + _store = store; + } + + public async Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default) + { + var artifact = await _store.GetAsync(new AuthArtifactKey(hubSessionId.Value), ct); + + if (artifact is not HubFlowArtifact flow) + return null; + + if (flow.IsCompleted) + return null; + + if (!flow.Payload.TryGet("authorization_code", out string? authorizationCode)) + return null; + + if (!flow.Payload.TryGet("code_verifier", out string? codeVerifier)) + return null; + + return new HubCredentials + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + ClientProfile = flow.ClientProfile, + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs new file mode 100644 index 0000000..a49409a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs @@ -0,0 +1,41 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultHubFlowReader : IHubFlowReader + { + private readonly IAuthStore _store; + private readonly IClock _clock; + + public DefaultHubFlowReader(IAuthStore store, IClock clock) + { + _store = store; + _clock = clock; + } + + public async Task GetStateAsync(HubSessionId hubSessionId, CancellationToken ct = default) + { + var artifact = await _store.GetAsync(new AuthArtifactKey(hubSessionId.Value), ct); + + if (artifact is not HubFlowArtifact flow) + return null; + + var now = _clock.UtcNow; + + return new HubFlowState + { + Exists = true, + HubSessionId = flow.HubSessionId, + FlowType = flow.FlowType, + ClientProfile = flow.ClientProfile, + ReturnUrl = flow.ReturnUrl, + IsExpired = flow.IsExpired(now), + IsCompleted = flow.IsCompleted, + IsActive = !flow.IsExpired(now) && !flow.IsCompleted + }; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs new file mode 100644 index 0000000..bfbc2ba --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class HubCapabilities : IHubCapabilities + { + public bool SupportsPkce => true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 98496b7..6868542 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -3,9 +3,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand> + internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand { - public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { return issuer.IssueLoginSessionAsync(LoginContext, ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs index 5139b34..0040e5a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs @@ -3,8 +3,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public interface ISessionCommand + public interface ISessionCommand { - Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct); + Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs index 8cedd4d..668a9d2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal interface ISessionOrchestrator + internal interface ISessionOrchestrator { - Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); + Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs index a271c0d..64a9773 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs @@ -3,16 +3,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public interface ISessionQueryService + public interface ISessionQueryService { - Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); + Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); - Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default); + Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default); - Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs index eacdfe7..47102fb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs @@ -4,20 +4,20 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class RevokeAllChainsCommand : ISessionCommand + public sealed class RevokeAllChainsCommand : ISessionCommand { - public TUserId UserId { get; } - public ChainId? ExceptChainId { get; } + public UserKey UserKey { get; } + public SessionChainId? ExceptChainId { get; } - public RevokeAllChainsCommand(TUserId userId, ChainId? exceptChainId) + public RevokeAllChainsCommand(UserKey userKey, SessionChainId? exceptChainId) { - UserId = userId; + UserKey = userKey; ExceptChainId = exceptChainId; } - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeAllChainsAsync(context.TenantId, UserId, ExceptChainId, context.At, ct); + await issuer.RevokeAllChainsAsync(context.TenantId, UserKey, ExceptChainId, context.At, ct); return Unit.Value; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs index 0815f98..c8c1a37 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs @@ -4,18 +4,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator { - public sealed class RevokeChainCommand : ISessionCommand + public sealed class RevokeChainCommand : ISessionCommand { - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } - public RevokeChainCommand(ChainId chainId) + public RevokeChainCommand(SessionChainId chainId) { ChainId = chainId; } public async Task ExecuteAsync( AuthContext context, - ISessionIssuer issuer, + ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeChainAsync( diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs index 8aa0702..4f0d752 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -1,25 +1,26 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator { - public sealed class RevokeRootCommand : ISessionCommand + public sealed class RevokeRootCommand : ISessionCommand { - public TUserId UserId { get; } + public UserKey UserKey { get; } - public RevokeRootCommand(TUserId userId) + public RevokeRootCommand(UserKey userKey) { - UserId = userId; + UserKey = userKey; } public async Task ExecuteAsync( AuthContext context, - ISessionIssuer issuer, + ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeRootAsync( context.TenantId, - UserId, + UserKey, context.At, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs index 3d88afe..86fa7fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand + internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand { - public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeSessionAsync(TenantId, SessionId, _.At, ct); return Unit.Value; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs index d57b479..1e52d02 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs @@ -3,9 +3,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand> + internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand { - public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { return issuer.RotateSessionAsync(RotationContext, ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index 784df9b..2a9c93c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -4,19 +4,19 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthSessionOrchestrator : ISessionOrchestrator + public sealed class UAuthSessionOrchestrator : ISessionOrchestrator { private readonly IAuthAuthority _authority; - private readonly ISessionIssuer _issuer; + private readonly ISessionIssuer _issuer; private bool _executed; - public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) + public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) { _authority = authority; _issuer = issuer; } - public async Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default) + public async Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default) { if (_executed) throw new InvalidOperationException("Session orchestrator can only be executed once per operation."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index ea9e146..9bd0336 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -6,70 +6,70 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthSessionQueryService : ISessionQueryService + public sealed class UAuthSessionQueryService : ISessionQueryService { - private readonly ISessionStoreFactory _storeFactory; + private readonly ISessionStoreKernelFactory _storeFactory; private readonly UAuthServerOptions _options; - public UAuthSessionQueryService(ISessionStoreFactory storeFactory, IOptions options) + public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IOptions options) { _storeFactory = storeFactory; _options = options.Value; } - public async Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) + public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { - var kernel = _storeFactory.Create(context.TenantId); + var kernel = _storeFactory.Create(context.TenantId); - var session = await kernel.GetSessionAsync(context.TenantId,context.SessionId); + var session = await kernel.GetSessionAsync(context.SessionId); if (session is null) - return SessionValidationResult.Invalid(SessionState.NotFound); + return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); var state = session.GetState(context.Now, _options.Session.IdleTimeout); if (state != SessionState.Active) - return SessionValidationResult.Invalid(state); + return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: session.ChainId); - var chain = await kernel.GetChainAsync(context.TenantId, session.ChainId); + var chain = await kernel.GetChainAsync(session.ChainId); if (chain is null || chain.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); - var root = await kernel.GetSessionRootAsync(context.TenantId, session.UserId); + var root = await kernel.GetSessionRootByUserAsync(session.UserKey); if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); if (session.SecurityVersionAtCreation != root.SecurityVersion) - return SessionValidationResult.Invalid(SessionState.SecurityMismatch); + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. // Currently this line has error on refresh flow. //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - return SessionValidationResult.Active(context.TenantId, session, chain, root); + return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, session.Claims, boundDeviceId: session.Device.DeviceId); } - public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionAsync(tenantId, sessionId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionAsync(sessionId); } - public Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionsByChainAsync(tenantId, chainId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionsByChainAsync(chainId); } - public Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainsByUserAsync(tenantId, userId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainsByUserAsync(userKey); } - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainIdBySessionAsync(tenantId, sessionId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainIdBySessionAsync(sessionId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs new file mode 100644 index 0000000..cef6bda --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IPkceAuthorizationValidator +{ + PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs new file mode 100644 index 0000000..605a817 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// Represents a PKCE authorization process that has been initiated +/// but not yet completed. This artifact is short-lived, single-use, +/// and must be consumed atomically. +/// +public sealed class PkceAuthorizationArtifact : AuthArtifact +{ + public PkceAuthorizationArtifact( + AuthArtifactKey authorizationCode, + string codeChallenge, + PkceChallengeMethod challengeMethod, + DateTimeOffset expiresAt, + int maxAttempts, + PkceContextSnapshot context) + : base(AuthArtifactType.PkceAuthorizationCode, expiresAt, maxAttempts) + { + AuthorizationCode = authorizationCode; + CodeChallenge = codeChallenge; + ChallengeMethod = challengeMethod; + Context = context; + } + + /// + /// Opaque authorization code issued to the client. + /// This is the lookup key in the AuthStore. + /// + public AuthArtifactKey AuthorizationCode { get; } + + /// + /// Base64Url-encoded hashed code challenge (S256). + /// The original verifier is never stored. + /// + public string CodeChallenge { get; } + + public PkceChallengeMethod ChallengeMethod { get; } + + /// + /// Immutable snapshot of client and request context + /// at the time the PKCE flow was initiated. + /// + public PkceContextSnapshot Context { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs new file mode 100644 index 0000000..d479e3a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator +{ + public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now) + { + // 1️⃣ Expiration + if (artifact.IsExpired(now)) + return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); + + // 2️⃣ Attempt limit + if (!artifact.CanAttempt()) + return PkceValidationResult.Fail(PkceValidationFailureReason.MaxAttemptsExceeded); + + // 3️⃣ Context consistency + //if (!IsContextValid(artifact.Context, completionContext)) + //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); + + // 4️⃣ Challenge method + if (artifact.ChallengeMethod != PkceChallengeMethod.S256) + return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); + + // 5️⃣ Verifier check + if (!IsVerifierValid(codeVerifier, artifact.CodeChallenge)) + return PkceValidationResult.Fail(PkceValidationFailureReason.InvalidVerifier); + + return PkceValidationResult.Ok(); + } + + private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnapshot completion) + { + if (!original.ClientProfile.Equals(completion.ClientProfile)) + return false; + + if (!string.Equals(original.TenantId, completion.TenantId, StringComparison.Ordinal)) + return false; + + if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) + return false; + + if (!string.Equals(original.DeviceId, completion.DeviceId, StringComparison.Ordinal)) + return false; + + return true; + } + + private static bool IsVerifierValid(string verifier, string expectedChallenge) + { + if (string.IsNullOrWhiteSpace(verifier)) + return false; + + using var sha256 = SHA256.Create(); + byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + + string computedChallenge = Base64UrlEncode(hash); + + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(computedChallenge), Encoding.ASCII.GetBytes(expectedChallenge)); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs new file mode 100644 index 0000000..d111596 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class PkceAuthorizeRequest +{ + public string CodeChallenge { get; init; } = default!; + public string ChallengeMethod { get; init; } = default!; + public string? RedirectUri { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs new file mode 100644 index 0000000..39de36f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum PkceChallengeMethod +{ + S256 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs new file mode 100644 index 0000000..c826b12 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// Immutable snapshot of relevant request and client context +/// captured at PKCE authorization time. +/// Used to ensure consistency and prevent flow confusion. +/// +public sealed class PkceContextSnapshot +{ + public PkceContextSnapshot( + UAuthClientProfile clientProfile, + string? tenantId, + string? redirectUri, + string? deviceId) + { + ClientProfile = clientProfile; + TenantId = tenantId; + RedirectUri = redirectUri; + DeviceId = deviceId; + } + + /// + /// Client profile resolved at runtime (e.g. BlazorWasm). + /// + public UAuthClientProfile ClientProfile { get; } + + /// + /// Tenant context at the time of authorization. + /// + public string? TenantId { get; } + + /// + /// Redirect URI used during authorization. + /// Must match during completion. + /// + public string? RedirectUri { get; } + + /// + /// Optional device binding identifier. + /// Enables future hard-binding of PKCE flows to devices. + /// + public string? DeviceId { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs new file mode 100644 index 0000000..6f3dc07 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum PkceValidationFailureReason +{ + None, + ArtifactExpired, + MaxAttemptsExceeded, + UnsupportedChallengeMethod, + InvalidVerifier, + ContextMismatch +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs new file mode 100644 index 0000000..ea7b605 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class PkceValidationResult +{ + private PkceValidationResult(bool success, PkceValidationFailureReason reason) + { + Success = success; + FailureReason = reason; + } + + public bool Success { get; } + + public PkceValidationFailureReason FailureReason { get; } + + public static PkceValidationResult Ok() => new(true, PkceValidationFailureReason.None); + + public static PkceValidationResult Fail(PkceValidationFailureReason reason) => new(false, reason); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs new file mode 100644 index 0000000..ef3ce7a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal class DefaultRefreshResponsePolicy : IRefreshResponsePolicy + { + public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + { + if (flow.EffectiveMode == UAuthMode.PureOpaque) + return CredentialKind.Session; + + if (flow.EffectiveMode == UAuthMode.PureJwt) + return CredentialKind.AccessToken; + + if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) + { + return CredentialKind.AccessToken; + } + + if (request.SessionId != null) + { + return CredentialKind.Session; + } + + if (flow.ClientProfile == UAuthClientProfile.Api) + return CredentialKind.AccessToken; + + return CredentialKind.Session; + } + + + public bool WriteRefreshToken(AuthFlowContext flow) + { + if (flow.EffectiveMode != UAuthMode.PureOpaque) + return true; + + return false; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs index 13fe2cf..f6fc373 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs @@ -6,20 +6,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { internal sealed class DefaultRefreshTokenResolver : IRefreshTokenResolver { - private const string DefaultCookieName = "ua_refresh"; + private const string DefaultCookieName = "uar"; private const string BearerPrefix = "Bearer "; private const string RefreshHeaderName = "X-Refresh-Token"; public string? Resolve(HttpContext context) { - // 1️⃣ Cookie (preferred) if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken)) { return cookieToken; } - // 2️⃣ Authorization: Bearer if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader)) { var value = authHeader.ToString(); @@ -31,7 +29,6 @@ internal sealed class DefaultRefreshTokenResolver : IRefreshTokenResolver } } - // 3️⃣ Explicit header fallback if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) && !string.IsNullOrWhiteSpace(headerToken)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs deleted file mode 100644 index 4090237..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// - /// - /// - public sealed class DefaultSessionRefreshService : ISessionRefreshService where TUserId : notnull - { - private readonly UAuthServerOptions _options; - private readonly ISessionStore _store; - private readonly ISessionActivityWriter _activityWriter; - - public DefaultSessionRefreshService( - IOptions options, - ISessionStore store, - ISessionActivityWriter activityWriter) - { - _options = options.Value; - _store = store; - _activityWriter = activityWriter; - } - - // It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh. - // That's why the service access store direcly: There is no security flow here, only validate and touch session. - public async Task RefreshAsync(SessionValidationResult validation, DateTimeOffset now, CancellationToken ct = default) - { - if (!validation.IsValid) - return SessionRefreshResult.ReauthRequired(); - - var session = validation.Session; - bool didTouch = false; - var touchInterval = _options.Session.TouchInterval; - - if (touchInterval.HasValue) - { - var elapsed = now - session.LastSeenAt; - - if (elapsed >= touchInterval.Value) - { - var touched = session.Touch(now); - await _activityWriter.TouchAsync(validation.TenantId, touched, ct); - didTouch = true; - } - } - - var primaryToken = PrimaryToken.FromSession(session.SessionId); - // For PureOpaque sessions, we do not issue a new refresh token on refresh. - return SessionRefreshResult.Success(primaryToken, didTouch: didTouch); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs new file mode 100644 index 0000000..c8a6c81 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultSessionTouchService : ISessionTouchService + { + private readonly ISessionStore _sessionStore; + + public DefaultSessionTouchService(ISessionStore sessionStore) + { + _sessionStore = sessionStore; + } + + // It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh. + // That's why the service access store direcly: There is no security flow here, only validate and touch session. + public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) + { + if (!validation.IsValid) + return SessionRefreshResult.ReauthRequired(); + + //var session = validation.Session; + bool didTouch = false; + + if (policy.TouchInterval.HasValue) + { + //var elapsed = now - session.LastSeenAt; + + //if (elapsed >= policy.TouchInterval.Value) + //{ + // var touched = session.Touch(now); + // await _activityWriter.TouchAsync(validation.TenantId, touched, ct); + // didTouch = true; + //} + + didTouch = await _sessionStore.TouchSessionAsync(validation.SessionId.Value, now, sessionTouchMode, ct); + } + + return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs new file mode 100644 index 0000000..8c05919 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IRefreshResponsePolicy + { + CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + bool WriteRefreshToken(AuthFlowContext flow); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs similarity index 61% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs index e7e7c2b..375fc31 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs @@ -6,8 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure /// Refreshes session lifecycle artifacts. /// Used by PureOpaque and Hybrid modes. /// - public interface ISessionRefreshService : IRefreshService where TUserId : notnull + public interface ISessionTouchService : IRefreshService { - Task RefreshAsync(SessionValidationResult validation, DateTimeOffset now, CancellationToken ct = default); + Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs new file mode 100644 index 0000000..3358193 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public class RefreshStrategyResolver + { + public static RefreshStrategy Resolve(UAuthMode mode) + { + return mode switch + { + UAuthMode.PureOpaque => RefreshStrategy.SessionOnly, + UAuthMode.PureJwt => RefreshStrategy.TokenOnly, + UAuthMode.SemiHybrid => RefreshStrategy.TokenWithSessionCheck, + UAuthMode.Hybrid => RefreshStrategy.SessionAndToken, + _ => throw new SecurityException("Unsupported refresh mode") + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs new file mode 100644 index 0000000..9f4fa8b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class SessionTouchPolicy + { + public TimeSpan? TouchInterval { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs new file mode 100644 index 0000000..5f85241 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal static class SessionValidationMapper + { + public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) + { + if (!result.IsValid) + { + if (result?.SessionId is null) + return null; + + return new SessionSecurityContext + { + SessionId = result.SessionId.Value, + State = result.State, + ChainId = result.ChainId, + UserKey = result.UserKey, + BoundDeviceId = result.BoundDeviceId + }; + } + + return new SessionSecurityContext + { + SessionId = result.SessionId!.Value, + State = SessionState.Active, + ChainId = result.ChainId, + UserKey = result.UserKey, + BoundDeviceId = result.BoundDeviceId + }; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index 9caab35..6165f93 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -20,7 +20,10 @@ public sealed class BearerSessionIdResolver : IInnerSessionIdResolver if (string.IsNullOrWhiteSpace(raw)) return null; - return new AuthSessionId(raw); + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs index b1988ae..3c905b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -23,9 +23,13 @@ public CookieSessionIdResolver(IOptions options) if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) return null; - return string.IsNullOrWhiteSpace(raw) - ? null - : new AuthSessionId(raw.Trim()); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index 0ea9eee..fe40bc1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -21,7 +21,14 @@ public HeaderSessionIdResolver(IOptions options) return null; var raw = values.FirstOrDefault(); - return string.IsNullOrWhiteSpace(raw) ? null : new AuthSessionId(raw); + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index c0b1e7f..bb3cc9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -21,9 +21,14 @@ public QuerySessionIdResolver(IOptions options) return null; var raw = values.FirstOrDefault(); - return string.IsNullOrWhiteSpace(raw) - ? null - : new AuthSessionId(raw.Trim()); + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index 7e9a6d9..34f7677 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -8,15 +8,15 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthUserAccessor : IUserAccessor { - private readonly ISessionStore _sessionStore; - private readonly IUAuthUserStore _userStore; + private readonly ISessionStore _sessionStore; + private readonly IUserIdConverter _userIdConverter; public UAuthUserAccessor( - ISessionStore sessionStore, - IUAuthUserStore userStore) + ISessionStore sessionStore, + IUserIdConverterResolver converterResolver) { _sessionStore = sessionStore; - _userStore = userStore; + _userIdConverter = converterResolver.GetConverter(); } public async Task ResolveAsync(HttpContext context) @@ -37,7 +37,8 @@ public async Task ResolveAsync(HttpContext context) return; } - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(session.UserId); + var userId = _userIdConverter.FromString(session.UserKey.Value); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(userId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs index debf965..3fabb84 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs @@ -15,7 +15,7 @@ public UserAccessorBridge(IServiceProvider services) public async Task ResolveAsync(HttpContext context) { - var accessor = _services.GetRequiredService>(); + var accessor = _services.GetRequiredService>(); await accessor.ResolveAsync(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index d423379..a10a2b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -11,27 +11,27 @@ namespace CodeBeam.UltimateAuth.Server.Issuers { - public sealed class UAuthSessionIssuer : IHttpSessionIssuer + public sealed class UAuthSessionIssuer : IHttpSessionIssuer { + private readonly ISessionStore _sessionStore; private readonly IOpaqueTokenGenerator _opaqueGenerator; - private readonly ISessionStoreFactory _storeFactory; private readonly UAuthServerOptions _options; private readonly IUAuthCookieManager _cookieManager; - public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options, IUAuthCookieManager cookieManager) + public UAuthSessionIssuer(ISessionStore sessionStore, IOpaqueTokenGenerator opaqueGenerator, IOptions options, IUAuthCookieManager cookieManager) { + _sessionStore = sessionStore; _opaqueGenerator = opaqueGenerator; - _storeFactory = storeFactory; _options = options.Value; _cookieManager = cookieManager; } - public Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { return IssueLoginInternalAsync(httpContext: null, context, ct); } - public Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) + public Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) { if (httpContext is null) throw new ArgumentNullException(nameof(httpContext)); @@ -39,7 +39,7 @@ public Task> IssueLoginSessionAsync(HttpContext httpConte return IssueLoginInternalAsync(httpContext, context, ct); } - private async Task> IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default) + private async Task IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (_options.Mode == UAuthMode.PureJwt) @@ -49,6 +49,8 @@ private async Task> IssueLoginInternalAsync(HttpContext? var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId)) + throw new InvalidCastException("Can't create opaque id."); var expiresAt = now.Add(_options.Session.Lifetime); @@ -59,76 +61,46 @@ private async Task> IssueLoginInternalAsync(HttpContext? expiresAt = absoluteExpiry; } - var store = _storeFactory.Create(context.TenantId); - - IssuedSession? issued = null; - - await store.ExecuteAsync(async () => + var session = UAuthSession.Create( + sessionId: sessionId, + tenantId: context.TenantId, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + claims: context.Claims, + device: context.Device, + metadata: context.Metadata + ); + + var issued = new IssuedSession { - // Root - var root = - await store.GetSessionRootAsync(context.TenantId, context.UserId) - ?? UAuthSessionRoot.Create( - context.TenantId, - context.UserId, - now); - - // Chain - var claimsSnapshot = context.Claims; - - var chain = UAuthSessionChain.Create( - ChainId.New(), - context.TenantId, - context.UserId, - root.SecurityVersion, - claimsSnapshot); - - root = root.AttachChain(chain, now); - - // Session - var session = UAuthSession.Create( - sessionId: new AuthSessionId(opaqueSessionId), - tenantId: context.TenantId, - userId: context.UserId, - chainId: chain.ChainId, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.DeviceInfo, - metadata: context.Metadata - ); - - // Persist (order is intentional) - await store.SaveSessionRootAsync(context.TenantId, root); - await store.SaveChainAsync(context.TenantId, chain); - await store.SaveSessionAsync(context.TenantId, session); - await store.SetActiveSessionIdAsync( - context.TenantId, - chain.ChainId, - session.SessionId); + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; - issued = new IssuedSession + await _sessionStore.CreateSessionAsync(issued, + new SessionStoreContext { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - }); - - //if (httpContext is not null) - //{ - // _cookieManager.Issue(httpContext, opaqueSessionId); - //} - - return issued!; + TenantId = context.TenantId, + UserKey = context.UserKey, + ChainId = context.ChainId, + IssuedAt = now, + Device = context.Device + }, + ct + ); + + return issued; } - public Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + public Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) { return RotateInternalAsync(httpContext: null, context, ct); } - public Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) + public Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) { if (httpContext is null) throw new ArgumentNullException(nameof(httpContext)); @@ -136,186 +108,71 @@ public Task> RotateSessionAsync(HttpContext httpContext, return RotateInternalAsync(httpContext, context, ct); } - private async Task> RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) + private async Task RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) { var now = context.Now; - var store = _storeFactory.Create(context.TenantId); - IssuedSession? issued = null; + var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out var newSessionId)) + throw new InvalidCastException("Can't create opaque session id."); - await store.ExecuteAsync(async () => + var expiresAt = now.Add(_options.Session.Lifetime); + if (_options.Session.MaxLifetime is not null) { - var session = await store.GetSessionAsync( - context.TenantId, - context.CurrentSessionId); - - if (session is null) - throw new SecurityException("Session not found."); - - if (session.IsRevoked || session.ExpiresAt <= now) - throw new SecurityException("Session is no longer valid."); - - var chainId = session.ChainId; - - var chain = await store.GetChainAsync( - context.TenantId, - chainId); - - if (chain is null || chain.IsRevoked) - throw new SecurityException("Session chain is invalid."); - - var opaqueSessionId = _opaqueGenerator.Generate(); - - var expiresAt = now.Add(_options.Session.Lifetime); - - if (_options.Session.MaxLifetime is not null) - { - var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); - if (absoluteExpiry < expiresAt) - expiresAt = absoluteExpiry; - } + var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + if (absoluteExpiry < expiresAt) + expiresAt = absoluteExpiry; + } - var newSession = UAuthSession.Create( - sessionId: new AuthSessionId(opaqueSessionId), - tenantId: session.TenantId, - userId: session.UserId, - chainId: chain.ChainId, + var issued = new IssuedSession + { + Session = UAuthSession.Create( + sessionId: newSessionId, + tenantId: context.TenantId, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, now: now, expiresAt: expiresAt, - claims: chain.ClaimsSnapshot, - device: session.Device, - metadata: session.Metadata - ); - - await store.SaveSessionAsync(context.TenantId, newSession); - - var rotatedChain = chain.RotateSession(newSession.SessionId); - - await store.SaveChainAsync(context.TenantId, rotatedChain); - await store.SetActiveSessionIdAsync( - context.TenantId, - chain.ChainId, - newSession.SessionId); - - await store.RevokeSessionAsync( - context.TenantId, - session.SessionId, - now); + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ), + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; - issued = new IssuedSession + await _sessionStore.RotateSessionAsync(context.CurrentSessionId, issued, + new SessionStoreContext { - Session = newSession, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - }); - - //if (httpContext is not null) - //{ - // _cookieManager.Write(httpContext, issued!.OpaqueSessionId); - //} - - return issued!; + TenantId = context.TenantId, + UserKey = context.UserKey, + IssuedAt = now, + Device = context.Device, + }, + ct + ); + + return issued; } public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var session = await store.GetSessionAsync(tenantId, sessionId); - if (session is null) - return; - - if (session.IsRevoked) - return; - - await store.RevokeSessionAsync( - tenantId, - sessionId, - at.UtcDateTime); - }); + await _sessionStore.RevokeSessionAsync(tenantId, sessionId, at, ct ); } - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var chain = await store.GetChainAsync(tenantId, chainId); - if (chain is null) - return; - - if (chain.IsRevoked) - return; - - await store.RevokeChainAsync(tenantId, chainId, at.UtcDateTime); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); - } - }); + await _sessionStore.RevokeChainAsync(tenantId, chainId, at, ct ); } - public async Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var root = await store.GetSessionRootAsync(tenantId, userId); - if (root is null) - return; - - foreach (var chain in root.Chains) - { - if (exceptChainId.HasValue && chain.ChainId.Equals(exceptChainId.Value)) - { - continue; - } - - await store.RevokeChainAsync(tenantId, chain.ChainId, at.UtcDateTime); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); - } - } - - await store.SaveSessionRootAsync(tenantId, root); - }); + await _sessionStore.RevokeAllChainsAsync(tenantId, userKey, exceptChainId, at, ct ); } - public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var root = await store.GetSessionRootAsync(tenantId, userId); - if (root is null) - return; - - var revokedRoot = root.Revoke(at); - - await store.SaveSessionRootAsync(tenantId, revokedRoot); - - foreach (var chain in root.Chains) - { - await store.RevokeChainAsync(tenantId, chain.ChainId, at); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); - } - } - }); + await _sessionStore.RevokeRootAsync(tenantId, userKey, at, ct ); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index adee773..ad22a08 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -18,13 +18,17 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IUserIdConverterResolver _converterResolver; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; + _refreshTokenStore = refreshTokenStore; + _converterResolver = converterResolver; _clock = clock; } @@ -36,8 +40,9 @@ public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuan return flow.EffectiveMode switch { + // TODO: Discuss, Hybrid token may be JWT. UAuthMode.PureOpaque or UAuthMode.Hybrid => - Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)), + Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())), UAuthMode.SemiHybrid or UAuthMode.PureJwt => @@ -48,23 +53,39 @@ UAuthMode.SemiHybrid or }; } - public Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) - return Task.FromResult(null); + return null; - var tokens = flow.OriginalOptions.Tokens; - var expires = _clock.UtcNow.Add(tokens.RefreshTokenLifetime); + var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); - return Task.FromResult(new RefreshToken + var stored = new StoredRefreshToken + { + TenantId = flow.TenantId, + TokenHash = hash, + UserKey = context.UserKey, + // TODO: Check here again + SessionId = (AuthSessionId)context.SessionId, + ChainId = context.ChainId, + IssuedAt = _clock.UtcNow, + ExpiresAt = expires + }; + + if (persistence == RefreshTokenPersistence.Persist) + { + await _refreshTokenStore.StoreAsync(flow.TenantId, stored, ct); + } + + return new RefreshToken { Token = raw, TokenHash = hash, ExpiresAt = expires - }); + }; } private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) @@ -84,14 +105,14 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken { var claims = new Dictionary { - ["sub"] = context.UserId, + ["sub"] = context.UserKey, ["tenant"] = context.TenantId }; foreach (var kv in context.Claims) claims[kv.Key] = kv.Value; - if (!string.IsNullOrWhiteSpace(context.SessionId)) + if (context.SessionId != null) claims["sid"] = context.SessionId!; if (tokens.AddJwtIdClaim) @@ -99,7 +120,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken var descriptor = new UAuthJwtTokenDescriptor { - Subject = context.UserId, + Subject = context.UserKey, Issuer = tokens.Issuer, Audience = tokens.Audience, IssuedAt = _clock.UtcNow, @@ -116,7 +137,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken Token = jwt, Type = TokenType.Jwt, ExpiresAt = expires, - SessionId = context.SessionId + SessionId = context.SessionId.ToString() }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs deleted file mode 100644 index 047ce87..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Server.Options -{ - public sealed class UAuthHubOptions - { - public string? ClientBaseAddress { get; set; } - - public HashSet AllowedClientOrigins { get; set; } = new(); - - internal UAuthHubOptions Clone() => new() - { - ClientBaseAddress = ClientBaseAddress, - AllowedClientOrigins = new HashSet(AllowedClientOrigins) - }; - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs new file mode 100644 index 0000000..9eefe56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UAuthHubServerOptions + { + public string? ClientBaseAddress { get; set; } + + public HashSet AllowedClientOrigins { get; set; } = new(); + + /// + /// Lifetime of hub flow artifacts (UI orchestration). + /// Should be short-lived. + /// + public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(2); + + public string? LoginPath { get; set; } = "/login"; + + internal UAuthHubServerOptions Clone() => new() + { + ClientBaseAddress = ClientBaseAddress, + AllowedClientOrigins = new HashSet(AllowedClientOrigins), + FlowLifetime = FlowLifetime, + LoginPath = LoginPath + }; + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index be3422f..1388620 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -90,7 +90,7 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public AuthResponseOptions AuthResponse { get; init; } = new(); - public UAuthHubOptions Hub { get; set; } = new(); + public UAuthHubServerOptions Hub { get; set; } = new(); /// /// Controls how session identifiers are resolved from incoming requests diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs new file mode 100644 index 0000000..db6542d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs @@ -0,0 +1,241 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using System.ComponentModel.DataAnnotations; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class DefaultRefreshFlowService : IRefreshFlowService + { + private readonly ISessionQueryService _sessionQueries; + private readonly ISessionTouchService _sessionRefresh; + private readonly IRefreshTokenRotationService _tokenRotation; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IUserIdConverterResolver _userIdConverterResolver; + + public DefaultRefreshFlowService( + ISessionQueryService sessionQueries, + ISessionTouchService sessionRefresh, + IRefreshTokenRotationService tokenRotation, + IRefreshTokenStore refreshTokenStore, + IUserIdConverterResolver userIdConverterResolver) + { + _sessionQueries = sessionQueries; + _sessionRefresh = sessionRefresh; + _tokenRotation = tokenRotation; + _refreshTokenStore = refreshTokenStore; + _userIdConverterResolver = userIdConverterResolver; + } + + public async Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default) + { + return flow.EffectiveMode switch + { + UAuthMode.PureOpaque => + await HandleSessionOnlyAsync(flow, request, ct), + + UAuthMode.PureJwt => + await HandleTokenOnlyAsync(flow, request, ct), + + UAuthMode.Hybrid => + await HandleHybridAsync(flow, request, ct), + + UAuthMode.SemiHybrid => + await HandleSemiHybridAsync(flow, request, ct), + + _ => RefreshFlowResult.ReauthRequired() + }; + } + + private async Task HandleSessionOnlyAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + var validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var touchPolicy = new SessionTouchPolicy + { + TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + }; + + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, request.Now, ct); + + if (!refresh.IsSuccess || refresh.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + return RefreshFlowResult.Success(refresh.DidTouch ? RefreshOutcome.Touched : RefreshOutcome.NoOp, refresh.SessionId); + } + + private async Task HandleTokenOnlyAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + //if (rotation.Result.RefreshToken is not null) + //{ + // var converter = _userIdConverterResolver.GetConverter(); + + // await _refreshTokenStore.StoreAsync( + // flow.TenantId, + // new StoredRefreshToken + // { + // TokenHash = rotation.Result.RefreshToken.TokenHash, + // UserId = rotation.UserId!, + // SessionId = rotation.SessionId!.Value, + // ChainId = rotation.ChainId, + // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, + // IssuedAt = request.Now + // }, + // ct); + //} + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + private async Task HandleHybridAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device, + ExpectedSessionId = request.SessionId.Value + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + var touchPolicy = new SessionTouchPolicy + { + TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + }; + + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, request.Now, ct); + + if (!refresh.IsSuccess || refresh.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + sessionId: refresh.SessionId, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + private async Task HandleSemiHybridAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device, + ExpectedSessionId = request.SessionId.Value + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + // ❗ NO SESSION TOUCH HERE + // Session lifetime is fixed in SemiHybrid + + //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + sessionId: request.SessionId.Value, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + //private async Task StoreRefreshTokenAsync(AuthFlowContext flow, RefreshTokenRotationExecution rotation, DateTimeOffset now, CancellationToken ct) + //{ + // if (rotation.Result.RefreshToken is null) + // return; + + // await _refreshTokenStore.StoreAsync( + // flow.TenantId, + // new StoredRefreshToken + // { + // TokenHash = rotation.Result.RefreshToken.TokenHash, + // UserId = rotation.UserId!, + // SessionId = rotation.SessionId!.Value, + // ChainId = rotation.ChainId, + // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, + // IssuedAt = now + // }, + // ct); + //} + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs new file mode 100644 index 0000000..663bb9b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + public interface IRefreshFlowService + { + Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs index 32bf604..6bb844b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs @@ -3,8 +3,8 @@ namespace CodeBeam.UltimateAuth.Server.Services { - public interface IRefreshTokenRotationService + public interface IRefreshTokenRotationService { - Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); + Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs index 2888ae2..a0f00e8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs @@ -11,6 +11,8 @@ public interface IUAuthFlowService { Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); + Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); + Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); @@ -21,14 +23,6 @@ public interface IUAuthFlowService Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default); - Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); - - Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default); - - Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default); - - Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs deleted file mode 100644 index c21ffa6..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - /// - /// Issues, refreshes and validates access and refresh tokens. - /// Stateless or hybrid depending on auth mode. - /// - public interface IUAuthTokenService - { - /// - /// Issues access (and optionally refresh) tokens - /// for a validated session. - /// - Task CreateTokensAsync(AuthFlowContext flow, TokenIssueContext context, CancellationToken cancellationToken = default); - - /// - /// Refreshes tokens using a refresh token. - /// - Task RefreshAsync(AuthFlowContext flow, TokenRefreshContext context, CancellationToken cancellationToken = default); - - /// - /// Validates JWT. - /// - Task> ValidateJwtAsync(string token, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 6435f9a..e61298f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,22 +1,20 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; +using System; namespace CodeBeam.UltimateAuth.Server.Services; -public sealed class RefreshTokenRotationService : IRefreshTokenRotationService +public sealed class RefreshTokenRotationService : IRefreshTokenRotationService { - private readonly IRefreshTokenValidator _validator; - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenValidator _validator; + private readonly IRefreshTokenStore _store; private readonly ITokenIssuer _tokenIssuer; private readonly IClock _clock; - public RefreshTokenRotationService( - IRefreshTokenValidator validator, - IRefreshTokenStore store, - ITokenIssuer tokenIssuer, - IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStore store, ITokenIssuer tokenIssuer, IClock clock) { _validator = validator; _store = store; @@ -24,49 +22,85 @@ public RefreshTokenRotationService( _clock = clock; } - public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) + // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. + public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) { - var now = context.Now; var validation = await _validator.ValidateAsync( - flow.TenantId, - context.RefreshToken, - now, + new RefreshTokenValidationContext + { + TenantId = flow.TenantId, + RefreshToken = context.RefreshToken, + Now = context.Now, + Device = context.Device, + ExpectedSessionId = context.ExpectedSessionId + }, ct); - // ❌ Invalid if (!validation.IsValid) - return RefreshTokenRotationResult.Failed(); + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; - // 🚨 Reuse detected → nuke from orbit if (validation.IsReuseDetected) { if (validation.ChainId is not null) { - await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, now, ct); + await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, now, ct); + await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, context.Now, ct); } - return RefreshTokenRotationResult.Failed(); + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } + if (validation.UserKey is not UserKey uKey) + { + throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); + } - // ✅ Valid rotation var tokenContext = new TokenIssuanceContext { TenantId = flow.OriginalOptions.MultiTenant.Enabled ? validation.TenantId : null, - UserId = validation.UserId!.ToString()!, - SessionId = validation.SessionId!.Value + UserKey = uKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId }; var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); - var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(flow, tokenContext, ct); + var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.DoNotPersist, ct); + + if (refreshToken is null) + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; + + // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. + // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync + await _store.RevokeAsync(validation.TenantId, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); - return RefreshTokenRotationResult.Success(accessToken, refreshToken!); + var stored = new StoredRefreshToken + { + TenantId = flow.TenantId, + TokenHash = refreshToken.TokenHash, + UserKey = uKey, + SessionId = validation.SessionId.Value, + ChainId = validation.ChainId, + IssuedAt = _clock.UtcNow, + ExpiresAt = refreshToken.ExpiresAt + }; + await _store.StoreAsync(validation.TenantId, stored); + + return new RefreshTokenRotationExecution() + { + TenantId = validation.TenantId, + UserKey = validation.UserKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId, + Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) + }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index f1fcee8..833c7b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,36 +1,39 @@ -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.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; -using Microsoft.AspNetCore.Http; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthFlowService : IUAuthFlowService { + private readonly IAuthFlowContextAccessor _authFlow; private readonly IUAuthUserService _users; - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _queries; + private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; - private readonly IRefreshTokenValidator _tokenValidator; + private readonly IUserIdConverterResolver _userIdConverterResolver; + private readonly IRefreshTokenValidator _tokenValidator; public UAuthFlowService( + IAuthFlowContextAccessor authFlow, IUAuthUserService users, - ISessionOrchestrator orchestrator, - ISessionQueryService queries, + ISessionOrchestrator orchestrator, + ISessionQueryService queries, ITokenIssuer tokens, - IRefreshTokenValidator tokenValidator) + IUserIdConverterResolver userIdConverterResolver, + IRefreshTokenValidator tokenValidator) { + _authFlow = authFlow; _users = users; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; + _userIdConverterResolver = userIdConverterResolver; _tokenValidator = tokenValidator; } @@ -44,16 +47,6 @@ public Task CompleteMfaAsync(CompleteMfaRequest request, Cancellati throw new NotImplementedException(); } - public Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) { throw new NotImplementedException(); @@ -62,29 +55,26 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { var now = request.At ?? DateTimeOffset.UtcNow; - var device = request.DeviceInfo ?? DeviceInfo.Unknown; var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); if (!auth.Succeeded) return LoginResult.Failed(); - var sessionContext = new AuthenticatedSessionContext + var converter = _userIdConverterResolver.GetConverter(); + var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); + var sessionContext = new AuthenticatedSessionContext { TenantId = request.TenantId, - UserId = auth.UserId!, + UserKey = userKey, Now = now, - DeviceInfo = device, + Device = request.Device, Claims = auth.Claims, - ChainId = request.ChainId + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty // TODO: Check all SessionMetadata.Empty statements }; - var authContext = AuthContext.ForAuthenticatedUser( - request.TenantId, - AuthOperation.Login, - now, - DeviceContext.From(device)); - + var authContext = flow.ToAuthContext(now); var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); bool shouldIssueTokens = request.RequestTokens; @@ -96,13 +86,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var tokenContext = new TokenIssuanceContext { TenantId = request.TenantId, - UserId = auth.UserId!.ToString()!, + UserKey = userKey, SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, Claims = auth.Claims.AsDictionary() }; var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); + var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; } @@ -110,131 +101,105 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Success(issuedSession.Session.SessionId, tokens); } - public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { var now = request.At ?? DateTimeOffset.UtcNow; - var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke,now); - return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); - } + var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) - { - var now = request.At ?? DateTimeOffset.UtcNow; + if (!auth.Succeeded) + return LoginResult.Failed(); - if (request.CurrentSessionId is null) - throw new InvalidOperationException("CurrentSessionId must be provided for logout-all operation."); + var converter = _userIdConverterResolver.GetConverter(); + var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); + var sessionContext = new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserKey = userKey, + Now = now, + Device = request.Device, + Claims = auth.Claims, + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty + }; - var currentSessionId = request.CurrentSessionId.Value; + var authContext = flow.ToAuthContext(now); + var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + + bool shouldIssueTokens = request.RequestTokens; - var validation = await _queries.ValidateSessionAsync( - new SessionValidationContext + AuthTokens? tokens = null; + + if (shouldIssueTokens) + { + var tokenContext = new TokenIssuanceContext { TenantId = request.TenantId, - SessionId = currentSessionId, - Now = now - }, - ct); + UserKey = userKey, + SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, + Claims = auth.Claims.AsDictionary() + }; - if (!validation.IsValid || validation.Session is null) - throw new InvalidOperationException("Current session is not valid."); - var userId = validation.Session.UserId; + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - ChainId? exceptChainId = null; + var access = await _tokens.IssueAccessTokenAsync(effectiveFlow, tokenContext, ct); - if (request.ExceptCurrent) - { - exceptChainId = await _queries.ResolveChainIdAsync( - request.TenantId, - currentSessionId, - ct); + var refresh = await _tokens.IssueRefreshTokenAsync(effectiveFlow, tokenContext, RefreshTokenPersistence.Persist, ct); - if (exceptChainId is null) - throw new InvalidOperationException("Current session chain could not be resolved."); + tokens = new AuthTokens + { + AccessToken = access, + RefreshToken = refresh + }; } - var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke, now); - await _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userId, exceptChainId), ct); + return LoginResult.Success(issuedSession.Session.SessionId, tokens); } - public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { - throw new NotImplementedException(); + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; + var authContext = authFlow.ToAuthContext(now); + + return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); } - public async Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default) + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) { - var now = DateTimeOffset.UtcNow; - - // Validate refresh token (STORE is authority) - var validation = await _tokenValidator.ValidateAsync(flow.TenantId, request.RefreshToken, now, ct); - - if (!validation.IsValid) - { - if (validation.IsReuseDetected && validation.SessionId is not null) - { - var chainId = await _queries.ResolveChainIdAsync( - request.TenantId, - validation.SessionId.Value, - ct); - - if (chainId is not null) - { - var authContext = AuthContext.System( - request.TenantId, - AuthOperation.Revoke, - now); - - await _orchestrator.ExecuteAsync( - authContext, - new RevokeChainCommand(chainId.Value), - ct); - } - } - - return SessionRefreshResult.ReauthRequired(); - } + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; - var session = await _queries.GetSessionAsync(request.TenantId, validation.SessionId!.Value); + if (authFlow.Session is not SessionSecurityContext session) + throw new InvalidOperationException("LogoutAll requires an active session."); - if (session is null) - return SessionRefreshResult.ReauthRequired(); + var authContext = authFlow.ToAuthContext(now); + SessionChainId? exceptChainId = null; - var rotationContext = new SessionRotationContext + if (request.ExceptCurrent) { - TenantId = request.TenantId, - CurrentSessionId = validation.SessionId!.Value, - UserId = validation.UserId!, - Now = now - }; - - var refreshAuthContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); + exceptChainId = session.ChainId; - var issuedSession = await _orchestrator.ExecuteAsync( - refreshAuthContext, - new RotateSessionCommand(rotationContext), - ct); + if (exceptChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); + } - var tokenContext = new TokenIssuanceContext + if (authFlow.UserKey is UserKey uaKey) { - TenantId = request.TenantId, - UserId = validation.UserId!.ToString()!, - SessionId = issuedSession.Session.SessionId - }; - - var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); - - var primaryToken = PrimaryToken.FromAccessToken(accessToken); - - return SessionRefreshResult.Success(primaryToken, refreshToken); + var command = new RevokeAllChainsCommand(uaKey, exceptChainId); + await _orchestrator.ExecuteAsync(authContext, command, ct); + } + } - public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) + public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) { throw new NotImplementedException(); } - } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs index 5848519..931fd7d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -55,9 +55,9 @@ public async Task> ValidateAsync(string var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; AuthSessionId? sessionId = null; var sid = jwt.GetClaim("sid")?.Value; - if (!string.IsNullOrWhiteSpace(sid)) + if (!AuthSessionId.TryCreate(sid, out AuthSessionId ssid)) { - sessionId = new AuthSessionId(sid); + sessionId = ssid; } return TokenValidationResult.Valid( diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs new file mode 100644 index 0000000..3e05168 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs @@ -0,0 +1,90 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; + +// TODO: Add wrapper service in client project. Validate method also may add. +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthSessionManager : IUAuthSessionManager + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _sessionQueryService; + private readonly IClock _clock; + + public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService, IClock clock) + { + _authFlow = authFlow; + _orchestrator = orchestrator; + _sessionQueryService = sessionQueryService; + _clock = clock; + } + + public Task> GetChainsAsync( + string? tenantId, + UserKey userKey) + => _sessionQueryService.GetChainsByUserAsync(tenantId, userKey); + + public Task> GetSessionsAsync( + string? tenantId, + SessionChainId chainId) + => _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId); + + public Task GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + => _sessionQueryService.GetSessionAsync(tenantId, sessionId); + + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeSessionCommand(tenantId, sessionId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) + => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + public Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeAllChainsCommand(userKey, exceptChainId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeChainCommand(chainId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeRootCommand(userKey); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public async Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + if (chainId is null) + return null; + + var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); + + return sessions.FirstOrDefault(s => s.SessionId == sessionId); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs deleted file mode 100644 index 4d0e0df..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthSessionService : IUAuthSessionService - { - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _sessionQueryService; - - public UAuthSessionService(ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService) - { - _orchestrator = orchestrator; - _sessionQueryService = sessionQueryService; - } - - public Task> ValidateSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset now) - { - var context = new SessionValidationContext() - { - TenantId = tenantId, - Now = now - }; - - return _sessionQueryService.ValidateSessionAsync(context); - } - - public Task>> GetChainsAsync( - string? tenantId, - TUserId userId) - => _sessionQueryService.GetChainsByUserAsync( - tenantId, - userId); - - public Task>> GetSessionsAsync( - string? tenantId, - ChainId chainId) - => _sessionQueryService.GetSessionsByChainAsync( - tenantId, - chainId); - - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) - => _sessionQueryService.GetSessionAsync( - tenantId, - sessionId); - - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeSessionCommand(tenantId,sessionId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) - => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - - public Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeAllChainsCommand(userId, exceptChainId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeChainCommand(chainId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeRootCommand(userId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public async Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) - { - var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - - if (chainId is null) - return null; - - var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); - - return sessions.FirstOrDefault(s => s.SessionId == sessionId); - } - - public Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken ct = default) - { - var deviceContext = DeviceContext.From(context.DeviceInfo); - var authContext = AuthContext.ForAuthenticatedUser(tenantId, AuthOperation.Login, context.Now, deviceContext); - var command = new CreateLoginSessionCommand(context); - - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs deleted file mode 100644 index 544de76..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Abstactions; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthTokenService : IUAuthTokenService - { - private readonly ITokenIssuer _issuer; - private readonly IJwtValidator _validator; - private readonly IUserIdConverter _userIdConverter; - - public UAuthTokenService(ITokenIssuer issuer, IJwtValidator validator, IUserIdConverterResolver converterResolver) - { - _issuer = issuer; - _validator = validator; - _userIdConverter = converterResolver.GetConverter(); - } - - public async Task CreateTokensAsync( - AuthFlowContext flow, - TokenIssueContext context, - CancellationToken ct = default) - { - var issuerCtx = ToIssuerContext(context); - - var access = await _issuer.IssueAccessTokenAsync(flow, issuerCtx, ct); - var refresh = await _issuer.IssueRefreshTokenAsync(flow, issuerCtx, ct); - - return new AuthTokens - { - AccessToken = access, - RefreshToken = refresh - }; - } - - public async Task RefreshAsync( - AuthFlowContext flow, - TokenRefreshContext context, - CancellationToken ct = default) - { - throw new NotImplementedException("Refresh flow will be implemented after refresh-token store & validation."); - } - - public async Task> ValidateJwtAsync(string token, CancellationToken ct = default) - => await _validator.ValidateAsync(token, ct); - - private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) - { - return new TokenIssuanceContext - { - UserId = _userIdConverter.ToString(src.Session.UserId), - TenantId = src.TenantId, - SessionId = src.Session.SessionId, - Claims = src.Session.Claims.AsDictionary() - }; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs new file mode 100644 index 0000000..1c80abf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Stores; + +public sealed record AuthArtifactKey(string Value) +{ + public static AuthArtifactKey New() => new(Guid.NewGuid().ToString("N")); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs new file mode 100644 index 0000000..88dddea --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Stores; + +public interface IAuthStore +{ + Task StoreAsync(AuthArtifactKey key, AuthArtifact artifact, CancellationToken cancellationToken = default); + + Task GetAsync(AuthArtifactKey key, CancellationToken cancellationToken = default); + + /// + /// Atomically gets and removes the artifact. + /// This MUST be consume-once. + /// + Task ConsumeAsync(AuthArtifactKey key, CancellationToken cancellationToken = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs new file mode 100644 index 0000000..7b84b89 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Server.Stores; + +internal sealed class InMemoryAuthStore : IAuthStore +{ + private sealed record Entry(AuthArtifact Artifact); + + private readonly ConcurrentDictionary _store = new(); + + public Task StoreAsync(AuthArtifactKey key, AuthArtifact artifact, CancellationToken cancellationToken = default) + { + _store[key.Value] = new Entry(artifact); + return Task.CompletedTask; + } + + public Task GetAsync(AuthArtifactKey key, CancellationToken cancellationToken = default) + { + if (!_store.TryGetValue(key.Value, out var entry)) + return Task.FromResult(null); + + if (entry.Artifact.IsExpired(DateTimeOffset.UtcNow)) + { + _store.TryRemove(key.Value, out _); + return Task.FromResult(null); + } + + return Task.FromResult(entry.Artifact); + } + + public Task ConsumeAsync(AuthArtifactKey key, CancellationToken cancellationToken = default) + { + if (!_store.TryRemove(key.Value, out var entry)) + return Task.FromResult(null); + + if (entry.Artifact.IsExpired(DateTimeOffset.UtcNow)) + return Task.FromResult(null); + + return Task.FromResult(entry.Artifact); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs index fb8dd61..d1e541d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Stores /// Resolves session store kernels from DI and provides them /// to framework-level session stores. /// - public sealed class UAuthSessionStoreFactory : ISessionStoreFactory + public sealed class UAuthSessionStoreFactory : ISessionStoreKernelFactory { private readonly IServiceProvider _provider; @@ -17,16 +17,13 @@ public UAuthSessionStoreFactory(IServiceProvider provider) _provider = provider; } - public ISessionStoreKernel Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { - var kernel = _provider.GetService>(); + var kernel = _provider.GetService(); - if (kernel is null) + if (kernel is ITenantAwareSessionStore tenantAware) { - throw new InvalidOperationException( - "No ISessionStoreKernel registered. " + - "Call AddUltimateAuthServer().AddSessionStoreKernel()." - ); + tenantAware.BindTenant(tenantId); } return kernel; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs index a549909..0f090ea 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory { - internal sealed class InMemoryCredentialUser : IUser + internal sealed class InMemoryCredentialUser : IUser { - public UserId UserId { get; init; } + public UserKey UserId { get; init; } public string Username { get; init; } public string PasswordHash { get; private set; } = default!; @@ -13,10 +13,10 @@ internal sealed class InMemoryCredentialUser : IUser public bool IsActive { get; init; } = true; - IReadOnlyDictionary? IUser.Claims => null; + IReadOnlyDictionary? IUser.Claims => null; public InMemoryCredentialUser( - UserId userId, + UserKey userId, string username, string passwordHash, long securityVersion = 0, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs index 688fdb9..116896e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs @@ -7,8 +7,7 @@ internal static class InMemoryCredentialSeeder { public static IReadOnlyCollection CreateDefaultUsers(IUAuthPasswordHasher passwordHasher) { - var adminUserId = UserId.New(); - + var adminUserId = UserKey.New(); var passwordHash = passwordHasher.Hash("Password!"); var admin = new InMemoryCredentialUser( diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs index 2836606..7b730c4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs @@ -5,17 +5,17 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory { - internal sealed class InMemoryUserStore : IUAuthUserStore + internal sealed class InMemoryUserStore : IUAuthUserStore { private readonly ConcurrentDictionary _usersByUsername; - private readonly ConcurrentDictionary _usersById; + private readonly ConcurrentDictionary _usersById; public InMemoryUserStore(IEnumerable seededUsers) { _usersByUsername = new ConcurrentDictionary( StringComparer.OrdinalIgnoreCase); - _usersById = new ConcurrentDictionary(); + _usersById = new ConcurrentDictionary(); foreach (var user in seededUsers) { @@ -24,18 +24,18 @@ public InMemoryUserStore(IEnumerable seededUsers) } } - public Task?> FindByIdAsync( + public Task?> FindByIdAsync( string? tenantId, - UserId userId, + UserKey userId, CancellationToken token = default) { token.ThrowIfCancellationRequested(); _usersById.TryGetValue(userId, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); + return Task.FromResult?>(user is { IsActive: true } ? user : null); } - public Task?> FindByUsernameAsync( + public Task?> FindByUsernameAsync( string? tenantId, string username, CancellationToken ct = default) @@ -43,10 +43,10 @@ public InMemoryUserStore(IEnumerable seededUsers) ct.ThrowIfCancellationRequested(); if (!_usersByUsername.TryGetValue(username, out var user) || user.IsActive is false) - return Task.FromResult?>(null); + return Task.FromResult?>(null); // Core’daki UserRecord’u kullanıyorsun; InMemory tarafı buna map eder. - var record = new UserRecord + var record = new UserRecord { Id = user.UserId, Username = user.Username, @@ -59,10 +59,10 @@ public InMemoryUserStore(IEnumerable seededUsers) IsDeleted = false }; - return Task.FromResult?>(record); + return Task.FromResult?>(record); } - public Task?> FindByLoginAsync( + public Task?> FindByLoginAsync( string? tenantId, string login, CancellationToken token = default) @@ -70,12 +70,12 @@ public InMemoryUserStore(IEnumerable seededUsers) token.ThrowIfCancellationRequested(); _usersByUsername.TryGetValue(login, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); + return Task.FromResult?>(user is { IsActive: true } ? user : null); } public Task GetPasswordHashAsync( string? tenantId, - UserId userId, + UserKey userId, CancellationToken token = default) { token.ThrowIfCancellationRequested(); @@ -88,7 +88,7 @@ public InMemoryUserStore(IEnumerable seededUsers) public Task SetPasswordHashAsync( string? tenantId, - UserId userId, + UserKey userId, string passwordHash, CancellationToken token = default) { @@ -102,7 +102,7 @@ public Task SetPasswordHashAsync( return Task.CompletedTask; } - public Task GetSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + public Task GetSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) { return Task.FromResult( _usersById.TryGetValue(userId, out var user) @@ -110,7 +110,7 @@ public Task GetSecurityVersionAsync(string? tenantId, UserId userId, Cance : 0L); } - public Task IncrementSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + public Task IncrementSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) { if (_usersById.TryGetValue(userId, out var user)) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index b313c26..b653258 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -22,7 +22,7 @@ public static IServiceCollection AddInMemoryCredentials(this IServiceCollection return InMemoryCredentialSeeder.CreateDefaultUsers(hasher); }); - services.AddSingleton>(sp => + services.AddSingleton>(sp => { var users = sp.GetRequiredService>(); return new InMemoryUserStore(users); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs new file mode 100644 index 0000000..75245e2 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class AuthSessionIdEfConverter + { + public static AuthSessionId FromDatabase(string raw) + { + if (!AuthSessionId.TryCreate(raw, out var id)) + { + throw new InvalidOperationException( + $"Invalid AuthSessionId value in database: '{raw}'"); + } + + return id; + } + + public static string ToDatabase(AuthSessionId id) + => id.Value; + + public static AuthSessionId? FromDatabaseNullable(string? raw) + { + if (raw is null) + return null; + + if (!AuthSessionId.TryCreate(raw, out var id)) + { + throw new InvalidOperationException( + $"Invalid AuthSessionId value in database: '{raw}'"); + } + + return id; + } + + public static string? ToDatabaseNullable(AuthSessionId? id) + => id?.Value; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs deleted file mode 100644 index c998acd..0000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class EfCoreSessionActivityWriter : ISessionActivityWriter where TUserId : notnull -{ - private readonly UltimateAuthSessionDbContext _db; - - public EfCoreSessionActivityWriter(UltimateAuthSessionDbContext db) - { - _db = db; - } - - public async Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) - { - var projection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == session.SessionId && - x.TenantId == tenantId, - ct); - - if (projection is null) - return; - // TODO: Rethink architecture - var updated = session as UAuthSession - ?? throw new InvalidOperationException("EF Core ActivityWriter requires UAuthSession instance."); - - _db.Sessions.Update(updated.ToProjection()); - await _db.SaveChangesAsync(ct); - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs index 4a69c9c..59a5292 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs @@ -6,67 +6,75 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class EfCoreSessionStore : ISessionStore +internal sealed class EfCoreSessionStore : ISessionStore { - private readonly EfCoreSessionStoreKernel _kernel; - private readonly UltimateAuthSessionDbContext _db; + private readonly EfCoreSessionStoreKernel _kernel; + private readonly UltimateAuthSessionDbContext _db; - public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) + public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) { _kernel = kernel; _db = db; } - public async Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public async Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { var projection = await _db.Sessions .AsNoTracking() - .Where(x => - x.SessionId == sessionId && - x.TenantId == tenantId) - .SingleOrDefaultAsync(ct); - - if (projection is null) - return null; + .SingleOrDefaultAsync( + x => x.SessionId == sessionId && + x.TenantId == tenantId, + ct); - return projection.ToDomain(); + return projection?.ToDomain(); } - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { await _kernel.ExecuteAsync(async ct => { var now = ctx.IssuedAt; - var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.TenantId == ctx.TenantId && x.UserId!.Equals(ctx.UserId), ct); + var rootProjection = await _db.Roots + .SingleOrDefaultAsync( + x => x.TenantId == ctx.TenantId && + x.UserKey == ctx.UserKey, + ct); - ISessionRoot root; + ISessionRoot root; if (rootProjection is null) { - root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserId, now); + root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); _db.Roots.Add(root.ToProjection()); } else { - var chains = await LoadChainsAsync(ctx, ct); - root = rootProjection.ToDomain(chains); - } + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootProjection.RootId) + .ToListAsync(ct); + root = rootProjection.ToDomain( + chainProjections.Select(c => c.ToDomain()).ToList()); + } - ISessionChain chain; + ISessionChain chain; if (ctx.ChainId is not null) { - var chainProjection = await _db.Chains.SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + var chainProjection = await _db.Chains + .SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + chain = chainProjection.ToDomain(); } else { - chain = UAuthSessionChain.Create( - ChainId.New(), + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, ctx.TenantId, - ctx.UserId, + ctx.UserKey, root.SecurityVersion, ClaimsSnapshot.Empty); @@ -74,43 +82,44 @@ await _kernel.ExecuteAsync(async ct => root = root.AttachChain(chain, now); } - var session = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: SessionMetadata.Empty - ); + var issuedSession = (UAuthSession)issued.Session; + + if (!issuedSession.ChainId.IsUnassigned) + throw new InvalidOperationException("Issued session already has chain."); + + var session = issuedSession.WithChain(chain.ChainId); _db.Sessions.Add(session.ToProjection()); + var updatedChain = chain.AttachSession(session.SessionId); _db.Chains.Update(updatedChain.ToProjection()); - }, ct); } - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { await _kernel.ExecuteAsync(async ct => { var now = ctx.IssuedAt; - var oldSessionProjection = await _db.Sessions.SingleOrDefaultAsync( - x => x.SessionId == currentSessionId && - x.TenantId == ctx.TenantId, - ct); + var oldProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == currentSessionId && + x.TenantId == ctx.TenantId, + ct); - if (oldSessionProjection is null) + if (oldProjection is null) throw new SecurityException("Session not found."); - var oldSession = oldSessionProjection.ToDomain(); + var oldSession = oldProjection.ToDomain(); - var chainProjection = await _db.Chains.SingleOrDefaultAsync( - x => x.ChainId == oldSession.ChainId, ct); + if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) + throw new SecurityException("Session is no longer valid."); + + var chainProjection = await _db.Chains + .SingleOrDefaultAsync( + x => x.ChainId == oldSession.ChainId, + ct); if (chainProjection is null) throw new SecurityException("Chain not found."); @@ -118,152 +127,174 @@ await _kernel.ExecuteAsync(async ct => var chain = chainProjection.ToDomain(); if (chain.IsRevoked) - throw new SecurityException("Session chain is revoked."); - - var newSession = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: SessionMetadata.Empty - ); + throw new SecurityException("Chain is revoked."); - _db.Sessions.Add(newSession.ToProjection()); + var newSession = ((UAuthSession)issued.Session) + .WithChain(chain.ChainId); - var updatedChain = chain.RotateSession(newSession.SessionId); - _db.Chains.Update(updatedChain.ToProjection()); + _db.Sessions.Add(newSession.ToProjection()); - var revokedOldSession = oldSession.Revoke(now); - _db.Sessions.Update(revokedOldSession.ToProjection()); + var rotatedChain = chain.RotateSession(newSession.SessionId); + _db.Chains.Update(rotatedChain.ToProjection()); + var revokedOld = oldSession.Revoke(now); + _db.Sessions.Update(revokedOld.ToProjection()); }, ct); } - public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) { + var touched = false; + await _kernel.ExecuteAsync(async ct => { - var rootProjection = await _db.Roots - .SingleOrDefaultAsync( - x => x.TenantId == tenantId && - x.UserId!.Equals(userId), - ct); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - if (rootProjection is null) + if (projection is null) return; - var chainProjections = await _db.Chains + var session = projection.ToDomain(); + + if (session.IsRevoked) + return; + + if (mode == SessionTouchMode.IfNeeded && at - session.LastSeenAt < TimeSpan.FromMinutes(1)) + return; + + var updated = session.Touch(at); + _db.Sessions.Update(updated.ToProjection()); + + touched = true; + }, ct); + + return touched; + } + + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => + { + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId && x.TenantId == tenantId, ct); + + if (projection is null) + return; + + var session = projection.ToDomain(); + + if (session.IsRevoked) + return; + + _db.Sessions.Update(session.Revoke(at).ToProjection()); + }, ct); + + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var chains = await _db.Chains .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey == userKey) .ToListAsync(ct); - foreach (var chainProjection in chainProjections) + foreach (var chainProjection in chains) { - var chain = chainProjection.ToDomain(); - - if (chain.IsRevoked) + if (exceptChainId.HasValue && + chainProjection.ChainId == exceptChainId.Value) continue; - var revokedChain = chain.Revoke(at); - _db.Chains.Update(revokedChain.ToProjection()); + var chain = chainProjection.ToDomain(); + + if (!chain.IsRevoked) + _db.Chains.Update(chain.Revoke(at).ToProjection()); if (chain.ActiveSessionId is not null) { - var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == chain.ActiveSessionId && - x.TenantId == tenantId, - ct); + var sessionProjection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); if (sessionProjection is not null) { var session = sessionProjection.ToDomain(); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); } } } - - var root = rootProjection.ToDomain(chainProjections - .Select(c => c.ToDomain()) - .ToList()); - - var revokedRoot = root.Revoke(at); - _db.Roots.Update(revokedRoot.ToProjection()); - }, ct); } - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => { - var chainProjection = await _db.Chains + var projection = await _db.Chains .SingleOrDefaultAsync( x => x.ChainId == chainId && x.TenantId == tenantId, ct); - if (chainProjection is null) + if (projection is null) return; - var chain = chainProjection.ToDomain(); + var chain = projection.ToDomain(); if (chain.IsRevoked) return; - var revokedChain = chain.Revoke(at); - _db.Chains.Update(revokedChain.ToProjection()); + _db.Chains.Update(chain.Revoke(at).ToProjection()); if (chain.ActiveSessionId is not null) { var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == chain.ActiveSessionId && - x.TenantId == tenantId, - ct); + .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); if (sessionProjection is not null) { var session = sessionProjection.ToDomain(); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); } } - }, ct); - } - public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => { - var sessionProjection = await _db.Sessions + var rootProjection = await _db.Roots .SingleOrDefaultAsync( - x => x.SessionId == sessionId && - x.TenantId == tenantId, + x => x.TenantId == tenantId && + x.UserKey == userKey, ct); - if (sessionProjection is null) + if (rootProjection is null) return; - var session = sessionProjection.ToDomain(); + var chainProjections = await _db.Chains + .Where(x => x.RootId == rootProjection.RootId) + .ToListAsync(ct); - if (session.IsRevoked) - return; + foreach (var chainProjection in chainProjections) + { + var chain = chainProjection.ToDomain(); + _db.Chains.Update(chain.Revoke(at).ToProjection()); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } + } + } + + var root = rootProjection.ToDomain(chainProjections.Select(c => c.ToDomain()).ToList()); + + _db.Roots.Update(root.Revoke(at).ToProjection()); }, ct); - } - public async Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { var projections = await _db.Sessions .AsNoTracking() @@ -275,19 +306,19 @@ public async Task>> GetSessionsByChainAsync(stri return projections.Select(x => x.ToDomain()).ToList(); } - public async Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public async Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { var projections = await _db.Chains .AsNoTracking() .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey.Equals(userKey)) .ToListAsync(ct); return projections.Select(x => x.ToDomain()).ToList(); } - public async Task?> GetChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task GetChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { var projection = await _db.Chains .AsNoTracking() @@ -299,18 +330,18 @@ public async Task>> GetChainsByUserAsync(st return projection?.ToDomain(); } - public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { return await _db.Sessions .AsNoTracking() .Where(x => x.SessionId == sessionId && x.TenantId == tenantId) - .Select(x => (ChainId?)x.ChainId) + .Select(x => (SessionChainId?)x.ChainId) .SingleOrDefaultAsync(ct); } - public async Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task GetActiveSessionIdAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { return await _db.Chains .AsNoTracking() @@ -321,13 +352,13 @@ public async Task>> GetChainsByUserAsync(st .SingleOrDefaultAsync(ct); } - public async Task?> GetSessionRootAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public async Task GetSessionRootAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { var rootProjection = await _db.Roots .AsNoTracking() .SingleOrDefaultAsync( x => x.TenantId == tenantId && - x.UserId!.Equals(userId), + x.UserKey!.Equals(userKey), ct); if (rootProjection is null) @@ -337,24 +368,9 @@ public async Task>> GetChainsByUserAsync(st .AsNoTracking() .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey!.Equals(userKey)) .ToListAsync(ct); return rootProjection.ToDomain(chainProjections.Select(x => x.ToDomain()).ToList()); } - - - private async Task>> LoadChainsAsync(SessionStoreContext ctx, CancellationToken ct) - { - var chainProjections = await _db.Chains - .AsNoTracking() - .Where(x => - x.TenantId == ctx.TenantId && - x.UserId!.Equals(ctx.UserId)) - .ToListAsync(ct); - - return chainProjections - .Select(x => x.ToDomain()) - .ToList(); - } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs index 5982b75..05b5121 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs @@ -1,18 +1,20 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; using System.Data; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class EfCoreSessionStoreKernel + internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel { - private readonly UltimateAuthSessionDbContext _db; + private readonly UltimateAuthSessionDbContext _db; - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) + public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) { _db = db; } - public async Task ExecuteAsync(Func action, CancellationToken ct) + public async Task ExecuteAsync(Func action, CancellationToken ct = default) { var strategy = _db.Database.CreateExecutionStrategy(); @@ -43,5 +45,202 @@ await strategy.ExecuteAsync(async () => }); } + public async Task GetSessionAsync(AuthSessionId sessionId) + { + var projection = await _db.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(ISession session) + { + var projection = session.ToProjection(); + + var exists = await _db.Sessions + .AnyAsync(x => x.SessionId == session.SessionId); + + if (exists) + _db.Sessions.Update(projection); + else + _db.Sessions.Add(projection); + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + { + var projection = await _db.Sessions + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + if (projection is null) + return; + + var session = projection.ToDomain(); + if (session.IsRevoked) + return; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + public async Task GetChainAsync(SessionChainId chainId) + { + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + return projection?.ToDomain(); + } + + public async Task SaveChainAsync(ISessionChain chain) + { + var projection = chain.ToProjection(); + + var exists = await _db.Chains + .AnyAsync(x => x.ChainId == chain.ChainId); + + if (exists) + _db.Chains.Update(projection); + else + _db.Chains.Add(projection); + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + var chain = projection.ToDomain(); + if (chain.IsRevoked) + return; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId) + { + return await _db.Chains + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + projection.ActiveSessionId = sessionId; + _db.Chains.Update(projection); + } + + public async Task GetSessionRootByUserAsync(UserKey userKey) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + public async Task SaveSessionRootAsync(ISessionRoot root) + { + var projection = root.ToProjection(); + + var exists = await _db.Roots + .AnyAsync(x => x.RootId == root.RootId); + + if (exists) + _db.Roots.Update(projection); + else + _db.Roots.Add(projection); + } + + public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + { + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (projection is null) + return; + + var root = projection.ToDomain(); + _db.Roots.Update(root.Revoke(at).ToProjection()); + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) + { + return await _db.Sessions + .AsNoTracking() + .Where(x => x.SessionId == sessionId) + .Select(x => (SessionChainId?)x.ChainId) + .SingleOrDefaultAsync(); + } + + public async Task> GetChainsByUserAsync(UserKey userKey) + { + var projections = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task> GetSessionsByChainAsync(SessionChainId chainId) + { + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetSessionRootByIdAsync(SessionRootId rootId) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.RootId == rootId); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + + public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) + { + var projections = await _db.Sessions + .Where(x => x.ExpiresAt <= at && !x.IsRevoked) + .ToListAsync(); + + foreach (var p in projections) + { + var revoked = p.ToDomain().Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + } + } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs new file mode 100644 index 0000000..8677017 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory + { + private readonly IServiceProvider _sp; + + public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + { + _sp = sp; + } + + public ISessionStoreKernel Create(string? tenantId) + { + return _sp.GetRequiredService(); + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index fac4658..d0d5a59 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -2,14 +2,15 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionChainProjection + internal sealed class SessionChainProjection { public long Id { get; set; } - public ChainId ChainId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; + public SessionRootId RootId { get; } public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } public int RotationCount { get; set; } public long SecurityVersionAtCreation { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index 6698c41..e223049 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -2,15 +2,15 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionProjection + internal sealed class SessionProjection { public long Id { get; set; } // EF internal PK public AuthSessionId SessionId { get; set; } = default!; - public ChainId ChainId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } @@ -21,7 +21,7 @@ internal sealed class SessionProjection public long SecurityVersionAtCreation { get; set; } - public DeviceInfo Device { get; set; } = DeviceInfo.Empty; + public DeviceContext Device { get; set; } public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index bc4dc81..c49aae0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -1,11 +1,13 @@ -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionRootProjection + internal sealed class SessionRootProjection { public long Id { get; set; } - + public SessionRootId RootId { get; set; } public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 62ab304..b1b1c32 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -4,12 +4,13 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionChainProjectionMapper { - public static ISessionChain ToDomain(this SessionChainProjection p) + public static ISessionChain ToDomain(this SessionChainProjection p) { - return UAuthSessionChain.FromProjection( + return UAuthSessionChain.FromProjection( p.ChainId, + p.RootId, p.TenantId, - p.UserId, + p.UserKey, p.RotationCount, p.SecurityVersionAtCreation, p.ClaimsSnapshot, @@ -19,13 +20,13 @@ public static ISessionChain ToDomain(this SessionChainProjecti ); } - public static SessionChainProjection ToProjection(this ISessionChain chain) + public static SessionChainProjection ToProjection(this ISessionChain chain) { - return new SessionChainProjection + return new SessionChainProjection { ChainId = chain.ChainId, TenantId = chain.TenantId, - UserId = chain.UserId, + UserKey = chain.UserKey, RotationCount = chain.RotationCount, SecurityVersionAtCreation = chain.SecurityVersionAtCreation, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 37cc755..ed2a371 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -4,16 +4,12 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionProjectionMapper { - public static ISession ToDomain(this SessionProjection p) + public static ISession ToDomain(this SessionProjection p) { - var device = p.Device == DeviceInfo.Empty - ? DeviceInfo.Unknown - : p.Device; - - return UAuthSession.FromProjection( + return UAuthSession.FromProjection( p.SessionId, p.TenantId, - p.UserId, + p.UserKey, p.ChainId, p.CreatedAt, p.ExpiresAt, @@ -21,19 +17,19 @@ public static ISession ToDomain(this SessionProjection ToProjection(this ISession s) + public static SessionProjection ToProjection(this ISession s) { - return new SessionProjection + return new SessionProjection { SessionId = s.SessionId, TenantId = s.TenantId, - UserId = s.UserId, + UserKey = s.UserKey, ChainId = s.ChainId, CreatedAt = s.CreatedAt, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index d722485..e8f0f95 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -4,25 +4,27 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionRootProjectionMapper { - public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList> chains) + public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) { - return UAuthSessionRoot.FromProjection( + return UAuthSessionRoot.FromProjection( + root.RootId, root.TenantId, - root.UserId, + root.UserKey, root.IsRevoked, root.RevokedAt, root.SecurityVersion, - chains, + chains ?? Array.Empty(), root.LastUpdatedAt ); } - public static SessionRootProjection ToProjection(this ISessionRoot root) + public static SessionRootProjection ToProjection(this ISessionRoot root) { - return new SessionRootProjection + return new SessionRootProjection { + RootId = root.RootId, TenantId = root.TenantId, - UserId = root.UserId, + UserKey = root.UserKey, IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs index 3453058..545bce4 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs @@ -3,12 +3,16 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { + internal sealed class AuthSessionIdConverter : ValueConverter + { + public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) + { + } + } + internal sealed class NullableAuthSessionIdConverter : ValueConverter { - public NullableAuthSessionIdConverter() - : base( - v => v == null ? null : v.Value, - v => v == null ? null : AuthSessionId.From(v)) + public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) { } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs index 54b9dbd..2b786a5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -8,10 +8,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull { - services.AddDbContext>(configureDb); - services.AddScoped>(); - services.AddScoped, EfCoreSessionStore>(); - services.AddScoped, EfCoreSessionActivityWriter>(); + services.AddDbContext(configureDb); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs index fa94bd6..6e1b3f5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs @@ -3,11 +3,11 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class UltimateAuthSessionDbContext : DbContext + internal sealed class UltimateAuthSessionDbContext : DbContext { - public DbSet> Roots => Set>(); - public DbSet> Chains => Set>(); - public DbSet> Sessions => Set>(); + public DbSet Roots => Set(); + public DbSet Chains => Set(); + public DbSet Sessions => Set(); public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) { @@ -16,17 +16,17 @@ public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder b) { - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion) .IsRowVersion(); - e.Property(x => x.UserId) + e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => new { x.TenantId, x.UserId }) + e.HasIndex(x => new { x.TenantId, x.UserKey }) .IsUnique(); e.Property(x => x.SecurityVersion) @@ -34,25 +34,33 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.LastUpdatedAt) .IsRequired(); + + e.Property(x => x.RootId) + .HasConversion( + v => v.Value, + v => SessionRootId.From(v)) + .IsRequired(); + + e.HasIndex(x => new { x.TenantId, x.RootId }); + }); - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion) .IsRowVersion(); - e.Property(x => x.UserId) + e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => x.ChainId) - .IsUnique(); + e.HasIndex(x => new { x.TenantId, x.ChainId }).IsUnique(); e.Property(x => x.ChainId) .HasConversion( v => v.Value, - v => ChainId.From(v)) + v => SessionChainId.From(v)) .IsRequired(); e.Property(x => x.ActiveSessionId) @@ -66,28 +74,26 @@ protected override void OnModelCreating(ModelBuilder b) .IsRequired(); }); - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion).IsRowVersion(); - e.HasIndex(x => x.SessionId).IsUnique(); - e.HasIndex(x => new { x.ChainId, x.RevokedAt }); + e.HasIndex(x => new { x.TenantId, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.TenantId, x.ChainId, x.RevokedAt }); e.Property(x => x.SessionId) - .HasConversion( - v => v.Value, - v => AuthSessionId.From(v)) + .HasConversion(new AuthSessionIdConverter()) .IsRequired(); e.Property(x => x.ChainId) .HasConversion( v => v.Value, - v => ChainId.From(v)) + v => SessionChainId.From(v)) .IsRequired(); e.Property(x => x.Device) - .HasConversion(new JsonValueConverter()) + .HasConversion(new JsonValueConverter()) .IsRequired(); e.Property(x => x.Claims) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj index 10d2902..9351133 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -10,6 +10,7 @@ + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs deleted file mode 100644 index 3a4639d..0000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Concurrent; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel -{ - private readonly SemaphoreSlim _tx = new(1, 1); - - private readonly ConcurrentDictionary> _sessions = new(); - private readonly ConcurrentDictionary> _chains = new(); - private readonly ConcurrentDictionary> _roots = new(); - private readonly ConcurrentDictionary _activeSessions = new(); - - public async Task ExecuteAsync(Func action) - { - await _tx.WaitAsync(); - try - { - await action(); - } - finally - { - _tx.Release(); - } - } - - public Task?> GetSessionAsync(string? _, AuthSessionId sessionId) - => Task.FromResult( - _sessions.TryGetValue(sessionId, out var s) ? s : null); - - public Task SaveSessionAsync(string? _, ISession session) - { - _sessions[session.SessionId] = session; - return Task.CompletedTask; - } - - public Task RevokeSessionAsync(string? _, AuthSessionId sessionId, DateTimeOffset at) - { - if (_sessions.TryGetValue(sessionId, out var session)) - { - _sessions[sessionId] = session.Revoke(at); - } - return Task.CompletedTask; - } - - public Task>> GetSessionsByChainAsync(string? _, ChainId chainId) - { - var result = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); - - return Task.FromResult>>(result); - } - - public Task?> GetChainAsync(string? _, ChainId chainId) - => Task.FromResult( - _chains.TryGetValue(chainId, out var c) ? c : null); - - public Task SaveChainAsync(string? _, ISessionChain chain) - { - _chains[chain.ChainId] = chain; - return Task.CompletedTask; - } - - public Task RevokeChainAsync(string? _, ChainId chainId, DateTimeOffset at) - { - if (_chains.TryGetValue(chainId, out var chain)) - { - _chains[chainId] = chain.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetActiveSessionIdAsync(string? _, ChainId chainId) - { - return Task.FromResult( - _activeSessions.TryGetValue(chainId, out var id) - ? id - : null - ); - } - - public Task SetActiveSessionIdAsync(string? _, ChainId chainId, AuthSessionId sessionId) - { - _activeSessions[chainId] = sessionId; - return Task.CompletedTask; - } - - public Task>> GetChainsByUserAsync(string? _, TUserId userId) - { - if (!_roots.TryGetValue(userId, out var root)) - return Task.FromResult>>(Array.Empty>()); - - return Task.FromResult>>(root.Chains.ToList()); - } - - public Task?> GetSessionRootAsync(string? _, TUserId userId) - => Task.FromResult(_roots.TryGetValue(userId, out var r) ? r : null); - - public Task SaveSessionRootAsync(string? _, ISessionRoot root) - { - _roots[root.UserId] = root; - return Task.CompletedTask; - } - - public Task RevokeSessionRootAsync(string? _, TUserId userId, DateTimeOffset at) - { - if (_roots.TryGetValue(userId, out var root)) - { - _roots[userId] = root.Revoke(at); - } - return Task.CompletedTask; - } - - public Task DeleteExpiredSessionsAsync(string? _, DateTimeOffset now) - { - foreach (var kvp in _sessions) - { - var session = kvp.Value; - - if (session.ExpiresAt <= now) - { - _sessions.TryGetValue(kvp.Key, out var existing); - - if (existing is not null) - { - _sessions.TryUpdate( - kvp.Key, - existing.Revoke(now), - existing); - } - } - } - - return Task.CompletedTask; - } - - public Task GetChainIdBySessionAsync(string? _, AuthSessionId sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(session.ChainId); - - return Task.FromResult(null); - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs deleted file mode 100644 index cd7fe8b..0000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory -{ - internal sealed class InMemorySessionActivityWriter : ISessionActivityWriter where TUserId : notnull - { - private readonly ISessionStoreFactory _factory; - - public InMemorySessionActivityWriter(ISessionStoreFactory factory) - { - _factory = factory; - } - - public Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) - { - var kernel = _factory.Create(tenantId); - return kernel.SaveSessionAsync(tenantId, session); - } - } - -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index b00b646..ed5f958 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -1,170 +1,154 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; using System.Security; -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -public sealed class InMemorySessionStore : ISessionStore +public sealed class InMemorySessionStore : ISessionStore { - private readonly ISessionStoreFactory _factory; + private readonly ISessionStoreKernelFactory _factory; + private readonly UAuthServerOptions _options; - public InMemorySessionStore(ISessionStoreFactory factory) + public InMemorySessionStore(ISessionStoreKernelFactory factory, IOptions options) { _factory = factory; + _options = options.Value; } - private ISessionStoreKernel Kernel(string? tenantId) - => _factory.Create(tenantId); + private ISessionStoreKernel Kernel(string? tenantId) + => _factory.Create(tenantId); - public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - => Kernel(tenantId).GetSessionAsync(tenantId, sessionId); + public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + => Kernel(tenantId).GetSessionAsync(sessionId); - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { var now = ctx.IssuedAt; - // Root - var root = - await k.GetSessionRootAsync(ctx.TenantId, ctx.UserId) - ?? UAuthSessionRoot.Create( - ctx.TenantId, - ctx.UserId, - now); - - // Chain - ISessionChain chain; + var root = await k.GetSessionRootByUserAsync(ctx.UserKey) ?? UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); + ISessionChain chain; if (ctx.ChainId is not null) { - chain = await k.GetChainAsync(ctx.TenantId, ctx.ChainId.Value) - ?? throw new InvalidOperationException("Chain not found."); + chain = await k.GetChainAsync(ctx.ChainId.Value) ?? throw new InvalidOperationException("Chain not found."); } else { - chain = UAuthSessionChain.Create( - ChainId.New(), + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, ctx.TenantId, - ctx.UserId, + ctx.UserKey, root.SecurityVersion, ClaimsSnapshot.Empty); root = root.AttachChain(chain, now); } - // Session - var session = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: null); - - await k.SaveSessionRootAsync(ctx.TenantId, root); - await k.SaveChainAsync(ctx.TenantId, chain); - await k.SaveSessionAsync(ctx.TenantId, session); - await k.SetActiveSessionIdAsync( - ctx.TenantId, - chain.ChainId, - session.SessionId); - }); + var session = issued.Session; + + if (!session.ChainId.IsUnassigned) + { + throw new InvalidOperationException("Issued session already has a chain assigned."); + } + + session = session.WithChain(chain.ChainId); + + // Persist (order intentional) + await k.SaveSessionRootAsync(root); + await k.SaveChainAsync(chain); + await k.SaveSessionAsync(session); + await k.SetActiveSessionIdAsync(chain.ChainId, session.SessionId); + }, ct); } - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { var now = ctx.IssuedAt; - var old = await k.GetSessionAsync(ctx.TenantId, currentSessionId) + var old = await k.GetSessionAsync(currentSessionId) ?? throw new SecurityException("Session not found."); - var chain = await k.GetChainAsync(ctx.TenantId, old.ChainId) + if (old.IsRevoked || old.ExpiresAt <= now) + throw new SecurityException("Session is no longer valid."); + + var chain = await k.GetChainAsync(old.ChainId) ?? throw new SecurityException("Chain not found."); - var newSession = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: null); - - await k.SaveSessionAsync(ctx.TenantId, newSession); - await k.SetActiveSessionIdAsync( - ctx.TenantId, - chain.ChainId, - newSession.SessionId); - - await k.RevokeSessionAsync( - ctx.TenantId, - currentSessionId, - now); - }); - } + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked."); - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - => Kernel(tenantId).RevokeSessionAsync(tenantId, sessionId, at); + var newSession = ((UAuthSession)issued.Session).WithChain(chain.ChainId); - public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + await k.SaveSessionAsync(newSession); + await k.SetActiveSessionIdAsync(chain.ChainId, newSession.SessionId); + await k.RevokeSessionAsync(old.SessionId, now); + }, ct); + } + + public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) { - var k = Kernel(tenantId); + var k = Kernel(null); + bool touched = false; - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { - var root = await k.GetSessionRootAsync(tenantId, userId); - if (root is null) + var session = await k.GetSessionAsync(sessionId); + if (session is null || session.IsRevoked) return; - foreach (var chain in root.Chains) + if (mode == SessionTouchMode.IfNeeded) { - await k.RevokeChainAsync(tenantId, chain.ChainId, at); - - if (chain.ActiveSessionId is not null) - { - await k.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); - } + var elapsed = at - session.LastSeenAt; + if (elapsed < _options.Session.TouchInterval) + return; } - await k.RevokeSessionRootAsync(tenantId, userId, at); - }); + var updated = session.Touch(at); + await k.SaveSessionAsync(updated); + + touched = true; + }, ct); + + return touched; } - public async Task RevokeChainAsync(string? tenantId,ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeSessionAsync(sessionId, at); + + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { - var chain = await k.GetChainAsync(tenantId, chainId); - if (chain is null) - return; + var chains = await k.GetChainsByUserAsync(userKey); - await k.RevokeChainAsync(tenantId, chainId, at); - - if (chain.ActiveSessionId is not null) + foreach (var chain in chains) { - await k.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await k.RevokeChainAsync(chain.ChainId, at); + + if (chain.ActiveSessionId is not null) + await k.RevokeSessionAsync(chain.ActiveSessionId.Value, at); } - }); + }, ct); } + + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeChainAsync(chainId, at); + + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeSessionRootAsync(userKey, at); } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index 11285ed..157bfd8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -3,19 +3,22 @@ namespace CodeBeam.UltimateAuth.Sessions.InMemory { - public sealed class InMemorySessionStoreFactory : ISessionStoreFactory + public sealed class InMemorySessionStoreFactory : ISessionStoreKernelFactory { private readonly ConcurrentDictionary _stores = new(); - public ISessionStoreKernel Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { var key = tenantId ?? "__single__"; - var store = _stores.GetOrAdd( - key, - _ => new InMemorySessionStoreKernel()); + var store = _stores.GetOrAdd(key, _ => + { + var k = new InMemorySessionStoreKernel(); + k.BindTenant(tenantId); + return k; + }); - return (ISessionStoreKernel)store; + return (ISessionStoreKernel)store; } } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs new file mode 100644 index 0000000..3aaeee4 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs @@ -0,0 +1,139 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Collections.Concurrent; + +internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel, ITenantAwareSessionStore +{ + private readonly SemaphoreSlim _tx = new(1, 1); + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary _roots = new(); + private readonly ConcurrentDictionary _activeSessions = new(); + + public string? TenantId { get; private set; } + + public void BindTenant(string? tenantId) + { + TenantId = tenantId ?? "__single__"; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task GetSessionAsync(AuthSessionId sessionId) + => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + + public Task SaveSessionAsync(ISession session) + { + _sessions[session.SessionId] = session; + return Task.CompletedTask; + } + + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + _sessions[sessionId] = session.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainAsync(SessionChainId chainId) + => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + + public Task SaveChainAsync(ISessionChain chain) + { + _chains[chain.ChainId] = chain; + return Task.CompletedTask; + } + + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + { + if (_chains.TryGetValue(chainId, out var chain)) + { + _chains[chainId] = chain.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetActiveSessionIdAsync(SessionChainId chainId) + => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); + + public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + { + _activeSessions[chainId] = sessionId; + return Task.CompletedTask; + } + + public Task GetSessionRootByUserAsync(UserKey userKey) + => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + + public Task GetSessionRootByIdAsync(SessionRootId rootId) + => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + + public Task SaveSessionRootAsync(ISessionRoot root) + { + _roots[root.UserKey] = root; + return Task.CompletedTask; + } + + public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + { + if (_roots.TryGetValue(userKey, out var root)) + { + _roots[userKey] = root.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainIdBySessionAsync(AuthSessionId sessionId) + { + if (_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(session.ChainId); + + return Task.FromResult(null); + } + + public Task> GetChainsByUserAsync(UserKey userKey) + { + if (!_roots.TryGetValue(userKey, out var root)) + return Task.FromResult>(Array.Empty()); + + return Task.FromResult>(root.Chains.ToList()); + } + + public Task> GetSessionsByChainAsync(SessionChainId chainId) + { + var result = _sessions.Values + .Where(s => s.ChainId == chainId) + .ToList(); + + return Task.FromResult>(result); + } + + public Task DeleteExpiredSessionsAsync(DateTimeOffset at) + { + foreach (var kvp in _sessions) + { + var session = kvp.Value; + + if (session.ExpiresAt <= at) + { + _sessions[kvp.Key] = session.Revoke(at); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index 70af9b5..c12a815 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -7,9 +7,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - services.AddSingleton(); - services.AddScoped(typeof(ISessionStore<>), typeof(InMemorySessionStore<>)); - services.AddScoped(typeof(ISessionActivityWriter<>), typeof(InMemorySessionActivityWriter<>)); + services.AddSingleton(); + // TODO: Discuss it to be singleton or scoped + services.AddScoped(); return services; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 0000000..ed166fc --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs index 3753ad0..05dc0c1 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -3,23 +3,16 @@ using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore { private readonly UltimateAuthTokenDbContext _db; - private readonly IUserIdConverter _converter; - public EfCoreRefreshTokenStore( - UltimateAuthTokenDbContext db, - IUserIdConverterResolver converters) + public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterResolver converters) { _db = db; - _converter = converters.GetConverter(); } - public async Task StoreAsync( - string? tenantId, - StoredRefreshToken token, - CancellationToken ct = default) + public async Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) { if (token.TenantId != tenantId) throw new InvalidOperationException("TenantId mismatch between context and token."); @@ -28,8 +21,8 @@ public async Task StoreAsync( { TenantId = tenantId, TokenHash = token.TokenHash, - UserId = _converter.ToString(token.UserId), - SessionId = token.SessionId.Value, + UserKey = token.UserKey, + SessionId = token.SessionId, ChainId = token.ChainId.Value, IssuedAt = token.IssuedAt, ExpiresAt = token.ExpiresAt @@ -38,10 +31,7 @@ public async Task StoreAsync( await _db.SaveChangesAsync(ct); } - public async Task?> FindByHashAsync( - string? tenantId, - string tokenHash, - CancellationToken ct = default) + public async Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) { var e = await _db.RefreshTokens .AsNoTracking() @@ -53,76 +43,63 @@ public async Task StoreAsync( if (e is null) return null; - return new StoredRefreshToken + return new StoredRefreshToken { TenantId = e.TenantId, TokenHash = e.TokenHash, - UserId = _converter.FromString(e.UserId), - SessionId = new AuthSessionId(e.SessionId), - ChainId = new ChainId(e.ChainId), + UserKey = e.UserKey, + SessionId = e.SessionId, + ChainId = e.ChainId, IssuedAt = e.IssuedAt, ExpiresAt = e.ExpiresAt, RevokedAt = e.RevokedAt }; } - public Task RevokeAsync( - string? tenantId, - string tokenHash, - DateTimeOffset revokedAt, - CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.TokenHash == tokenHash && - x.TenantId == tenantId && - x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), + public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + { + var query = _db.RefreshTokens + .Where(x => + x.TokenHash == tokenHash && + x.TenantId == tenantId && + x.RevokedAt == null); + + if (replacedByTokenHash == null) + { + return query.ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); + } + + return query.ExecuteUpdateAsync( + x => x + .SetProperty(t => t.RevokedAt, revokedAt) + .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), ct); + } - public Task RevokeBySessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => x.TenantId == tenantId && x.SessionId == sessionId.Value && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeByChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => x.TenantId == tenantId && - x.ChainId == chainId.Value && + x.ChainId == chainId && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeAllForUserAsync( - string? tenantId, - TUserId userId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - var uid = _converter.ToString(userId); return _db.RefreshTokens .Where(x => x.TenantId == tenantId && - x.UserId == uid && + x.UserKey == userKey && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index def05c6..14a759a 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -9,9 +9,11 @@ internal sealed class RefreshTokenProjection public string? TenantId { get; set; } public string TokenHash { get; set; } = default!; - public string UserId { get; set; } = default!; - public string SessionId { get; set; } = default!; - public ChainId ChainId { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; + public AuthSessionId SessionId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; + + public string? ReplacedByTokenHash { get; set; } public DateTimeOffset IssuedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs index 9dc2fe3..243d161 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { services.AddDbContext(configureDb); - services.AddScoped(typeof(IRefreshTokenStore<>), typeof(EfCoreRefreshTokenStore<>)); + services.AddScoped(typeof(IRefreshTokenStore), typeof(EfCoreRefreshTokenStore)); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index 59f1347..7b958e2 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -15,9 +14,6 @@ public UltimateAuthTokenDbContext(DbContextOptions o protected override void OnModelCreating(ModelBuilder b) { - // ------------------------------------------------- - // REFRESH TOKEN - // ------------------------------------------------- b.Entity(e => { e.HasKey(x => x.Id); @@ -31,16 +27,15 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(x => new { x.TenantId, x.TokenHash }) .IsUnique(); - e.HasIndex(x => new { x.TenantId, x.UserId }); + e.HasIndex(x => new { x.TenantId, x.UserKey }); e.HasIndex(x => new { x.TenantId, x.SessionId }); e.HasIndex(x => new { x.TenantId, x.ChainId }); + e.HasIndex(x => new { x.TenantId, x.ExpiresAt }); + e.HasIndex(x => new { x.TenantId, x.ReplacedByTokenHash }); e.Property(x => x.ExpiresAt).IsRequired(); }); - // ------------------------------------------------- - // REVOKED JTI - // ------------------------------------------------- b.Entity(e => { e.HasKey(x => x.Id); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index a474406..3d4b09b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -4,63 +4,45 @@ namespace CodeBeam.UltimateAuth.Tokens.InMemory; -public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) - => tenantId ?? "__default__"; + private static string NormalizeTenant(string? tenantId) => tenantId ?? "__default__"; - private readonly ConcurrentDictionary> _tokens - = new(); + private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync( - string? tenantId, - StoredRefreshToken token, - CancellationToken ct = default) + public Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - token.TokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), token.TokenHash); _tokens[key] = token; return Task.CompletedTask; } - public Task?> FindByHashAsync( - string? tenantId, - string tokenHash, - CancellationToken ct = default) + public Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - tokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync( - string? tenantId, - string tokenHash, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - tokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[key] = token with + { + RevokedAt = revokedAt, + ReplacedByTokenHash = replacedByTokenHash + }; } return Task.CompletedTask; } - public Task RevokeBySessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); @@ -77,11 +59,7 @@ public Task RevokeBySessionAsync( return Task.CompletedTask; } - public Task RevokeByChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); @@ -98,18 +76,14 @@ public Task RevokeByChainAsync( return Task.CompletedTask; } - public Task RevokeAllForUserAsync( - string? tenantId, - TUserId userId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); foreach (var (key, token) in _tokens) { if (key.TenantId == tenant && - EqualityComparer.Default.Equals(token.UserId, userId) && + token.UserKey == userKey && !token.IsRevoked) { _tokens[key] = token with { RevokedAt = revokedAt }; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 869d09e..4716253 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddScoped(typeof(IRefreshTokenStore<>), typeof(InMemoryRefreshTokenStore<>)); + services.AddSingleton(typeof(IRefreshTokenStore), typeof(InMemoryRefreshTokenStore)); return services; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 2bbcda1..145cd30 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -1,36 +1,38 @@  - - net10.0 - enable - enable - false - + + net10.0 + enable + enable + false + - - - - - - - + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs index e2d8214..c053640 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs @@ -2,21 +2,92 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; -public class AuthSessionIdTests +public sealed class AuthSessionIdTests { + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string AnotherValidRaw = "session-bbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + [Fact] + public void TryCreate_returns_false_for_null() + { + var result = AuthSessionId.TryCreate(null!, out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + [Fact] - public void Cannot_create_empty_session_id() + public void TryCreate_returns_false_for_empty_string() { - Assert.Throws(() => new AuthSessionId(string.Empty)); + var result = AuthSessionId.TryCreate(string.Empty, out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + + [Fact] + public void TryCreate_returns_false_for_short_value() + { + var result = AuthSessionId.TryCreate("too-short", out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + + [Fact] + public void TryCreate_creates_id_for_valid_value() + { + var result = AuthSessionId.TryCreate(ValidRaw, out var id); + + Assert.True(result); + Assert.NotEqual(default, id); + Assert.Equal(ValidRaw, id.Value); } [Fact] public void Equality_is_value_based() { - var id1 = new AuthSessionId("abc"); - var id2 = new AuthSessionId("abc"); + AuthSessionId.TryCreate(ValidRaw, out var id1); + AuthSessionId.TryCreate(ValidRaw, out var id2); Assert.Equal(id1, id2); Assert.True(id1 == id2); + Assert.False(id1 != id2); + } + + [Fact] + public void Different_values_are_not_equal() + { + AuthSessionId.TryCreate(ValidRaw, out var id1); + AuthSessionId.TryCreate(AnotherValidRaw, out var id2); + + Assert.NotEqual(id1, id2); + Assert.True(id1 != id2); + } + + [Fact] + public void ToString_returns_raw_value() + { + AuthSessionId.TryCreate(ValidRaw, out var id); + + Assert.Equal(ValidRaw, id.ToString()); + } + + [Fact] + public void Implicit_string_conversion_returns_raw_value() + { + AuthSessionId.TryCreate(ValidRaw, out var id); + + string value = id; + + Assert.Equal(ValidRaw, value); + } + + [Fact] + public void Default_value_has_null_Value() + { + var id = default(AuthSessionId); + + Assert.Null(id.Value); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs new file mode 100644 index 0000000..ad919a4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -0,0 +1,116 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core +{ + public sealed class RefreshTokenValidatorTests + { + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + + private static DefaultRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) + { + return new DefaultRefreshTokenValidator(store, CreateHasher()); + } + + private static ITokenHasher CreateHasher() + { + return new HmacSha256TokenHasher(Encoding.UTF8.GetBytes("unit-test-secret-key")); + } + + [Fact] + public async Task Invalid_When_Token_Not_Found() + { + var store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = "non-existing", + Now = DateTimeOffset.UtcNow, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Token_is_Revoked() + { + var store = new InMemoryRefreshTokenStore(); + var hasher = CreateHasher(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; + + var rawToken = "refresh-token-1"; + var hash = hasher.Hash(rawToken); + + await store.StoreAsync(null, new StoredRefreshToken + { + TenantId = null, + TokenHash = hash, + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + ChainId = SessionChainId.New(), + IssuedAt = now.AddMinutes(-5), + ExpiresAt = now.AddMinutes(5), + RevokedAt = now + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = rawToken, + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } + + [Fact] + public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + { + var store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; + + await store.StoreAsync(null, new StoredRefreshToken + { + TenantId = null, + TokenHash = "hash-2", + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + ChainId = SessionChainId.New(), + IssuedAt = now, + ExpiresAt = now.AddMinutes(10) + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = "hash-2", + ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + } +} + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 13011f5..605ff14 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -2,22 +2,66 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; -public class UAuthSessionChainTests +public sealed class UAuthSessionChainTests { + private static AuthSessionId CreateSessionId(string seed) + { + var raw = seed.PadRight(32, 'x'); + AuthSessionId.TryCreate(raw, out var id); + return id; + } + [Fact] - public void Rotating_chain_increments_rotation_count() + public void New_chain_has_expected_initial_state() { - var chain = UAuthSessionChain.Create( - ChainId.New(), + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), tenantId: null, - userId: "user-1", + userKey: UserKey.FromString("user-1"), securityVersion: 0, ClaimsSnapshot.Empty); - var rotated = chain.RotateSession(new AuthSessionId("s2")); + Assert.Equal(0, chain.RotationCount); + Assert.Null(chain.ActiveSessionId); + Assert.False(chain.IsRevoked); + } + + [Fact] + public void Rotating_chain_sets_active_session_and_increments_rotation() + { + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var sessionId = CreateSessionId("s1"); + var rotated = chain.RotateSession(sessionId); Assert.Equal(1, rotated.RotationCount); - Assert.Equal("s2", rotated.ActiveSessionId?.Value); + Assert.Equal(sessionId, rotated.ActiveSessionId); + Assert.NotSame(chain, rotated); + } + + [Fact] + public void Multiple_rotations_increment_rotation_count() + { + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var first = chain.RotateSession(CreateSessionId("s1")); + var second = first.RotateSession(CreateSessionId("s2")); + + Assert.Equal(2, second.RotationCount); + Assert.Equal(CreateSessionId("s2"), second.ActiveSessionId); } [Fact] @@ -25,16 +69,56 @@ public void Revoked_chain_does_not_rotate() { var now = DateTimeOffset.UtcNow; - var chain = UAuthSessionChain.Create( - ChainId.New(), + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), null, - "user-1", + UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); var revoked = chain.Revoke(now); - var rotated = revoked.RotateSession(new AuthSessionId("s2")); + var rotated = revoked.RotateSession(CreateSessionId("s2")); Assert.Same(revoked, rotated); + Assert.True(rotated.IsRevoked); + } + + [Fact] + public void Revoking_chain_sets_revocation_fields() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var revoked = chain.Revoke(now); + + Assert.True(revoked.IsRevoked); + Assert.Equal(now, revoked.RevokedAt); + } + + [Fact] + public void Revoking_already_revoked_chain_is_idempotent() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var revoked1 = chain.Revoke(now); + var revoked2 = revoked1.Revoke(now.AddMinutes(1)); + + Assert.Same(revoked1, revoked2); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 1bf3f35..7548b28 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -5,19 +5,23 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; public class UAuthSessionTests { + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + [Fact] public void Revoke_marks_session_as_revoked() { var now = DateTimeOffset.UtcNow; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); - var session = UAuthSession.Create( - new AuthSessionId("s1"), + var session = UAuthSession.Create( + sessionId: sessionId, tenantId: null, - userId: "user-1", - chainId: ChainId.New(), + userKey: UserKey.FromString("user-1"), + chainId: SessionChainId.New(), now, now.AddMinutes(10), - DeviceInfo.Unknown, + DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -32,15 +36,16 @@ public void Revoke_marks_session_as_revoked() public void Revoking_twice_returns_same_instance() { var now = DateTimeOffset.UtcNow; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); - var session = UAuthSession.Create( - new AuthSessionId("s1"), + var session = UAuthSession.Create( + sessionId, null, - "user-1", - ChainId.New(), + UserKey.FromString("user-1"), + SessionChainId.New(), now, now.AddMinutes(10), - DeviceInfo.Unknown, + DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs index 70b7269..8b2dcf7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs @@ -15,6 +15,26 @@ public FakeUAuthClient(params RefreshOutcome[] outcomes) _outcomes = new Queue(outcomes); } + public Task BeginPkceAsync(bool navigateToHubLogin = true) + { + throw new NotImplementedException(); + } + + public Task BeginPkceAsync(string? returnUrl = null) + { + throw new NotImplementedException(); + } + + public Task CompletePkceLoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } + + public Task CompletePkceLoginAsync(PkceLoginRequest request) + { + throw new NotImplementedException(); + } + public Task GetCurrentPrincipalAsync() { throw new NotImplementedException(); @@ -30,6 +50,11 @@ public Task LogoutAsync() throw new NotImplementedException(); } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) + { + throw new NotImplementedException(); + } + public Task ReauthAsync() { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs new file mode 100644 index 0000000..ee19e82 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + internal static class TestIds + { + public static AuthSessionId Session(string raw) + { + if (!AuthSessionId.TryCreate(raw, out var id)) + throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}"); + + return id; + } + } +}