diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx
index 901d2484..be71aa02 100644
--- a/UltimateAuth.slnx
+++ b/UltimateAuth.slnx
@@ -16,6 +16,7 @@
+
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor
new file mode 100644
index 00000000..fe0ebb2a
--- /dev/null
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor
@@ -0,0 +1,54 @@
+@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi
+@inject ProductApiService Api
+@inject ISnackbar Snackbar
+
+
+
+
+ Resource Api
+ Sample demonstration of a resource.
+
+
+
+ Reload
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs
new file mode 100644
index 00000000..33ddb1cc
--- /dev/null
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs
@@ -0,0 +1,95 @@
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using Microsoft.AspNetCore.Components;
+using MudBlazor;
+
+namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs;
+
+public partial class ResourceApiDialog
+{
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = default!;
+
+ [Parameter]
+ public UAuthState AuthState { get; set; } = default!;
+
+ private List _products = new List();
+ private string? _newName = null;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ _products = (await Api.GetAllAsync()).Value ?? new();
+ StateHasChanged();
+ }
+ }
+
+ private async Task CommittedItemChanges(SampleProduct item)
+ {
+ var result = await Api.UpdateAsync(item.Id, item);
+
+ if (result.IsSuccess)
+ {
+ Snackbar.Add("Product updated successfully", Severity.Success);
+ }
+ else
+ {
+ Snackbar.Add(result?.ErrorText ?? "Failed to update product.", Severity.Error);
+ }
+
+ return DataGridEditFormAction.Close;
+ }
+
+ private async Task GetProducts()
+ {
+ var result = await Api.GetAllAsync();
+
+ if (result.IsSuccess)
+ {
+ _products = result.Value ?? new();
+ }
+ else
+ {
+ Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error);
+ }
+ }
+
+ private async Task CreateProduct()
+ {
+ var product = new SampleProduct
+ {
+ Name = _newName
+ };
+
+ var result = await Api.CreateAsync(product);
+
+ if (result.IsSuccess)
+ {
+ Snackbar.Add("New product created.");
+ _products = (await Api.GetAllAsync()).Value ?? new();
+ }
+ else
+ {
+ Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error);
+ }
+ }
+
+ private async Task DeleteProduct(int id)
+ {
+ var result = await Api.DeleteAsync(id);
+
+ if (result.IsSuccess)
+ {
+ Snackbar.Add("Product deleted succesfully.", Severity.Success);
+ _products = (await Api.GetAllAsync()).Value ?? new();
+ }
+ else
+ {
+ Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error);
+ }
+ }
+}
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 984e3912..beac4f94 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
@@ -142,6 +142,15 @@
}
+
+
+ Resource Api
+
+
+
+
+ Manage Resource
+
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 f734b4b8..9ee407b9 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
@@ -190,6 +190,11 @@ private async Task OpenRoleDialog()
await DialogService.ShowAsync("Role Management", GetDialogParameters(), UAuthDialog.GetDialogOptions());
}
+ private async Task OpenResourceApiDialog()
+ {
+ await DialogService.ShowAsync("Resource Api", GetDialogParameters(), UAuthDialog.GetDialogOptions());
+ }
+
private DialogParameters GetDialogParameters()
{
return new DialogParameters
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 53a18e6b..7bc8536b 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs
@@ -3,6 +3,7 @@
using CodeBeam.UltimateAuth.Core.Extensions;
using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm;
using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure;
+using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
@@ -28,16 +29,21 @@
});
builder.Services.AddMudExtensions();
+builder.Services.AddScoped();
builder.Services.AddScoped();
-//builder.Services.AddHttpClient("UAuthHub", client =>
-//{
-// client.BaseAddress = new Uri("https://localhost:6110");
-//});
//builder.Services.AddHttpClient("ResourceApi", client =>
//{
// client.BaseAddress = new Uri("https://localhost:6120");
//});
+builder.Services.AddScoped(sp =>
+{
+ return new HttpClient
+ {
+ BaseAddress = new Uri("https://localhost:6120") // Resource API
+ };
+});
+
await builder.Build().RunAsync();
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs
new file mode 100644
index 00000000..f410db79
--- /dev/null
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs
@@ -0,0 +1,78 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using Microsoft.AspNetCore.Components.WebAssembly.Http;
+using System.Net.Http.Json;
+
+namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi;
+
+public class ProductApiService
+{
+ private readonly HttpClient _http;
+
+ public ProductApiService(HttpClient http)
+ {
+ _http = http;
+ }
+
+ private HttpRequestMessage CreateRequest(HttpMethod method, string url, object? body = null)
+ {
+ var request = new HttpRequestMessage(method, url);
+ request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
+
+ if (body is not null)
+ {
+ request.Content = JsonContent.Create(body);
+ }
+
+ return request;
+ }
+
+ public Task>> GetAllAsync()
+ => SendAsync>(CreateRequest(HttpMethod.Get, "/api/products"));
+
+ public Task> GetAsync(int id)
+ => SendAsync(CreateRequest(HttpMethod.Get, $"/api/products/{id}"));
+
+ public Task> CreateAsync(SampleProduct product)
+ => SendAsync(CreateRequest(HttpMethod.Post, $"/api/products", product));
+
+ public Task> UpdateAsync(int id, SampleProduct product)
+ => SendAsync(CreateRequest(HttpMethod.Put, $"/api/products/{id}", product));
+
+ public Task> DeleteAsync(int id)
+ => SendAsync(CreateRequest(HttpMethod.Delete, $"/api/products/{id}"));
+
+ private async Task> SendAsync(HttpRequestMessage request)
+ {
+ var response = await _http.SendAsync(request);
+
+ var result = new UAuthResult
+ {
+ Status = (int)response.StatusCode,
+ IsSuccess = response.IsSuccessStatusCode
+ };
+
+ if (response.IsSuccessStatusCode)
+ {
+ result.Value = await response.Content.ReadFromJsonAsync();
+ return result;
+ }
+
+ result.Problem = await TryReadProblem(response);
+ return result;
+ }
+
+ private async Task TryReadProblem(HttpResponseMessage response)
+ {
+ try
+ {
+ return await response.Content.ReadFromJsonAsync();
+ }
+ catch
+ {
+ return new UAuthProblem
+ {
+ Title = response.ReasonPhrase
+ };
+ }
+ }
+}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs
new file mode 100644
index 00000000..bf145679
--- /dev/null
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi;
+
+public class SampleProduct
+{
+ public int Id { get; set; }
+ public string? Name { get; set; }
+}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html
index 6499fa41..26cd7ae0 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html
@@ -8,8 +8,6 @@
-
-
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs
deleted file mode 100644
index 1e76b826..00000000
--- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace CodeBeam.UltimateAuth.Sample.ResourceApi.Controllers
-{
- [ApiController]
- [Route("[controller]")]
- public class WeatherForecastController : ControllerBase
- {
- private static readonly string[] Summaries =
- [
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
- ];
-
- [HttpGet(Name = "GetWeatherForecast")]
- public IEnumerable Get()
- {
- return Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = Summaries[Random.Shared.Next(Summaries.Length)]
- })
- .ToArray();
- }
- }
-}
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs
index ebb830f7..35eb05d1 100644
--- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs
@@ -1,74 +1,29 @@
-using System.Security.Claims;
-using CodeBeam.UltimateAuth.Core.Extensions;
using CodeBeam.UltimateAuth.Server.Extensions;
var builder = WebApplication.CreateBuilder(args);
-// Add services to the container.
-
builder.Services.AddControllers();
-// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
-builder.Services.AddUltimateAuthResourceApi();
-
-builder.Services.AddCors(options =>
-{
- options.AddPolicy("WasmSample", policy =>
+builder.Services.AddUltimateAuthResourceApi(o =>
{
- policy
- .WithOrigins("https://localhost:6130")
- .AllowAnyHeader()
- .AllowAnyMethod();
+ o.UAuthHubBaseUrl = "https://localhost:6110";
+ o.AllowedClientOrigins.Add("https://localhost:6130");
});
-});
var app = builder.Build();
-// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
-app.UseCors("WasmSample");
+app.UseUltimateAuthResourceApi();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
-app.MapGet("/health", () =>
-{
- return Results.Ok(new
- {
- service = "ResourceApi",
- status = "ok"
- });
-});
-
-app.MapGet("/me", (ClaimsPrincipal user) =>
-{
- return Results.Ok(new
- {
- IsAuthenticated = user.Identity?.IsAuthenticated,
- Name = user.Identity?.Name,
- Claims = user.Claims.Select(c => new
- {
- c.Type,
- c.Value
- })
- });
-})
-.RequireAuthorization();
-
-app.MapGet("/data", () =>
-{
- return Results.Ok(new
- {
- Message = "You are authorized to access protected data."
- });
-})
-.RequireAuthorization("ApiUser");
app.Run();
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs
new file mode 100644
index 00000000..7d9191f3
--- /dev/null
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+
+namespace CodeBeam.UltimateAuth.Sample.ResourceApi;
+
+public static class AppActions
+{
+ public static class Products
+ {
+ public static readonly string Read = UAuthActions.Create("products", "read", ActionScope.Self);
+
+ public static readonly string Create = UAuthActions.Create("products", "create", ActionScope.Admin);
+
+ public static readonly string Update = UAuthActions.Create("products", "update", ActionScope.Admin);
+
+ public static readonly string Delete = UAuthActions.Create("products", "delete", ActionScope.Admin);
+ }
+}
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs
new file mode 100644
index 00000000..10db05bf
--- /dev/null
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.Sample.ResourceApi;
+
+public static class ProductStore
+{
+ public static List Items = new() { new SampleProduct() { Id = 0, Name = "Test"} };
+}
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs
new file mode 100644
index 00000000..8f99886a
--- /dev/null
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs
@@ -0,0 +1,73 @@
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Errors;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CodeBeam.UltimateAuth.Sample.ResourceApi;
+
+[ApiController]
+[Route("api/products")]
+public class ProductsController : ControllerBase
+{
+ [HttpGet]
+ [Authorize(Roles = "Admin")]
+ //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] // You can use UAuthActions as permission in ASP.NET Core policy.
+ public IActionResult GetAll()
+ {
+ return Ok(ProductStore.Items);
+ }
+
+ [HttpGet("{id}")]
+ [Authorize(Roles = "Admin")]
+ //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)]
+ public IActionResult Get(int id)
+ {
+ var item = ProductStore.Items.FirstOrDefault(x => x.Id == id);
+ if (item == null) return NotFound();
+
+ return Ok(item);
+ }
+
+ [HttpPost]
+ [Authorize(Roles = "Admin")]
+ //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)]
+ public IActionResult Create(SampleProduct product)
+ {
+ var nextId = ProductStore.Items.Any()
+ ? ProductStore.Items.Max(x => x.Id) + 1
+ : 1;
+
+ product.Id = nextId;
+ ProductStore.Items.Add(product);
+
+ return Ok(product);
+ }
+
+ [HttpPut("{id}")]
+ [Authorize(Roles = "Admin")]
+ //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)]
+ public IActionResult Update(int id, SampleProduct product)
+ {
+ var item = ProductStore.Items.FirstOrDefault(x => x.Id == id);
+
+ if (item == null)
+ {
+ throw new UAuthNotFoundException("No product found.");
+ }
+
+ item.Name = product.Name;
+ return Ok(product);
+ }
+
+ [HttpDelete("{id}")]
+ [Authorize(Roles = "Admin")]
+ //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)]
+ public IActionResult Delete(int id)
+ {
+ var item = ProductStore.Items.FirstOrDefault(x => x.Id == id);
+ if (item == null) return NotFound();
+
+ ProductStore.Items.Remove(item);
+ return Ok(item);
+ }
+}
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs
new file mode 100644
index 00000000..2c75603d
--- /dev/null
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Sample.ResourceApi;
+
+public class SampleProduct
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = default!;
+}
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs
deleted file mode 100644
index fc51bad6..00000000
--- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace CodeBeam.UltimateAuth.Sample.ResourceApi
-{
- public class WeatherForecast
- {
- public DateOnly Date { get; set; }
-
- public int TemperatureC { get; set; }
-
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
-
- public string? Summary { get; set; }
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs
index 9f886535..1671ed53 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs
@@ -4,6 +4,7 @@ public enum AuthOperation
{
Login,
Access,
+ ResourceAccess,
Refresh,
Revoke,
Logout,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs
index 0ca7c003..2a674d30 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs
@@ -7,7 +7,7 @@ public class UAuthResult
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
- public UAuthProblem? Problem { get; init; }
+ public UAuthProblem? Problem { get; set; }
public HttpStatusInfo Http => new(Status);
@@ -31,5 +31,5 @@ internal HttpStatusInfo(int status)
public sealed class UAuthResult : UAuthResult
{
- public T? Value { get; init; }
+ public T? Value { get; set; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs
new file mode 100644
index 00000000..e4d4f0cc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class AuthSnapshotDto
+{
+ public IdentityDto? Identity { get; set; }
+
+ public ClaimsDto? Claims { get; set; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs
new file mode 100644
index 00000000..76482332
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class ClaimsDto
+{
+ public Dictionary Claims { get; set; } = new();
+
+ public string[] Roles { get; set; } = Array.Empty();
+
+ public string[] Permissions { get; set; } = Array.Empty();
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs
new file mode 100644
index 00000000..e962875e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs
@@ -0,0 +1,11 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class IdentityDto
+{
+ public string Tenant { get; set; } = default!;
+
+ public string? UserKey { get; set; }
+
+ public DateTimeOffset? AuthenticatedAt { get; set; }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs
new file mode 100644
index 00000000..353b8de5
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public sealed class SessionValidationDto
+{
+ public int State { get; set; } = default!;
+
+ public bool IsValid { get; set; }
+
+ public AuthSnapshotDto? Snapshot { get; set; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
index bb4a0ac8..9443abab 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
@@ -29,7 +29,7 @@ private SessionValidationResult() { }
public static SessionValidationResult Active(
TenantKey tenant,
- UserKey? userId,
+ UserKey? userKey,
AuthSessionId sessionId,
SessionChainId chainId,
SessionRootId rootId,
@@ -40,7 +40,7 @@ public static SessionValidationResult Active(
{
Tenant = tenant,
State = SessionState.Active,
- UserKey = userId,
+ UserKey = userKey,
SessionId = sessionId,
ChainId = chainId,
RootId = rootId,
diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
index 0a9418b5..c6761ad6 100644
--- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
@@ -21,6 +21,7 @@ public static class Claims
public static class HttpItems
{
public const string SessionContext = "__UAuth.SessionContext";
+ public const string SessionValidationResult = "__UAuth.SessionValidationResult";
public const string TenantContextKey = "__UAuthTenant";
public const string UserContextKey = "__UAuthUser";
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs
index 6aaae599..5eed57ee 100644
--- a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs
@@ -1,9 +1,15 @@
namespace CodeBeam.UltimateAuth.Core.Errors;
-public sealed class UAuthChallengeRequiredException : UAuthException
+public sealed class UAuthChallengeRequiredException : UAuthRuntimeException
{
- public UAuthChallengeRequiredException(string? reason = null)
- : base(code: "challenge_required", message: reason ?? "Additional authentication is required.")
+ public override int StatusCode => 401;
+
+ public override string Title => "Reauthentication Required";
+
+ public override string TypePrefix => "https://docs.ultimateauth.com/errors/challenge";
+
+ public UAuthChallengeRequiredException(string? reason = null)
+ : base("challenge_required", reason ?? "Additional authentication is required.")
{
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs
new file mode 100644
index 00000000..b95fbac3
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs
@@ -0,0 +1,12 @@
+namespace CodeBeam.UltimateAuth.Core.Errors;
+
+public sealed class UAuthAuthenticationException : UAuthRuntimeException
+{
+ public override int StatusCode => 401;
+ public override string Title => "Unauthorized";
+
+ public UAuthAuthenticationException(string code = "authentication_required")
+ : base(code, code)
+ {
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
new file mode 100644
index 00000000..f605ed94
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
@@ -0,0 +1,92 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure;
+
+public static class SessionValidationMapper
+{
+ public static SessionValidationResult ToDomain(SessionValidationDto dto)
+ {
+ var state = (SessionState)dto.State;
+
+ if (!dto.IsValid || dto.Snapshot.Identity is null)
+ {
+ return SessionValidationResult.Invalid(state);
+ }
+
+ var tenant = TenantKey.FromInternal(dto.Snapshot.Identity.Tenant);
+
+ UserKey? userKey = string.IsNullOrWhiteSpace(dto.Snapshot.Identity.UserKey)
+ ? null
+ : UserKey.Parse(dto.Snapshot.Identity.UserKey, null);
+
+ ClaimsSnapshot claims;
+
+ if (dto.Snapshot.Claims is null)
+ {
+ claims = ClaimsSnapshot.Empty;
+ }
+ else
+ {
+ var builder = ClaimsSnapshot.Create();
+
+ foreach (var (type, values) in dto.Snapshot.Claims.Claims)
+ {
+ builder.AddMany(type, values);
+ }
+
+ foreach (var role in dto.Snapshot.Claims.Roles)
+ {
+ builder.AddRole(role);
+ }
+
+ foreach (var permission in dto.Snapshot.Claims.Permissions)
+ {
+ builder.AddPermission(permission);
+ }
+
+ claims = builder.Build();
+ }
+
+ AuthSessionId.TryCreate("temp", out AuthSessionId tempSessionId);
+
+ return SessionValidationResult.Active(
+ tenant,
+ userKey,
+ tempSessionId, // TODO: This is TEMP add real
+ SessionChainId.New(), // TEMP
+ SessionRootId.New(), // TEMP
+ claims,
+ dto.Snapshot.Identity.AuthenticatedAt ?? DateTimeOffset.UtcNow,
+ null
+ );
+ }
+
+ 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/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
index 6cb5db77..3357fd43 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
@@ -1,6 +1,7 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Infrastructure;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Extensions;
diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs
index eabcc607..e2b4ed54 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs
@@ -13,4 +13,11 @@ public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder b
configure?.Invoke(options);
});
}
+
+ public static AuthenticationBuilder AddUAuthResourceApi(this AuthenticationBuilder builder)
+ {
+ return builder.AddScheme(
+ UAuthConstants.SchemeDefaults.GlobalScheme,
+ options => { });
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs
new file mode 100644
index 00000000..a479aa56
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs
@@ -0,0 +1,78 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+
+namespace CodeBeam.UltimateAuth.Server.Authentication;
+
+internal sealed class UAuthResourceAuthenticationHandler : AuthenticationHandler
+{
+ private readonly ITransportCredentialResolver _credentialResolver;
+ private readonly ISessionValidator _sessionValidator;
+ private readonly IDeviceContextFactory _deviceFactory;
+ private readonly IClock _clock;
+
+ public UAuthResourceAuthenticationHandler(
+ ITransportCredentialResolver credentialResolver,
+ ISessionValidator sessionValidator,
+ IDeviceContextFactory deviceFactory,
+ IClock clock,
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ _credentialResolver = credentialResolver;
+ _sessionValidator = sessionValidator;
+ _deviceFactory = deviceFactory;
+ _clock = clock;
+ }
+
+ protected override async Task HandleAuthenticateAsync()
+ {
+ var credential = await _credentialResolver.ResolveAsync(Context);
+
+ if (credential is null)
+ return AuthenticateResult.NoResult();
+
+ if (!AuthSessionId.TryCreate(credential.Value, out var sessionId))
+ return AuthenticateResult.Fail("Invalid session");
+
+ var tenant = Context.GetTenant();
+
+ var result = await _sessionValidator.ValidateSessionAsync(new SessionValidationContext
+ {
+ Tenant = tenant,
+ SessionId = sessionId,
+ Device = _deviceFactory.Create(credential.Device),
+ Now = _clock.UtcNow
+ });
+
+ if (!result.IsValid || result.UserKey is null)
+ return AuthenticateResult.NoResult();
+
+ var claims = new List
+ {
+ new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value)
+ };
+
+ foreach (var (type, values) in result.Claims.Claims)
+ {
+ foreach (var value in values)
+ {
+ claims.Add(new Claim(type, value));
+ }
+ }
+
+ var identity = new ClaimsIdentity(claims, Scheme.Name);
+ var principal = new ClaimsPrincipal(identity);
+
+ return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs
new file mode 100644
index 00000000..1b18149b
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs
@@ -0,0 +1,36 @@
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Server.Authorization;
+
+internal static class ResourceAccessContextBuilder
+{
+ public static AccessContext Create(HttpContext http, string action)
+ {
+ var user = http.RequestServices.GetRequiredService();
+
+ return new AccessContext(
+ actorUserKey: user.IsAuthenticated ? user.UserKey : null,
+ actorTenant: http.GetTenant(),
+ isAuthenticated: user.IsAuthenticated,
+ isSystemActor: false,
+ actorChainId: null,
+
+ resource: ResolveResource(action),
+ targetUserKey: null,
+ resourceTenant: http.GetTenant(),
+
+ action: action,
+ attributes: EmptyAttributes.Instance
+ );
+ }
+
+ private static string ResolveResource(string action)
+ {
+ var parts = action.Split('.');
+ return parts.Length > 0 ? parts[0] : "unknown";
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs
new file mode 100644
index 00000000..e22240e9
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace CodeBeam.UltimateAuth.Server.Authorization;
+
+public sealed class UAuthActionRequirement : IAuthorizationRequirement
+{
+ public string Action { get; }
+
+ public UAuthActionRequirement(string action)
+ {
+ Action = action;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs
new file mode 100644
index 00000000..3df4852a
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs
@@ -0,0 +1,43 @@
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Authorization;
+
+public sealed class UAuthAuthorizationHandler : AuthorizationHandler
+{
+ private readonly IAccessOrchestrator _orchestrator;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public UAuthAuthorizationHandler(
+ IAccessOrchestrator orchestrator,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _orchestrator = orchestrator;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, UAuthActionRequirement requirement)
+ {
+ var http = _httpContextAccessor.HttpContext!;
+ var accessContext = ResourceAccessContextBuilder.Create(http, requirement.Action);
+
+ try
+ {
+ await _orchestrator.ExecuteAsync(
+ accessContext,
+ new AccessCommand(_ => Task.CompletedTask));
+
+ context.Succeed(requirement);
+ }
+ catch (UAuthAuthorizationException)
+ {
+ // deny
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs
new file mode 100644
index 00000000..147b87dc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs
@@ -0,0 +1,29 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Authorization;
+
+public class UAuthPolicyProvider : IAuthorizationPolicyProvider
+{
+ private readonly DefaultAuthorizationPolicyProvider _fallback;
+
+ public UAuthPolicyProvider(IOptions options)
+ {
+ _fallback = new DefaultAuthorizationPolicyProvider(options);
+ }
+
+ public Task GetPolicyAsync(string policyName)
+ {
+ var policy = new AuthorizationPolicyBuilder()
+ .AddRequirements(new UAuthActionRequirement(policyName))
+ .Build();
+
+ return Task.FromResult(policy);
+ }
+
+ public Task GetDefaultPolicyAsync()
+ => _fallback.GetDefaultPolicyAsync();
+
+ public Task GetFallbackPolicyAsync()
+ => _fallback.GetFallbackPolicyAsync();
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs
new file mode 100644
index 00000000..302b4327
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs
@@ -0,0 +1,77 @@
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Policies.Abstractions;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Authorization;
+
+public sealed class UAuthResourceAccessOrchestrator : IAccessOrchestrator
+{
+ private readonly IAccessAuthority _authority;
+ private readonly IAccessPolicyProvider _policyProvider;
+ private readonly IHttpContextAccessor _http;
+
+ public UAuthResourceAccessOrchestrator(
+ IAccessAuthority authority,
+ IAccessPolicyProvider policyProvider,
+ IHttpContextAccessor http)
+ {
+ _authority = authority;
+ _policyProvider = policyProvider;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ context = EnrichFromClaims(context);
+
+ var policies = _policyProvider.GetPolicies(context);
+ var decision = _authority.Decide(context, policies);
+
+ if (!decision.IsAllowed)
+ throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
+
+ if (decision.RequiresReauthentication)
+ throw new InvalidOperationException("Requires reauthentication.");
+
+ await command.ExecuteAsync(ct);
+ }
+
+ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ context = EnrichFromClaims(context);
+
+ var policies = _policyProvider.GetPolicies(context);
+ var decision = _authority.Decide(context, policies);
+
+ if (!decision.IsAllowed)
+ throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
+
+ if (decision.RequiresReauthentication)
+ throw new InvalidOperationException("Requires reauthentication.");
+
+ return await command.ExecuteAsync(ct);
+ }
+
+ private AccessContext EnrichFromClaims(AccessContext context)
+ {
+ var http = _http.HttpContext!;
+ var user = http.User;
+
+ var permissions = user.Claims
+ .Where(c => c.Type == "uauth:permission")
+ .Select(c => Permission.From(c.Value));
+
+ var compiled = new CompiledPermissionSet(permissions);
+
+ return context.WithAttribute(UAuthConstants.Access.Permissions, compiled);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs
index a61260b8..953ac704 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs
@@ -11,7 +11,6 @@ public static AuthOperation ToAuthOperation(this AuthFlowType flowType)
AuthFlowType.Login => AuthOperation.Login,
AuthFlowType.Reauthentication => AuthOperation.Login,
- AuthFlowType.ApiAccess => AuthOperation.Access,
AuthFlowType.ValidateSession => AuthOperation.Access,
AuthFlowType.UserInfo => AuthOperation.Access,
AuthFlowType.PermissionQuery => AuthOperation.Access,
@@ -25,6 +24,8 @@ public static AuthOperation ToAuthOperation(this AuthFlowType flowType)
AuthFlowType.RevokeSession => AuthOperation.Revoke,
AuthFlowType.RevokeToken => AuthOperation.Revoke,
+ AuthFlowType.ApiAccess => AuthOperation.ResourceAccess,
+
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/HttpContext/HttpContextDeviceExtensions.cs
similarity index 90%
rename from src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs
index 5a5ec37a..3b93237f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs
@@ -5,7 +5,7 @@
namespace CodeBeam.UltimateAuth.Server.Extensions;
-public static class DeviceExtensions
+public static class HttpContextDeviceExtensions
{
public static async Task GetDeviceAsync(this HttpContext context)
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index 589f059f..6c6977dd 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Events;
using CodeBeam.UltimateAuth.Core.Extensions;
@@ -8,7 +9,6 @@
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Core.Runtime;
-using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.Policies.Abstractions;
using CodeBeam.UltimateAuth.Policies.Defaults;
@@ -17,29 +17,31 @@
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Authentication;
+using CodeBeam.UltimateAuth.Server.Authorization;
using CodeBeam.UltimateAuth.Server.Endpoints;
using CodeBeam.UltimateAuth.Server.Flows;
using CodeBeam.UltimateAuth.Server.Infrastructure;
using CodeBeam.UltimateAuth.Server.MultiTenancy;
using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Server.ResourceApi;
using CodeBeam.UltimateAuth.Server.Runtime;
using CodeBeam.UltimateAuth.Server.Security;
using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Server.Stores;
+using CodeBeam.UltimateAuth.Users;
using CodeBeam.UltimateAuth.Users.Contracts;
using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
-using CodeBeam.UltimateAuth.Users;
-using CodeBeam.UltimateAuth.Server.ResourceApi;
namespace CodeBeam.UltimateAuth.Server.Extensions;
public static class ServiceCollectionExtensions
{
- public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null)
+ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddUltimateAuth();
@@ -47,7 +49,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s
AddUsersInternal(services);
AddCredentialsInternal(services);
AddAuthorizationInternal(services);
- AddUltimateAuthPolicies(services);
+
+ services.AddUltimateAuthPolicies(configurePolicies);
services.AddOptions()
// Program.cs configuration (lowest precedence)
@@ -67,29 +70,39 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s
return services;
}
- public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null)
+ public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddUltimateAuth();
+ services.AddUltimateAuthPolicies(configurePolicies, isResourceApp: true);
- //AddUsersInternal(services);
- //AddCredentialsInternal(services);
- //AddAuthorizationInternal(services);
- //AddUltimateAuthPolicies(services);
-
- services.AddOptions()
+ services.AddOptions()
.Configure(options =>
{
configure?.Invoke(options);
})
- .BindConfiguration("UltimateAuth:Server")
- .PostConfigure(options =>
- {
- options.Endpoints.Authentication = false;
- });
+ .BindConfiguration("UltimateAuth:ResourceApi");
services.AddUltimateAuthResourceInternal();
+ var temp = new UAuthResourceApiOptions();
+ configure?.Invoke(temp);
+
+ if (temp.AllowedClientOrigins?.Count > 0)
+ {
+ services.AddCors(cors =>
+ {
+ cors.AddPolicy(temp.CorsPolicyName, policy =>
+ {
+ policy
+ .WithOrigins(temp.AllowedClientOrigins.ToArray())
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ });
+ });
+ }
+
return services;
}
@@ -126,7 +139,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
- // EVENTS
+ // Events
services.AddSingleton(sp =>
{
var options = sp.GetRequiredService>().Value;
@@ -178,7 +191,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.AddHttpContextAccessor();
- services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor));
+ services.TryAddScoped, UAuthUserAccessor>();
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
@@ -229,7 +242,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddSingleton();
services.TryAddSingleton();
- services.TryAddScoped();
+ services.TryAddScoped();
services.TryAddSingleton();
services.TryAddSingleton();
@@ -247,9 +260,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
- // -----------------------------
- // ENDPOINTS
- // -----------------------------
+ // Endpoints
services.TryAddScoped();
services.TryAddSingleton();
@@ -260,9 +271,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
- // ------------------------------
- // ASP.NET CORE INTEGRATION
- // ------------------------------
+ // ASP.NET Core Integration
services.AddAuthentication();
services.PostConfigureAll(options =>
@@ -273,10 +282,10 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
});
services.AddAuthentication().AddUAuthCookies();
-
-
services.AddAuthorization();
+ services.AddSingleton();
+ services.AddScoped();
services.Configure(opt =>
{
@@ -293,14 +302,22 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
return services;
}
- internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null)
+ internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null, bool isResourceApp = false)
{
if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry)))
throw new InvalidOperationException("UltimateAuth policies already registered.");
var registry = new AccessPolicyRegistry();
- DefaultPolicySet.Register(registry);
+ if (isResourceApp)
+ {
+ DefaultPolicySet.RegisterResource(registry);
+ }
+ else
+ {
+ DefaultPolicySet.RegisterServer(registry);
+ }
+
configure?.Invoke(registry);
services.AddSingleton(registry);
@@ -352,50 +369,7 @@ internal static IServiceCollection AddAuthorizationInternal(IServiceCollection s
return services;
}
- // TODO: This is not true, need to build true pipeline for ResourceApi.
- private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceCollection services)
- {
- services.AddSingleton();
-
- services.TryAddScoped();
- services.TryAddScoped();
-
- services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor));
- services.TryAddScoped();
- services.TryAddScoped();
- services.TryAddScoped();
-
- services.TryAddScoped();
- services.TryAddScoped();
-
- services.TryAddScoped();
-
- services.TryAddSingleton();
-
- services.AddHttpContextAccessor();
- services.AddAuthentication();
-
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddScoped();
- services.TryAddSingleton();
-
- services.Replace(ServiceDescriptor.Scoped());
- services.Replace(ServiceDescriptor.Scoped());
-
- services.PostConfigureAll(options =>
- {
- options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme;
- });
-
- return services;
- }
-
- public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null)
+ public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null)
{
services.PostConfigure(options =>
{
@@ -433,6 +407,85 @@ public static IServiceCollection AddUAuthHub(this IServiceCollection services, A
return services;
}
+
+ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceCollection services)
+ {
+ // Resource API Specific
+ services.AddSingleton();
+
+ services.AddScoped();
+ services.AddScoped, ResourceUserAccessor>();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Server & Resource API Shared
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.TryAddScoped();
+
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+
+ services.TryAddSingleton();
+
+ services.TryAddScoped();
+ services.TryAddSingleton(sp =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+
+ var resolvers = new List();
+
+ if (opts.EnableRoute)
+ resolvers.Add(new PathTenantResolver());
+
+ if (opts.EnableHeader)
+ resolvers.Add(new HeaderTenantResolver(opts.HeaderName));
+
+ if (opts.EnableDomain)
+ resolvers.Add(new HostTenantResolver());
+
+ return resolvers.Count switch
+ {
+ 0 => new NullTenantResolver(),
+ 1 => resolvers[0],
+ _ => new CompositeTenantResolver(resolvers)
+ };
+ });
+
+ // ASP.NET Core Integration
+ services.AddHttpContextAccessor();
+ services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = UAuthConstants.SchemeDefaults.GlobalScheme;
+ options.DefaultChallengeScheme = UAuthConstants.SchemeDefaults.GlobalScheme;
+ })
+ .AddUAuthResourceApi();
+ services.AddAuthorization();
+
+ services.AddSingleton();
+ services.AddScoped();
+
+ services.AddHttpClient((sp, client) =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+
+ if (string.IsNullOrWhiteSpace(opts.UAuthHubBaseUrl))
+ throw new InvalidOperationException("UAuthHubBaseUrl is not configured. Add it via UAuthResourceApiOptions.");
+
+ client.BaseAddress = new Uri(opts.UAuthHubBaseUrl);
+ });
+
+ return services;
+ }
}
internal sealed class NullTenantResolver : ITenantIdResolver
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
index 23e99b20..c78d6427 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs
@@ -1,8 +1,10 @@
using CodeBeam.UltimateAuth.Core.Runtime;
using CodeBeam.UltimateAuth.Server.Middlewares;
+using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
namespace CodeBeam.UltimateAuth.Server.Extensions;
@@ -40,4 +42,27 @@ public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicatio
app.UseAuthorization();
return app;
}
+
+ public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBuilder app)
+ {
+ var logger = app.ApplicationServices
+ .GetRequiredService()
+ .CreateLogger("UltimateAuth");
+
+ var options = app.ApplicationServices.GetRequiredService>().Value;
+
+ if (options.AllowedClientOrigins?.Count > 0)
+ {
+ app.UseCors(options.CorsPolicyName);
+ logger.LogInformation("UAuth Resource API initialized with CORS.");
+ }
+
+ app.UseUAuthExceptionHandling();
+ app.UseMiddleware();
+ app.UseMiddleware();
+ app.UseMiddleware();
+ app.UseMiddleware();
+
+ return app;
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
index cf9b5520..6dd8d03a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
@@ -46,11 +46,14 @@ private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeExcepti
private static int MapStatusCode(UAuthRuntimeException ex) =>
ex switch
{
+ UAuthAuthenticationException => StatusCodes.Status401Unauthorized,
+ UAuthAuthorizationException => StatusCodes.Status403Forbidden,
UAuthConflictException => StatusCodes.Status409Conflict,
UAuthValidationException => StatusCodes.Status400BadRequest,
UAuthUnauthorizedException => StatusCodes.Status401Unauthorized,
UAuthForbiddenException => StatusCodes.Status403Forbidden,
UAuthNotFoundException => StatusCodes.Status404NotFound,
+ UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status400BadRequest
};
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs
index 190cb521..502f7ed6 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs
@@ -28,7 +28,7 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi
switch (decision.Decision)
{
case AuthorizationDecision.Deny:
- throw new UAuthAuthorizationException(decision.Reason ?? "authorization_denied");
+ throw new UAuthAuthenticationException(decision.Reason ?? "authorization_denied");
case AuthorizationDecision.Challenge:
throw new UAuthChallengeRequiredException(decision.Reason);
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs
deleted file mode 100644
index eb649433..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-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/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs
index 64664ca5..7dd0f3a0 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs
@@ -2,7 +2,6 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Server.Middlewares;
using Microsoft.AspNetCore.Http;
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs
new file mode 100644
index 00000000..45365641
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs
@@ -0,0 +1,54 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Middlewares;
+
+public class SessionValidationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ISessionValidator _validator;
+ private readonly IClock _clock;
+
+ public SessionValidationMiddleware(RequestDelegate next, ISessionValidator validator, IClock clock)
+ {
+ _next = next;
+ _validator = validator;
+ _clock = clock;
+ }
+
+ public async Task Invoke(HttpContext context)
+ {
+ var sessionCtx = context.GetSessionContext();
+
+ if (sessionCtx.IsAnonymous)
+ {
+ context.Items[UAuthConstants.HttpItems.SessionValidationResult] = SessionValidationResult.Invalid(SessionState.NotFound);
+
+ await _next(context);
+ return;
+ }
+
+ var info = await context.GetDeviceAsync();
+ var device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress);
+
+ if (sessionCtx.Tenant is not TenantKey tenant)
+ throw new InvalidOperationException("Tenant is not resolved.");
+
+ var result = await _validator.ValidateSessionAsync(new SessionValidationContext
+ {
+ Tenant = tenant,
+ SessionId = sessionCtx.SessionId!.Value,
+ Now = _clock.UtcNow,
+ Device = device
+ });
+
+ context.Items["__UAuth.SessionValidationResult"] = result;
+
+ await _next(context);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs
similarity index 87%
rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs
rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs
index 96b6f414..c2f060d8 100644
--- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs
@@ -1,6 +1,6 @@
namespace CodeBeam.UltimateAuth.Server.Options;
-public sealed class UAuthHubServerOptions
+public sealed class UAuthHubOptions
{
public string? ClientBaseAddress { get; set; }
@@ -14,7 +14,7 @@ public sealed class UAuthHubServerOptions
public string? LoginPath { get; set; } = "/login";
- internal UAuthHubServerOptions Clone() => new()
+ internal UAuthHubOptions Clone() => new()
{
ClientBaseAddress = ClientBaseAddress,
AllowedClientOrigins = new HashSet(AllowedClientOrigins),
diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs
new file mode 100644
index 00000000..2fb51a20
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.Server.Options;
+
+public class UAuthResourceApiOptions
+{
+ public string UAuthHubBaseUrl { get; set; } = default!;
+ public HashSet AllowedClientOrigins { get; set; } = new();
+ public string CorsPolicyName { get; set; } = "UAuthResource";
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
index 34f41c83..6706f0d4 100644
--- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
@@ -76,7 +76,7 @@ public sealed class UAuthServerOptions
public UAuthResetOptions ResetCredential { get; init; } = new();
- public UAuthHubServerOptions Hub { get; set; } = new();
+ public UAuthHubOptions Hub { get; set; } = new();
///
/// Controls how session identifiers are resolved from incoming requests
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs
deleted file mode 100644
index b6f55185..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Policies.Abstractions;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi
-{
- internal sealed class AllowAllAccessPolicyProvider : IAccessPolicyProvider
- {
- public IReadOnlyCollection GetPolicies(AccessContext context)
- {
- throw new NotSupportedException();
- }
-
- public Task ResolveAsync(string name, CancellationToken ct = default)
- => Task.FromResult(new AllowAllPolicy());
- }
-
- internal sealed class AllowAllPolicy : IAccessPolicy
- {
- public bool AppliesTo(AccessContext context)
- {
- throw new NotImplementedException();
- }
-
- public AccessDecision Decide(AccessContext context)
- {
- throw new NotImplementedException();
- }
-
- public Task EvaluateAsync(AccessContext context, CancellationToken ct = default)
- => Task.FromResult(true);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs
deleted file mode 100644
index de7ff5df..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Server.Infrastructure;
-using CodeBeam.UltimateAuth.Users.Contracts;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal class NoOpIdentifierValidator : IIdentifierValidator
-{
- public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default)
- {
- throw new NotImplementedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs
deleted file mode 100644
index 9b173787..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal sealed class NoOpRefreshTokenValidator : IRefreshTokenValidator
-{
- public Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default)
- => Task.CompletedTask;
-
- Task IRefreshTokenValidator.ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct)
- {
- throw new NotImplementedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs
deleted file mode 100644
index a6b54a4a..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal sealed class NoOpSessionValidator : ISessionValidator
-{
- public Task ValidateSesAsync(SessionValidationContext context, CancellationToken ct = default)
- => Task.CompletedTask;
-
- public Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default)
- {
- throw new NotSupportedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs
deleted file mode 100644
index a1f51a9f..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal sealed class NoOpTokenHasher : ITokenHasher
-{
- public string Hash(string input) => input;
- public bool Verify(string input, string hash) => input == hash;
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs
deleted file mode 100644
index 4d7e0d92..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using CodeBeam.UltimateAuth.Core;
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using System.Security.Claims;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal sealed class NoOpUserClaimsProvider : IUserClaimsProvider
-{
- public Task> GetClaimsAsync(TenantKey tenant, UserKey user, CancellationToken ct = default)
- => Task.FromResult>(Array.Empty());
-
- Task IUserClaimsProvider.GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct)
- {
- return Task.FromResult(ClaimsSnapshot.Empty);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs
deleted file mode 100644
index b2f94991..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal class NotSupportedPasswordHasher : IUAuthPasswordHasher
-{
- public string Hash(string password)
- {
- throw new NotSupportedException();
- }
-
- public bool Verify(string hash, string secret)
- {
- throw new NotSupportedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs
deleted file mode 100644
index 1de3a14d..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal class NotSupportedRefreshTokenStoreFactory : IRefreshTokenStoreFactory
-{
- public IRefreshTokenStore Create(TenantKey tenant)
- {
- throw new NotSupportedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs
deleted file mode 100644
index 3f5db1ca..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal class NotSupportedSessionStoreFactory : ISessionStoreFactory
-{
- public ISessionStore Create(TenantKey tenant)
- {
- throw new NotSupportedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs
deleted file mode 100644
index 33b75cdb..00000000
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CodeBeam.UltimateAuth.Authorization;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-
-internal class NotSupportedUserRoleStoreFactory : IUserRoleStoreFactory
-{
- public IUserRoleStore Create(TenantKey tenant)
- {
- throw new NotSupportedException();
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs
new file mode 100644
index 00000000..5bfe197b
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs
@@ -0,0 +1,50 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Infrastructure;
+using Microsoft.AspNetCore.Http;
+using System.Net.Http.Json;
+
+namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+
+internal sealed class RemoteSessionValidator : ISessionValidator
+{
+ private readonly HttpClient _http;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public RemoteSessionValidator(HttpClient http, IHttpContextAccessor httpContextAccessor)
+ {
+ _http = http;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/auth/validate")
+ {
+ Content = JsonContent.Create(new
+ {
+ sessionId = context.SessionId.Value,
+ tenant = context.Tenant.Value
+ })
+ };
+
+ var httpContext = _httpContextAccessor.HttpContext!;
+
+ if (httpContext.Request.Headers.TryGetValue("Cookie", out var cookie))
+ {
+ request.Headers.Add("Cookie", cookie.ToString());
+ }
+
+ var response = await _http.SendAsync(request, ct);
+
+ if (!response.IsSuccessStatusCode)
+ return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId);
+
+ var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct);
+
+ if (dto is null)
+ return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId);
+
+ return SessionValidationMapper.ToDomain(dto);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs
new file mode 100644
index 00000000..7569b9e7
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs
@@ -0,0 +1,61 @@
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Options;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+
+internal sealed class ResourceAuthContextFactory : IAuthContextFactory
+{
+ private readonly IHttpContextAccessor _http;
+ private readonly IClock _clock;
+
+ public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock)
+ {
+ _http = http;
+ _clock = clock;
+ }
+
+ public AuthContext Create(DateTimeOffset? at = null)
+ {
+ var ctx = _http.HttpContext!;
+
+ var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult;
+
+ if (result is null || !result.IsValid)
+ {
+ return new AuthContext
+ {
+ Tenant = default!,
+ Operation = AuthOperation.ResourceAccess,
+ Mode = UAuthMode.PureOpaque,
+ ClientProfile = UAuthClientProfile.Api,
+ Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)),
+ At = at ?? _clock.UtcNow,
+ Session = null
+ };
+ }
+
+ return new AuthContext
+ {
+ Tenant = result.Tenant,
+ Operation = AuthOperation.ResourceAccess,
+ Mode = UAuthMode.PureOpaque, // sonra resolver yapılabilir
+ ClientProfile = UAuthClientProfile.Api,
+ Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)),
+ At = at ?? _clock.UtcNow,
+
+ Session = new SessionSecurityContext
+ {
+ UserKey = result.UserKey,
+ SessionId = result.SessionId.Value,
+ State = result.State,
+ ChainId = result.ChainId,
+ BoundDeviceId = result.BoundDeviceId
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs
new file mode 100644
index 00000000..4c0fb2b9
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs
@@ -0,0 +1,33 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+
+internal sealed class ResourceUserAccessor : IUserAccessor
+{
+ private readonly IUserIdConverter _converter;
+
+ public ResourceUserAccessor(IUserIdConverterResolver resolver)
+ {
+ _converter = resolver.GetConverter();
+ }
+
+ public Task ResolveAsync(HttpContext context)
+ {
+ var result = context.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult;
+
+ if (result is null || !result.IsValid || result.UserKey is null)
+ {
+ context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous();
+ return Task.CompletedTask;
+ }
+
+ var userId = _converter.FromString(result.UserKey.Value);
+ context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Authenticated(userId);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
index da89b7c9..787e5281 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
@@ -22,6 +22,8 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi
// TODO: Improve Device binding
// Validate runs before AuthFlowContext is set, do not call _authFlow here.
+
+ // TODO: Add GetSessionAggregate store method to 1 call instead of 3 calls of root chain session
public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default)
{
var kernel = _storeFactory.Create(context.Tenant);
diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs
index c7ccf270..bf91cb87 100644
--- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs
+++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs
@@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Policies.Defaults;
internal static class DefaultPolicySet
{
- public static void Register(AccessPolicyRegistry registry)
+ public static void RegisterServer(AccessPolicyRegistry registry)
{
// Invariant
registry.Add("", _ => new RequireAuthenticatedPolicy());
@@ -22,4 +22,19 @@ public static void Register(AccessPolicyRegistry registry)
// Permission
registry.Add("", _ => new MustHavePermissionPolicy());
}
+
+ public static void RegisterResource(AccessPolicyRegistry registry)
+ {
+ // Invariant
+ registry.Add("", _ => new RequireAuthenticatedPolicy());
+ registry.Add("", _ => new DenyCrossTenantPolicy());
+
+ // Intent-based
+ registry.Add("", _ => new RequireSelfPolicy());
+ registry.Add("", _ => new DenyAdminSelfModificationPolicy());
+ registry.Add("", _ => new RequireSystemPolicy());
+
+ // Permission
+ registry.Add("", _ => new MustHavePermissionPolicy());
+ }
}
diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs
index cbe238d4..fb26bb6a 100644
--- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs
+++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs
@@ -1,7 +1,5 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Defaults;
-using System.Net;
namespace CodeBeam.UltimateAuth.Policies;
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs
new file mode 100644
index 00000000..c7fc3b1f
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs
@@ -0,0 +1 @@
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
new file mode 100644
index 00000000..ebc06239
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+public class AuthServerFactory : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs
new file mode 100644
index 00000000..46245237
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.VisualStudio.TestPlatform.TestHost;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+public class AuthServerTests : IClassFixture>
+{
+ private readonly WebApplicationFactory _factory;
+
+ public AuthServerTests(WebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj
new file mode 100644
index 00000000..74729080
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs
new file mode 100644
index 00000000..a06b2b12
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs
@@ -0,0 +1,96 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+public class LoginTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public LoginTests(AuthServerFactory factory)
+ {
+ _client = factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = false
+ });
+
+ _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130");
+ _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456");
+ }
+
+ [Fact]
+ public async Task Login_Should_Return_Cookie()
+ {
+ var response = await _client.PostAsJsonAsync("/auth/login", new
+ {
+ identifier = "admin",
+ secret = "admin"
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+ response.Headers.Location.Should().NotBeNull();
+ response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue();
+ cookies.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task Session_Lifecycle_Should_Work_Correctly()
+ {
+ var loginResponse1 = await _client.PostAsJsonAsync("/auth/login", new
+ {
+ identifier = "admin",
+ secret = "admin"
+ });
+
+ loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").FirstOrDefault();
+ cookie1.Should().NotBeNull();
+
+ _client.DefaultRequestHeaders.Add("Cookie", cookie1!);
+
+ var logoutResponse = await _client.PostAsync("/auth/logout", null);
+ logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ var logoutAgain = await _client.PostAsync("/auth/logout", null);
+ logoutAgain.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found);
+
+ _client.DefaultRequestHeaders.Remove("Cookie");
+
+ var loginResponse2 = await _client.PostAsJsonAsync("/auth/login", new
+ {
+ identifier = "admin",
+ secret = "admin"
+ });
+
+ loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found);
+ var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").FirstOrDefault();
+ cookie2.Should().NotBeNull();
+ cookie2.Should().NotBe(cookie1);
+ }
+
+ [Fact]
+ public async Task Authenticated_User_Should_Access_Me_Endpoint()
+ {
+ var loginResponse = await _client.PostAsJsonAsync("/auth/login", new
+ {
+ identifier = "admin",
+ secret = "admin"
+ });
+
+ var cookie = loginResponse.Headers.GetValues("Set-Cookie").First();
+ _client.DefaultRequestHeaders.Add("Cookie", cookie);
+ var response = await _client.PostAsync("/auth/me/get", null);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task Anonymous_Should_Not_Access_Me()
+ {
+ var response = await _client.PostAsync("/auth/me/get", null);
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs
new file mode 100644
index 00000000..c7fc3b1f
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs
@@ -0,0 +1 @@
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs
deleted file mode 100644
index f9e4007a..00000000
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-using System.Runtime.CompilerServices;
-
-[assembly: CollectionBehavior(DisableTestParallelization = true)]