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