diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs new file mode 100644 index 0000000..7e6192e --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Json; +using FluentResults; +using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Services; +using IdentityShroud.Core.Tests.Fixtures; +using Microsoft.AspNetCore.Mvc; +using NSubstitute.ClearExtensions; + +namespace IdentityShroud.Api.Tests.Apis; + +public class RealmApisTests(ApplicationFactory factory) : IClassFixture +{ + [Theory] + [InlineData(null, null, null, false, "Name")] + [InlineData(null, null, "Foo", true, "")] + [InlineData(null, null, "", false, "Name")] + [InlineData(null, "foo", "Foo", true, "")] + [InlineData(null, "f/oo", "Foo", false, "Slug")] + [InlineData(null, "", "Foo", false, "Slug")] + [InlineData("0814934a-efe2-4784-ba84-a184c0b9de9e", "foo", "Foo", true, "")] + [InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")] + public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName) + { + var client = factory.CreateClient(); + + factory.RealmService.ClearSubstitute(); + factory.RealmService.Create(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new RealmCreateResponse(Guid.NewGuid(), "foo", "Foo"))); + + Guid? inputId = id is null ? (Guid?)null : new Guid(id); + var response = await client.PostAsync("/realms", JsonContent.Create(new + { + Id = inputId, + Slug = slug, + Name = name, + }), + TestContext.Current.CancellationToken); +#if DEBUG + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); +#endif + + if (succeeds) + { + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + await factory.RealmService.Received(1).Create( + Arg.Is(r => r.Id == inputId && r.Slug == slug && r.Name == name), + Arg.Any()); + } + else + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problemDetails = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); + await factory.RealmService.DidNotReceive().Create( + Arg.Any(), + Arg.Any()); + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs new file mode 100644 index 0000000..6135df6 --- /dev/null +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -0,0 +1,24 @@ +using IdentityShroud.Core.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestPlatform.TestHost; + +namespace IdentityShroud.Core.Tests.Fixtures; + +public class ApplicationFactory : WebApplicationFactory +{ + public IRealmService RealmService { get; } = Substitute.For(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + builder.ConfigureServices(services => + { + services.AddScoped(c => RealmService); + }); + + builder.UseEnvironment("Development"); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj new file mode 100644 index 0000000..6351c40 --- /dev/null +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IdentityShroud.Api/RealmController.cs b/IdentityShroud.Api/Apis/RealmApi.cs similarity index 75% rename from IdentityShroud.Api/RealmController.cs rename to IdentityShroud.Api/Apis/RealmApi.cs index 1e647da..265fbb9 100644 --- a/IdentityShroud.Api/RealmController.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,3 +1,5 @@ +using FluentResults; +using IdentityShroud.Api.Validation; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Services; @@ -6,23 +8,36 @@ using Microsoft.AspNetCore.Mvc; namespace IdentityShroud.Api; -public static class RealmController +public static class RealmApi { public static void MapRealmEndpoints(this IEndpointRouteBuilder app) { - var realm = app.MapGroup("/realms/{slug}"); + var realmsGroup = app.MapGroup("/realms"); + realmsGroup.MapPost("", RealmCreate) + .Validate() + .WithName("Create Realm") + .Produces(StatusCodes.Status201Created); - realm.MapGet("", GetRoot); - realm.MapPost("", (RealmCreateRequest request, [FromServices] RealmService service) => - service.Create(request)) - .WithName("Create Realm"); - realm.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var realmSlugGroup = app.MapGroup("{slug}"); + realmSlugGroup.MapGet("", GetRealmInfo); + realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); - var openidConnect = realm.MapGroup("openid-connect"); + var openidConnect = realmSlugGroup.MapGroup("openid-connect"); openidConnect.MapPost("auth", OpenIdConnectAuth); openidConnect.MapPost("token", OpenIdConnectToken); openidConnect.MapGet("jwks", OpenIdConnectJwks); } + + private static async Task, InternalServerError>> + RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service) + { + var response = await service.Create(request); + if (response.IsSuccess) + return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value); + + // TODO make helper to convert failure response to a proper HTTP result. + return TypedResults.InternalServerError(); + } private static Task OpenIdConnectJwks(HttpContext context) { @@ -57,7 +72,7 @@ public static class RealmController }, AppJsonSerializerContext.Default.OpenIdConfiguration); } - private static string GetRoot() + private static string GetRealmInfo() { return "Hello World!"; diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index a77becf..72b4639 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -10,7 +10,12 @@ 6b8ef434-0577-4a3c-8749-6b547d7787c5 + + true + + + diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 12cd304..510c626 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,10 +1,13 @@ +using FluentValidation; using IdentityShroud.Api; +using IdentityShroud.Api.Validation; using IdentityShroud.Core; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; using Serilog; using Serilog.Formatting.Json; + // Initial logging until we can set it up from Configuration Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() @@ -34,6 +37,8 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); + services.AddValidatorsFromAssemblyContaining(); + builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() //.Enrich.With() @@ -51,3 +56,5 @@ void ConfigureApplication(WebApplication app) // app.UseRouting(); // app.MapControllers(); } + +public partial class Program { } diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..e67f787 --- /dev/null +++ b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,7 @@ +namespace IdentityShroud.Api.Validation; + +public static class EndpointRouteBuilderExtensions +{ + public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class + => builder.AddEndpointFilter>(); +} \ No newline at end of file diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs new file mode 100644 index 0000000..8daa0a9 --- /dev/null +++ b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using IdentityShroud.Core.Messages.Realm; + +namespace IdentityShroud.Api.Validation; + +public class RealmCreateRequestValidator : AbstractValidator +{ + private const string SlugPattern = @"^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$"; + + public RealmCreateRequestValidator() + { + RuleFor(x => x.Id) + .NotEqual(Guid.Empty).When(x => x.Id.HasValue); + RuleFor(x => x.Slug) + .Matches(SlugPattern).Unless(x => x.Slug is null); + RuleFor(x => x.Name) + .NotNull().Length(1, 255); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Validation/ValidateFilter.cs new file mode 100644 index 0000000..fbebd9d --- /dev/null +++ b/IdentityShroud.Api/Validation/ValidateFilter.cs @@ -0,0 +1,33 @@ +using FluentValidation; + +namespace IdentityShroud.Api.Validation; + +public class ValidateFilter : IEndpointFilter where T : class +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + // Grab the deserialized argument (the DTO) from the context. + // The order of arguments matches the order of parameters in the endpoint delegate. + var dto = context.Arguments + .FirstOrDefault(arg => arg is T) as T; + + if (dto == null) + return Results.BadRequest("Unable to read request body."); + + // Resolve the matching validator from DI. + var validator = context.HttpContext.RequestServices + .GetService>(); + + if (validator != null) + { + var validationResult = await validator.ValidateAsync(dto); + if (!validationResult.IsValid) + return Results.ValidationProblem(validationResult.ToDictionary()); + } + + // Validation passed – continue to the actual handler. + return await next(context); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 8879e01..0ad00ef 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -41,12 +41,13 @@ public class RealmServiceTests : IClassFixture RealmCreateResponse val = ResultAssert.Success(response); if (realmId.HasValue) - Assert.Equal(realmId, val.Realm.Id); + Assert.Equal(realmId, val.Id); else - Assert.NotEqual(Guid.Empty, val.Realm.Id); + Assert.NotEqual(Guid.Empty, val.Id); - Assert.Equal("slug", val.Realm.Slug); - Assert.Equal("New realm", val.Realm.Name); - Assert.NotEmpty(val.Realm.PrivateKeyEncrypted); + Assert.Equal("slug", val.Slug); + Assert.Equal("New realm", val.Name); + + // TODO verify data has been stored! } } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs index 9457aab..fab91aa 100644 --- a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs +++ b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs @@ -1,3 +1,3 @@ namespace IdentityShroud.Core.Messages.Realm; -public record RealmCreateRequest(Guid? Id, string Slug, string Description); \ No newline at end of file +public record RealmCreateRequest(Guid? Id, string? Slug, string Name); \ No newline at end of file diff --git a/IdentityShroud.Core/Helpers/SlugHelper.cs b/IdentityShroud.Core/Helpers/SlugHelper.cs new file mode 100644 index 0000000..0c74455 --- /dev/null +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -0,0 +1,85 @@ +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentityShroud.Core.Helpers; + +public static class SlugHelper +{ + public static string GenerateSlug(string text, int maxLength = 40) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // Normalize to decomposed form (separates accents from letters) + string normalized = text.Normalize(NormalizationForm.FormD); + + StringBuilder sb = new StringBuilder(normalized.Length); + bool lastWasHyphen = false; + + foreach (char c in normalized) + { + // Skip diacritics (accents) + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) + continue; + + char lower = char.ToLowerInvariant(c); + + // Convert valid characters + if ((lower >= 'a' && lower <= 'z') || (lower >= '0' && lower <= '9')) + { + sb.Append(lower); + lastWasHyphen = false; + } + // Convert spaces, underscores, and other separators to hyphen + else if (char.IsWhiteSpace(c) || c == '_' || c == '-') + { + if (!lastWasHyphen && sb.Length > 0) + { + sb.Append('-'); + lastWasHyphen = true; + } + } + // Skip all other characters + } + + // Trim trailing hyphen if any + if (sb.Length > 0 && sb[sb.Length - 1] == '-') + sb.Length--; + + string slug = sb.ToString(); + + // Handle truncation with hash suffix for long strings + if (slug.Length > maxLength) + { + // Generate hash of original text + string hashSuffix = GenerateHashSuffix(text); + int contentLength = maxLength - hashSuffix.Length; + + // Truncate at word boundary if possible + int cutPoint = contentLength; + int lastHyphen = slug.LastIndexOf('-', contentLength - 1); + + if (lastHyphen > contentLength / 2) + cutPoint = lastHyphen; + + slug = slug.Substring(0, cutPoint).TrimEnd('-') + hashSuffix; + } + + return slug; + } + + private static string GenerateHashSuffix(string text) + { + using (var sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(text)); + + // Take first 4 bytes (will become ~5-6 base64url chars) + string base64Url = WebEncoders.Base64UrlEncode(hash, 0, 4); + return "-" + base64Url; + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/IRealmService.cs b/IdentityShroud.Core/Services/IRealmService.cs new file mode 100644 index 0000000..7a8ef79 --- /dev/null +++ b/IdentityShroud.Core/Services/IRealmService.cs @@ -0,0 +1,8 @@ +using IdentityShroud.Core.Messages.Realm; + +namespace IdentityShroud.Core.Services; + +public interface IRealmService +{ + Task> Create(RealmCreateRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 1989e93..50cb61d 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,23 +1,24 @@ using System.Security.Cryptography; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Services; -public record RealmCreateResponse(Realm Realm); +public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IEncryptionService encryptionService) + IEncryptionService encryptionService) : IRealmService { public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() { Id = request.Id ?? Guid.CreateVersion7(), - Slug = request.Slug, - Name = request.Description, + Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), + Name = request.Name, }; using RSA rsa = RSA.Create(2048); @@ -26,6 +27,7 @@ public class RealmService( db.Add(realm); await db.SaveChangesAsync(ct); - return new RealmCreateResponse(realm); + return new RealmCreateResponse( + realm.Id, realm.Slug, realm.Name); } } \ No newline at end of file diff --git a/IdentityShroud.sln b/IdentityShroud.sln index 7d9329f..b0de020 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -7,12 +7,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core.Tests", "IdentityShroud.Core.Tests\IdentityShroud.Core.Tests.csproj", "{DC887623-8680-4D3B-B23A-D54F7DA91891}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{576359FF-C672-4CC3-A683-3BB9D647E75D}" - ProjectSection(SolutionItems) = preProject - compose.yaml = compose.yaml - EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", "IdentityShroud.Migrations\IdentityShroud.Migrations.csproj", "{DEECABE3-8934-4696-B0E1-48738DD0CEC4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,5 +34,9 @@ Global {DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Debug|Any CPU.Build.0 = Debug|Any CPU {DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.ActiveCfg = Release|Any CPU {DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.Build.0 = Release|Any CPU + {4758FE2E-A437-44F0-B58E-09E52D67D288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4758FE2E-A437-44F0-B58E-09E52D67D288}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4758FE2E-A437-44F0-B58E-09E52D67D288}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4758FE2E-A437-44F0-B58E-09E52D67D288}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 125edc1..21dd76b 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -7,6 +7,7 @@ ForceIncluded ForceIncluded ForceIncluded + /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">