diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs deleted file mode 100644 index db984f1..0000000 --- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using IdentityShroud.Core; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Tests.Fixtures; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace IdentityShroud.Api.Tests.Apis; - -public class ClientApiTests : IClassFixture -{ - private readonly ApplicationFactory _factory; - - public ClientApiTests(ApplicationFactory factory) - { - _factory = factory; - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - if (!db.Database.EnsureCreated()) - { - db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); - } - } - - [Theory] - [InlineData(null, false, "ClientId")] - [InlineData("", false, "ClientId")] - [InlineData("my-client", true, "")] - public async Task Create_Validation(string? clientId, bool succeeds, string fieldName) - { - // setup - Realm realm = await CreateRealmAsync("test-realm", "Test Realm"); - - var client = _factory.CreateClient(); - - // act - var response = await client.PostAsync( - $"/api/v1/realms/{realm.Id}/clients", - JsonContent.Create(new { ClientId = clientId }), - TestContext.Current.CancellationToken); - -#if DEBUG - string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); -#endif - - if (succeeds) - { - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - } - else - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var problemDetails = - await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - - Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); - } - } - - [Fact] - public async Task Create_Success_ReturnsCreatedWithLocation() - { - // setup - Realm realm = await CreateRealmAsync("create-realm", "Create Realm"); - - var client = _factory.CreateClient(); - - // act - var response = await client.PostAsync( - $"/api/v1/realms/{realm.Id}/clients", - JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }), - TestContext.Current.CancellationToken); - -#if DEBUG - string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); -#endif - - // verify - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - - Assert.NotNull(body); - Assert.Equal("new-client", body.ClientId); - Assert.True(body.Id > 0); - } - - [Fact] - public async Task Create_UnknownRealm_ReturnsNotFound() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsync( - $"/api/v1/realms/{Guid.NewGuid()}/clients", - JsonContent.Create(new { ClientId = "some-client" }), - TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Get_Success() - { - // setup - Realm realm = await CreateRealmAsync("get-realm", "Get Realm"); - Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client"); - - var httpClient = _factory.CreateClient(); - - // act - var response = await httpClient.GetAsync( - $"/api/v1/realms/{realm.Id}/clients/{dbClient.Id}", - TestContext.Current.CancellationToken); - -#if DEBUG - string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); -#endif - - // verify - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - - Assert.NotNull(body); - Assert.Equal(dbClient.Id, body.Id); - Assert.Equal("get-client", body.ClientId); - Assert.Equal("Get Client", body.Name); - Assert.Equal(realm.Id, body.RealmId); - } - - [Fact] - public async Task Get_UnknownClient_ReturnsNotFound() - { - // setup - Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); - - var httpClient = _factory.CreateClient(); - - // act - var response = await httpClient.GetAsync( - $"/api/v1/realms/{realm.Id}/clients/99999", - TestContext.Current.CancellationToken); - - // verify - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - private async Task CreateRealmAsync(string slug, string name) - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var realm = new Realm { Slug = slug, Name = name }; - db.Realms.Add(realm); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - return realm; - } - - private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var client = new Client - { - RealmId = realm.Id, - ClientId = clientId, - Name = name, - CreatedAt = DateTime.UtcNow, - }; - db.Clients.Add(client); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - return client; - } -} diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index ecc46c0..7e6192e 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,35 +1,16 @@ using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Text.Json.Nodes; -using IdentityShroud.Core; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; +using FluentResults; +using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; +using NSubstitute.ClearExtensions; namespace IdentityShroud.Api.Tests.Apis; -public class RealmApisTests : IClassFixture +public class RealmApisTests(ApplicationFactory factory) : IClassFixture { - private readonly ApplicationFactory _factory; - - public RealmApisTests(ApplicationFactory factory) - { - _factory = factory; - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - if (!db.Database.EnsureCreated()) - { - db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); - } - } - [Theory] [InlineData(null, null, null, false, "Name")] [InlineData(null, null, "Foo", true, "")] @@ -41,129 +22,40 @@ public class RealmApisTests : IClassFixture [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(); + 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); - - // act - var response = await client.PostAsync("/api/v1/realms", JsonContent.Create(new + 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 - + 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()); + 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); + 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()); + await factory.RealmService.DidNotReceive().Create( + Arg.Any(), + Arg.Any()); } } - - [Fact] - public async Task GetOpenIdConfiguration_Success() - { - // setup - await ScopedContextAsync(async db => - { - db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo" }); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - }); - - // act - var client = _factory.CreateClient(); - var response = await client.GetAsync("auth/realms/foo/.well-known/openid-configuration", - TestContext.Current.CancellationToken); - - // verify -#if DEBUG - string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); -#endif - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var result = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(result); - JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/auth", result, "authorization_endpoint"); - JsonObjectAssert.Equal("http://localhost/auth/realms/foo", result, "issuer"); - JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/token", result, "token_endpoint"); - JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/jwks", result, "jwks_uri"); - } - - [Theory] - [InlineData("")] - [InlineData("bar")] - public async Task GetOpenIdConfiguration_NotFound(string slug) - { - // act - var client = _factory.CreateClient(); - var response = await client.GetAsync($"/realms/{slug}/.well-known/openid-configuration", - TestContext.Current.CancellationToken); - - // verify - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task GetJwks() - { - // setup - IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); - - using var rsa = RSA.Create(2048); - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - RealmKey realmKey = new() - { - Id = Guid.NewGuid(), - KeyType = "RSA", - Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), - CreatedAt = DateTime.UtcNow, - }; - - await ScopedContextAsync(async db => - { - db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]}); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - }); - - // act - var client = _factory.CreateClient(); - var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", - TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonObject? payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - - Assert.NotNull(payload); - JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid"); - JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n"); - JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e"); - } - - private async Task ScopedContextAsync( - Func action - ) - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await action(db); - } } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 9846559..6135df6 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -1,56 +1,24 @@ +using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Testcontainers.PostgreSql; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestPlatform.TestHost; namespace IdentityShroud.Core.Tests.Fixtures; -public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime +public class ApplicationFactory : WebApplicationFactory { - private readonly PostgreSqlContainer _postgresqlServer; + public IRealmService RealmService { get; } = Substitute.For(); -// public IRealmService RealmService { get; } = Substitute.For(); - - public ApplicationFactory() - { - _postgresqlServer = new PostgreSqlBuilder("postgres:18.1") - .WithName($"is-applicationFactory-{Guid.NewGuid():N}") - .Build(); - } - protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); - builder.ConfigureAppConfiguration((context, configBuilder) => + builder.ConfigureServices(services => { - configBuilder.AddInMemoryCollection( - new Dictionary - { - ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["secrets:master:0:Id"] = "94970f27-3d88-4223-9940-7dd57548f5b5", - ["secrets:master:0:Active"] = "true", - ["secrets:master:0:Algorithm"] = "AES", - ["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", - }); + services.AddScoped(c => RealmService); }); - // builder.ConfigureServices(services => - // { - // services.AddScoped(c => RealmService); - // }); - builder.UseEnvironment("Development"); } - - public async ValueTask InitializeAsync() - { - await _postgresqlServer.StartAsync(); - } - - public override async ValueTask DisposeAsync() - { - await _postgresqlServer.StopAsync(); - await base.DisposeAsync(); - } } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj index a3aa6a8..6351c40 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -26,7 +26,6 @@ - diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs deleted file mode 100644 index f423f54..0000000 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Buffers.Text; -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Security.Keys; -using IdentityShroud.Core.Services; -using IdentityShroud.TestUtils.Substitutes; - -namespace IdentityShroud.Api.Tests.Mappers; - -public class KeyServiceTests -{ - private readonly NullDekEncryptionService _dekEncryptionService = new(); - - [Fact] - public void Test() - { - // Setup - using RSA rsa = RSA.Create(2048); - - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - DekId kid = DekId.NewId(); - - RealmKey realmKey = new() - { - Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), - KeyType = "RSA", - Key = new(_dekEncryptionService.KeyId, rsa.ExportPkcs8PrivateKey()), - CreatedAt = DateTime.UtcNow, - Priority = 10, - }; - - // Act - KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService()); - var jwk = sut.CreateJsonWebKey(realmKey); - - Assert.NotNull(jwk); - Assert.Equal("RSA", jwk.KeyType); - Assert.Equal(realmKey.Id.ToString(), jwk.KeyId); - Assert.Equal("sig", jwk.Use); - Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent)); - Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus)); - } -} diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs deleted file mode 100644 index e595e34..0000000 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ /dev/null @@ -1,73 +0,0 @@ -using FluentResults; -using IdentityShroud.Api.Mappers; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace IdentityShroud.Api; - - - -public record ClientCreateReponse(int Id, string ClientId); - -/// -/// The part of the api below realms/{slug}/clients -/// -public static class ClientApi -{ - public const string ClientGetRouteName = "ClientGet"; - - public static void MapEndpoints(this IEndpointRouteBuilder erp) - { - RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); - - clientsGroup.MapPost("", ClientCreate) - .Validate() - .WithName("ClientCreate") - .Produces(StatusCodes.Status201Created); - - var clientIdGroup = clientsGroup.MapGroup("{clientId}") - .AddEndpointFilter(); - - clientIdGroup.MapGet("", ClientGet) - .WithName(ClientGetRouteName); - } - - private static Ok ClientGet( - Guid realmId, - int clientId, - HttpContext context) - { - Client client = (Client)context.Items["ClientEntity"]!; - return TypedResults.Ok(new ClientMapper().ToDto(client)); - } - - private static async Task, InternalServerError>> - ClientCreate( - Guid realmId, - ClientCreateRequest request, - [FromServices] IClientService service, - HttpContext context, - CancellationToken cancellationToken) - { - Realm realm = context.GetValidatedRealm(); - Result result = await service.Create(realm.Id, request, cancellationToken); - - if (result.IsFailed) - { - throw new NotImplementedException(); - } - - Client client = result.Value; - - return TypedResults.CreatedAtRoute( - new ClientCreateReponse(client.Id, client.ClientId), - ClientGetRouteName, - new RouteValueDictionary() - { - ["realmId"] = realm.Id, - ["clientId"] = client.Id, - }); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs deleted file mode 100644 index 80b5f13..0000000 --- a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace IdentityShroud.Api; - -public record ClientRepresentation -{ - public int Id { get; set; } - public Guid RealmId { get; set; } - public required string ClientId { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } - - public string? SignatureAlgorithm { get; set; } - - public bool AllowClientCredentialsFlow { get; set; } = false; - - public required DateTime CreatedAt { get; set; } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index 3c47b48..0000000 --- a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace IdentityShroud.Api; - -public static class EndpointRouteBuilderExtensions -{ - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); - - public static void MapApis(this IEndpointRouteBuilder erp) - { - RealmApi.MapRealmEndpoints(erp); - - OpenIdEndpoints.MapEndpoints(erp); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs deleted file mode 100644 index 771be81..0000000 --- a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Api; - -public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter -{ - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - Guid realmId = context.Arguments.OfType().First(); - int id = context.Arguments.OfType().First(); - Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted); - if (client is null) - { - return Results.NotFound(); - } - context.HttpContext.Items["ClientEntity"] = client; - - return await next(context); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs deleted file mode 100644 index 97a1bb9..0000000 --- a/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Api; - -public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter -{ - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - Guid id = context.Arguments.OfType().First(); - Realm? realm = await realmService.FindById(id, context.HttpContext.RequestAborted); - if (realm is null) - { - return Results.NotFound(); - } - context.HttpContext.Items["RealmEntity"] = realm; - - return await next(context); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs deleted file mode 100644 index 75338e1..0000000 --- a/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Api; - -/// -/// Note the filter depends on the slug path parameter to be the first string argument on the context. -/// The endpoint handlers should place path arguments first and in order of the path to ensure this works -/// consistently. -/// -/// -public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter -{ - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - string realmSlug = context.Arguments.OfType().FirstOrDefault() - ?? throw new InvalidOperationException("Expected argument missing, ensure you include path parameters in your handlers signature even when you don't use them"); - Realm? realm = await realmService.FindBySlug(realmSlug, context.HttpContext.RequestAborted); - if (realm is null) - { - return Results.NotFound(); - } - context.HttpContext.Items["RealmEntity"] = realm; - - return await next(context); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs deleted file mode 100644 index 8e58717..0000000 --- a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs +++ /dev/null @@ -1,11 +0,0 @@ -using IdentityShroud.Core.Model; -using Riok.Mapperly.Abstractions; - -namespace IdentityShroud.Api.Mappers; - -[Mapper] -public partial class ClientMapper -{ - [MapperIgnoreSource(nameof(Client.Secrets))] - public partial ClientRepresentation ToDto(Client client); -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs deleted file mode 100644 index 7155208..0000000 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Api.Mappers; - -public class KeyMapper(IKeyService keyService) -{ - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) - { - JsonWebKeySet wks = new(); - foreach (var k in keys) - { - var wk = keyService.CreateJsonWebKey(k); - if (wk is {}) - { - wks.Keys.Add(wk); - } - } - return wks; - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs deleted file mode 100644 index 6565413..0000000 --- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs +++ /dev/null @@ -1,72 +0,0 @@ -using IdentityShroud.Api.Mappers; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace IdentityShroud.Api; - -public static class OpenIdEndpoints -{ - // openid: auth/realms/{realmSlug}/.well-known/openid-configuration - // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) - - - public static void MapEndpoints(this IEndpointRouteBuilder erp) - { - var realmsGroup = erp.MapGroup("/auth/realms"); - - var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") - .AddEndpointFilter(); - realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); - - var openidConnect = realmSlugGroup.MapGroup("openid-connect"); - openidConnect.MapPost("auth", OpenIdConnectAuth); - openidConnect.MapPost("token", OpenIdConnectToken); - openidConnect.MapGet("jwks", OpenIdConnectJwks); - } - - private static async Task> GetOpenIdConfiguration( - string realmSlug, - [FromServices]IRealmService realmService, - HttpContext context) - { - Realm realm = context.GetValidatedRealm(); - - var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; - var searchString = $"realms/{realmSlug}"; - int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); - string baseUri = s.Substring(0, index + searchString.Length); - - return TypedResults.Json(new OpenIdConfiguration() - { - AuthorizationEndpoint = baseUri + "/openid-connect/auth", - TokenEndpoint = baseUri + "/openid-connect/token", - Issuer = baseUri, - JwksUri = baseUri + "/openid-connect/jwks", - }, AppJsonSerializerContext.Default.OpenIdConfiguration); - } - - private static async Task, BadRequest>> OpenIdConnectJwks( - string realmSlug, - [FromServices]IRealmService realmService, - [FromServices]KeyMapper keyMapper, - HttpContext context) - { - Realm realm = context.GetValidatedRealm(); - await realmService.LoadActiveKeys(realm); - return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); - } - - private static Task OpenIdConnectToken(HttpContext context) - { - throw new NotImplementedException(); - } - - private static Task OpenIdConnectAuth(HttpContext context) - { - throw new NotImplementedException(); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 88a5179..265fbb9 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,39 +1,31 @@ -using IdentityShroud.Core.Contracts; +using FluentResults; +using IdentityShroud.Api.Validation; +using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; -using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace IdentityShroud.Api; -public static class HttpContextExtensions -{ - public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!; -} - -// api: api/v1/realms/{realmId}/.... -// api: api/v1/realms/{realmId}/clients/{clientId} - - - public static class RealmApi { - public static void MapRealmEndpoints(IEndpointRouteBuilder erp) + public static void MapRealmEndpoints(this IEndpointRouteBuilder app) { - var realmsGroup = erp.MapGroup("/api/v1/realms"); + var realmsGroup = app.MapGroup("/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmIdGroup = realmsGroup.MapGroup("{realmId}") - .AddEndpointFilter(); - - ClientApi.MapEndpoints(realmIdGroup); - - + var realmSlugGroup = app.MapGroup("{slug}"); + realmSlugGroup.MapGet("", GetRealmInfo); + realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var openidConnect = realmSlugGroup.MapGroup("openid-connect"); + openidConnect.MapPost("auth", OpenIdConnectAuth); + openidConnect.MapPost("token", OpenIdConnectToken); + openidConnect.MapGet("jwks", OpenIdConnectJwks); } private static async Task, InternalServerError>> @@ -46,4 +38,78 @@ public static class RealmApi // TODO make helper to convert failure response to a proper HTTP result. return TypedResults.InternalServerError(); } + + private static Task OpenIdConnectJwks(HttpContext context) + { + throw new NotImplementedException(); + } + + private static Task OpenIdConnectToken(HttpContext context) + { + throw new NotImplementedException(); + } + + private static Task OpenIdConnectAuth(HttpContext context) + { + throw new NotImplementedException(); + } + + private static async Task, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context) + { + if (string.IsNullOrEmpty(slug)) + return TypedResults.BadRequest(); + var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var searchString = $"realms/{slug}"; + int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); + string baseUri = s.Substring(0, index + searchString.Length); + + return TypedResults.Json(new OpenIdConfiguration() + { + AuthorizationEndpoint = baseUri + "/openid-connect/auth", + TokenEndpoint = baseUri + "/openid-connect/token", + Issuer = baseUri, + JwksUri = baseUri + "/openid-connect/jwks", + }, AppJsonSerializerContext.Default.OpenIdConfiguration); + } + + private static string GetRealmInfo() + { + return "Hello World!"; + + /* keycloak returns this + { + "realm": "mpluskassa", + "public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1/qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ/UegS9ylobfIYXJsz0xmJK5ToFaSYa72D/Dyln7ROxudu8+zc70sz7bUKQ0/ktWRsiu76vY6Kr9+18PgaooPmb2QP8lS8IZEv+gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA+u7s/GrOLcLoLLgEnRRVwIDAQAB", + "token-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/protocol/openid-connect", + "account-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/account", + "tokens-not-before": 0 + } + */ + } + + // [HttpGet("")] + // public ActionResult Index() + // { + // return new JsonResult("Hello world!"); + // } + + // [HttpGet("{slug}/.well-known/openid-configuration")] + // public ActionResult GetOpenIdConfiguration( + // string slug, + // [FromServices]LinkGenerator linkGenerator) + // { + // var s = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}"; + // var searchString = $"realms/{slug}"; + // int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); + // string baseUri = s.Substring(0, index + searchString.Length); + // + // return new JsonResult(baseUri); + // } + + // [HttpPost("{slug}/protocol/openid-connect/token")] + // public ActionResult GetOpenIdConnectToken(string slug) + // + // { + // return new JsonResult("Hello world!"); + // } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs deleted file mode 100644 index 7666b36..0000000 --- a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentValidation; -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Api; - -public class ClientCreateRequestValidator : AbstractValidator -{ - // most of standard ascii minus the control characters and space - private const string ClientIdPattern = "^[\x21-\x7E]+"; - - private string[] AllowedAlgorithms = [ "RS256", "ES256" ]; - - public ClientCreateRequestValidator() - { - RuleFor(e => e.ClientId).NotEmpty().MaximumLength(40).Matches(ClientIdPattern); - RuleFor(e => e.Name).MaximumLength(80); - RuleFor(e => e.Description).MaximumLength(2048); - RuleFor(e => e.SignatureAlgorithm) - .Must(v => v is null || AllowedAlgorithms.Contains(v)) - .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null"); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index e7d90da..24d042f 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -1,9 +1,8 @@ using System.Text.Json.Serialization; using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Messages.Realm; +using Microsoft.Extensions.Diagnostics.HealthChecks; [JsonSerializable(typeof(OpenIdConfiguration))] -[JsonSerializable(typeof(RealmCreateRequest))] internal partial class AppJsonSerializerContext : JsonSerializerContext { } \ No newline at end of file diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 31f88b2..72b4639 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -17,7 +17,7 @@ - + diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings deleted file mode 100644 index c9c4f6a..0000000 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ /dev/null @@ -1,5 +0,0 @@ - - True - True - True - True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 29f6736..510c626 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,11 +1,9 @@ using FluentValidation; using IdentityShroud.Api; -using IdentityShroud.Api.Mappers; +using IdentityShroud.Api.Validation; using IdentityShroud.Core; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; -using IdentityShroud.Core.Security.Keys; -using IdentityShroud.Core.Services; using Serilog; using Serilog.Formatting.Json; @@ -36,21 +34,10 @@ void ConfigureBuilder(WebApplicationBuilder builder) // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi services.AddOpenApi(); services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); - services.AddHttpContextAccessor(); + services.AddValidatorsFromAssemblyContaining(); builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() @@ -65,8 +52,7 @@ void ConfigureApplication(WebApplication app) app.MapOpenApi(); } app.UseSerilogRequestLogging(); - app.MapApis(); - + app.MapRealmEndpoints(); // app.UseRouting(); // app.MapControllers(); } 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/Apis/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs similarity index 92% rename from IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs rename to IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs index 3e3a20a..8daa0a9 100644 --- a/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs +++ b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs @@ -1,7 +1,7 @@ using FluentValidation; using IdentityShroud.Core.Messages.Realm; -namespace IdentityShroud.Api; +namespace IdentityShroud.Api.Validation; public class RealmCreateRequestValidator : AbstractValidator { diff --git a/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs b/IdentityShroud.Api/Validation/ValidateFilter.cs similarity index 96% rename from IdentityShroud.Api/Apis/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Validation/ValidateFilter.cs index d621441..fbebd9d 100644 --- a/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs +++ b/IdentityShroud.Api/Validation/ValidateFilter.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace IdentityShroud.Api; +namespace IdentityShroud.Api.Validation; public class ValidateFilter : IEndpointFilter where T : class { diff --git a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs b/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs similarity index 100% rename from IdentityShroud.TestUtils/Asserts/ResultAssert.cs rename to IdentityShroud.Core.Tests/Asserts/ResultAssert.cs diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 844d4ca..573fb8b 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging.Abstractions; +using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; @@ -7,13 +8,23 @@ namespace IdentityShroud.Core.Tests.Fixtures; public class DbFixture : IAsyncLifetime { - private readonly PostgreSqlContainer _postgresqlServer; + private readonly IContainer _postgresqlServer; - public Db CreateDbContext(string dbName = "testdb") + private string ConnectionString => + $"Host={_postgresqlServer.Hostname};" + + $"Port={DbPort};" + + $"Username={Username};Password={Password}"; + + private string Username => "postgres"; + private string Password => "password"; + private string DbHostname => _postgresqlServer.Hostname; + private int DbPort => _postgresqlServer.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort); + + public Db CreateDbContext(string dbName) { var db = new Db(Options.Create(new() { - ConnectionString = _postgresqlServer.GetConnectionString(), + ConnectionString = ConnectionString + ";Database=" + dbName, LogSensitiveData = false, }), new NullLoggerFactory()); return db; @@ -22,7 +33,8 @@ public class DbFixture : IAsyncLifetime public DbFixture() { _postgresqlServer = new PostgreSqlBuilder("postgres:18.1") - .WithName("is-dbfixture-" + Guid.NewGuid().ToString("D")) + .WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D")) + .WithPassword(Password) .Build(); } @@ -38,7 +50,7 @@ public class DbFixture : IAsyncLifetime public NpgsqlConnection GetConnection(string dbname) { - string connString = _postgresqlServer.GetConnectionString() + string connString = ConnectionString + $";Database={dbname}"; var connection = new NpgsqlConnection(connString); connection.Open(); diff --git a/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs deleted file mode 100644 index 923a865..0000000 --- a/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using IdentityShroud.Core.Helpers; - -namespace IdentityShroud.Core.Tests.Helpers; - -public class Base64UrlConverterTests -{ - internal class Data - { - [JsonConverter(typeof(Base64UrlConverter))] - public byte[]? X { get; set; } - } - - [Fact] - public void Serialize() - { - Data d = new() { X = ">>>???"u8.ToArray() }; - string s = JsonSerializer.Serialize(d); - - Assert.Contains("\"Pj4-Pz8_\"", s); - } - - [Fact] - public void Deerialize() - { - var jsonstring = """ - { "X": "Pj4-Pz8_" } - """; - var d = JsonSerializer.Deserialize(jsonstring); - - Assert.Equal(">>>???", Encoding.UTF8.GetString(d.X)); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs b/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs deleted file mode 100644 index de94511..0000000 --- a/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using IdentityShroud.Core.Helpers; - -namespace IdentityShroud.Core.Tests.Helpers; - -public class SlugHelperTests -{ - [Theory] - [InlineData("", 40, "")] - [InlineData("test", 40, "test")] - [InlineData("Test", 40, "test")] - [InlineData("tést", 40, "test")] - [InlineData("foo_bar", 40, "foo-bar")] - [InlineData("foo bar", 40, "foo-bar")] - [InlineData("-foo", 40, "foo")] - [InlineData("foo-", 40, "foo")] - [InlineData("_foo", 40, "foo")] - [InlineData("foo_", 40, "foo")] - [InlineData("slug_would_be_too_long", 16, "slug-woul-frYeRw")] // not at word boundary - [InlineData("slug_would_be_too_long", 18, "slug-would-frYeRw")] // at word boundary - public void Test(string input, int max_length, string expected) - { - string result = SlugHelper.GenerateSlug(input, max_length); - - Assert.Equal(expected, result); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 8af08c1..3cb7db3 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -25,9 +25,7 @@ - - \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs deleted file mode 100644 index bf4d0a6..0000000 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using IdentityShroud.Core.Messages; -using Microsoft.AspNetCore.WebUtilities; - -namespace IdentityShroud.Core.Tests; - -public class JwtSignatureGeneratorTests -{ - - [Fact] - public void VerifySignatureValid() - { - using var rsa = RSA.Create(2048); - - string header = WebEncoders.Base64UrlEncode("fake header"u8.ToArray()); - string payload = WebEncoders.Base64UrlEncode("fake payload"u8.ToArray()); - var jwtString = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa); - - Assert.True(ValidateJwtSignature(jwtString, rsa)); - } - - /// - /// This test is to prove our signature verification code is correct. The inputs are - /// all from a production keycloak instance. - /// - [Fact] - public void ValidateKeycloakSignature() - { - string keycloakGeneratedJwt = - "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0.eyJleHAiOjE3NzEwNTQxMDksImlhdCI6MTc3MTA1MzgwOSwiYXV0aF90aW1lIjoxNzcxMDUzODA4LCJqdGkiOiI5MTEzZjEwNC03YzllLTQzNzItYmU4Yy03NDMwMmI1ZTU1NGUiLCJpc3MiOiJodHRwczovL2lhbS5rYXNzYWNsb3VkLm5sL2F1dGgvcmVhbG1zL21wbHVza2Fzc2EiLCJhdWQiOlsia2Fzc2EtbWFuYWdlbWVudC1zZXJ2aWNlIiwiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIiwiYWNjb3VudCJdLCJzdWIiOiIwOTNjY2YxNS1jNGE5LTRhYjQtOTcxZi1kNWEwMjIzNmQ4NWEiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJkZWFsZXJfc3VwcG9ydCIsInNpZCI6IjRiYjI0OGQ1LWVkNzktNGU0Yy05NWNjLTAwYzgzMzliZmZiMyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9tcGx1c2thc3NhLm9ubGluZSIsImh0dHBzOi8vd3d3Lm1wbHVza2Fzc2Euc3VwcG9ydCIsImh0dHBzOi8vbXBsdXNrYXNzYS5zdXBwb3J0IiwiaHR0cDovL2xvY2FsaG9zdDo0MDkwIiwiaHR0cHM6Ly93d3cubXBsdXNrYXNzYS5vbmxpbmUiLCJodHRwOi8vbG9jYWxob3N0IiwiLyoiLCJodHRwOi8vbG9jYWxob3N0OjQyMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbXBsdXNrYXNzYSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWFsZXItbWVkZXdlcmtlci1yb2xlIiwibXBsdXNrYXNzYS1tZWRld2Vya2VyLXJvbGUiXX0sInJlc291cmNlX2FjY2VzcyI6eyJhcGFjaGUyLWludHJhbmV0LWF1dGgiOnsicm9sZXMiOlsiaW50cmFuZXQiLCJyZWxlYXNlbm90ZXNfd3JpdGUiXX0sImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSI6eyJyb2xlcyI6WyJwb3NhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJkcmFmdF9saWNlbnNlX3dyaXRlIiwibGljZW5zZV9yZWFkIiwia25vd2xlZGdlSXRlbV9yZWFkIiwibWFpbGluZ19yZWFkIiwibXBsdXNhcGlfcmVhZCIsImRhdGFiYXNlX3VzZXJfd3JpdGUiLCJlbnZpcm9ubWVudF93cml0ZSIsImdrc19hdXRoY29kZV9yZWFkIiwiZW1wbG95ZWVfcmVhZCIsImRhdGFiYXNlX3VzZXJfcmVhZCIsImFwaWFjY291bnRfcGFzc3dvcmRyZXNldCIsIm1wbHVzYXBpX3dyaXRlIiwiZW52aXJvbm1lbnRfcmVhZCIsImtub3dsZWRnZUl0ZW1fd3JpdGUiLCJkYXRhYmFzZV91c2VyX3Bhc3N3b3JkX3JlYWQiLCJsaWNlbnNlX3dyaXRlIiwiY3VzdG9tZXJfd3JpdGUiLCJkZWFsZXJfcmVhZCIsImVtcGxveWVlX3dyaXRlIiwiZGF0YWJhc2VfY29uZmlndXJhdGlvbl93cml0ZSIsInJlbGF0aW9uc19yZWFkIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9tcGx1c19lbmNyeXB0ZWRfcmVhZCIsImRyYWZ0X2xpY2Vuc2VfcmVhZCIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fcmVhZCJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQga21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9.SHjVTWsFwiaKTxBX-0GZM1pK8rOodkYnEu_QJ4dlPpozai9j3RRJK3DswsuEbJC8PdQXI4-AI0-5JGBQi2gDXdFSVHhAblnmjva0sWCaY7lG2ASa65UKM_4RzH-6nvQ9EiZXdANzsWkLG350l-dLiqdt--Lpjpw2huK_GKAx20SKfauKBmm990rHzrl0Uii3wQ3fPHlAJ_8-WSnSBquOH8xsYJHa1LOsc2WqbEDnMA4hRnGvCoubwhkOANfWSx0OCwSIKBddrcts64ZAxFhmilZXGzWMqDkblY2fDU8_jrlysgYsymQlOVwwg7V5Ps-DJkGXWvmpncKfyYd3Vuwusg"; - string keycloakKeySet = """ - { - "keys": [ - { - "kid": "rmVwMNk3J5Xyf1hrKsUlEX7PMRn6vVJcHwSqX1EPFqI", - "kty": "RSA", - "alg": "RS256", - "use": "sig", - "n": "pYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1_qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ_UegS9ylobfIYXJsz0xmJK5ToFaSYa72D_Dyln7ROxudu8-zc70sz7bUKQ0_ktWRsiu76vY6Kr9-18PgaooPmb2QP8lS8IZEv-gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA-u7s_GrOLcLoLLgEnRRVw", - "e": "AQAB", - "x5c": [ - "MIICozCCAYsCBgFwfLC07DANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptcGx1c2thc3NhMB4XDTIwMDIyNTE0MTAyMFoXDTMwMDIyNTE0MTIwMFowFTETMBEGA1UEAwwKbXBsdXNrYXNzYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKWGywHjiwxMMy+LRMLhNi34rDgV6EKlgPUXT96YeprsBdRK34YgUiAdiREyn+xdTrIhdf6p7oeLg359EzvGyt5Unx05SB1kocGif1HoEvcpaG3yGFybM9MZiSuU6BWkmGu9g/w8pZ+0TsbnbvPs3O9LM+21CkNP5LVkbIru+r2Oiq/ftfD4GqKD5m9kD/JUvCGRL/oFuUi6qDHNQ3w/JbIodbHZ0PFuuXAbDZ3p5FnPewBfXDEeq3Q9rWXywMRBymGqIQC/RLELC4nNz9n9lGqkUwDr4KsmBOYciUTrez8nsovGEsu81w9dodpnNJq0qAPru7Pxqzi3C6Cy4BJ0UVcCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAYcJVYv8HzZQIMrqhIyu7EVihPAx0w9NaZ1xzB9qCHrwie6ZLQdnMm8l0IdehyYuY+0HK7FjC8dAcT4nklOQDg4iCp7ZrM7vFNP+60pR0i7aIbf0cFXy9VTOPvUsXmu+p1LqRQLRJD0BjO29gupTe68KyTtyuX5A7JfmCq84j5i45Md8A9MAMZWnXMSHiaZLtlOS/4t4cdc371uq9fH7SKusnvUY0d14+BzcrHi/eurhKUUjJZ1xclUE2trOXFrE78fSMUmeGbIlpAV0MtrJW7OmXmaHH8Q1wTm78RH/dn4EmWSoFcigdVcDge941/MT2soDSIGLUnYiYrW8d6HE2Lg==" - ], - "x5t": "rj9_q26MIdowvyJJbyHySeUl1y8", - "x5t#S256": "KNyQ8ngE925F__ZPJm-wCNUnGBJQGJbZGGjlCvmwBkM" - } - ] - } - """; - - JsonWebKeySet keySet = JsonSerializer.Deserialize(keycloakKeySet)!; - using RSA publicKey = LoadFromJwk(keySet.Keys[0]); - - Assert.True(ValidateJwtSignature(keycloakGeneratedJwt, publicKey)); - } - - private bool ValidateJwtSignature(string jwtString, RSA publicKey) - { - int lastDotIndex = jwtString.LastIndexOf('.'); - - return publicKey.VerifyData( - Encoding.UTF8.GetBytes(jwtString, 0, lastDotIndex), - WebEncoders.Base64UrlDecode(jwtString, lastDotIndex + 1, jwtString.Length - (lastDotIndex + 1)), - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - } - - private static RSA LoadFromJwk(JsonWebKey jwk) - { - var rsa = RSA.Create(); - var parameters = new RSAParameters - { - Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), - Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) - }; - - rsa.ImportParameters(parameters); - return rsa; - } - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Model/RealmTests.cs b/IdentityShroud.Core.Tests/Model/RealmTests.cs new file mode 100644 index 0000000..959d8b1 --- /dev/null +++ b/IdentityShroud.Core.Tests/Model/RealmTests.cs @@ -0,0 +1,51 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Tests.Model; + +public class RealmTests +{ + [Fact] + public void SetNewKey() + { + byte[] privateKey = [5, 6, 7, 8]; + byte[] encryptedPrivateKey = [1, 2, 3, 4]; + + var encryptionService = Substitute.For(); + encryptionService + .Encrypt(Arg.Any()) + .Returns(x => encryptedPrivateKey); + + Realm realm = new(); + realm.SetPrivateKey(encryptionService, privateKey); + + // should be able to return original without calling decrypt + Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + + encryptionService.Received(1).Encrypt(privateKey); + encryptionService.DidNotReceive().Decrypt(Arg.Any()); + } + + [Fact] + public void GetDecryptedKey() + { + byte[] privateKey = [5, 6, 7, 8]; + byte[] encryptedPrivateKey = [1, 2, 3, 4]; + + var encryptionService = Substitute.For(); + encryptionService + .Decrypt(encryptedPrivateKey) + .Returns(x => privateKey); + + Realm realm = new(); + realm.PrivateKeyEncrypted = encryptedPrivateKey; + + // should be able to return original without calling decrypt + Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + + encryptionService.Received(1).Decrypt(encryptedPrivateKey); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs b/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs new file mode 100644 index 0000000..6392676 --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using System.Text; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Tests.Security; + +public class AesGcmHelperTests +{ + [Fact] + public void EncryptDecryptCycleWorks() + { + string input = "Hello, world!"; + + var encryptionKey = RandomNumberGenerator.GetBytes(32); + + var cypher = AesGcmHelper.EncryptAesGcm(Encoding.UTF8.GetBytes(input), encryptionKey); + var output = AesGcmHelper.DecryptAesGcm(cypher, encryptionKey); + + Assert.Equal(input, Encoding.UTF8.GetString(output)); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs deleted file mode 100644 index 01851a4..0000000 --- a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text; -using IdentityShroud.Core.Security; -using Microsoft.Extensions.Configuration; - -namespace IdentityShroud.Core.Tests.Security; - -public class ConfigurationSecretProviderTests -{ - private static IConfiguration BuildConfigFromJson(string json) - { - // Convert the JSON string into a stream that the config builder can read. - var jsonBytes = Encoding.UTF8.GetBytes(json); - using var stream = new MemoryStream(jsonBytes); - - // Build the configuration just like the real app does, but from the stream. - var config = new ConfigurationBuilder() - .AddJsonStream(stream) // <-- reads from the in‑memory JSON - .Build(); - - return config; - } - - [Fact] - public void Test() - { - string jsonConfig = """ - { - "secrets": { - "master": [ - { - "Id": "5676d159-5495-4945-aa84-59ee694aa8a2", - "Active": true, - "Algorithm": "AES", - "Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo=" - }, - { - "Id": "b82489e7-a05a-4d64-b9a5-58d2f2c0dc39", - "Active": false, - "Algorithm": "AES", - "Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0=" - } - ] - } - } - """; - - - ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig)); - - // act - var keys = sut.GetKeys("master"); - - // verify - Assert.Equal(2, keys.Length); - var active = keys.Single(k => k.Active); - Assert.Equal(new Guid("5676d159-5495-4945-aa84-59ee694aa8a2"), active.Id.Id); - Assert.Equal("AES", active.Algorithm); - Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key); - - var inactive = keys.Single(k => !k.Active); - Assert.Equal(new Guid("b82489e7-a05a-4d64-b9a5-58d2f2c0dc39"), inactive.Id.Id); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs deleted file mode 100644 index d0269e6..0000000 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; -using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.TestUtils.Substitutes; -using Microsoft.EntityFrameworkCore; - -namespace IdentityShroud.Core.Tests.Services; - -public class ClientServiceTests : IClassFixture -{ - private readonly DbFixture _dbFixture; - private readonly NullDataEncryptionService _dataEncryptionService = new(); - - private readonly IClock _clock = Substitute.For(); - private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); - - public ClientServiceTests(DbFixture dbFixture) - { - _dbFixture = dbFixture; - using Db db = dbFixture.CreateDbContext(); - if (!db.Database.EnsureCreated()) - TruncateTables(db); - EnsureRealm(db); - } - - private void TruncateTables(Db db) - { - db.Database.ExecuteSqlRaw("TRUNCATE client CASCADE;"); - db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); - } - - private void EnsureRealm(Db db) - { - if (!db.Realms.Any(r => r.Id == _realmId)) - { - db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" }); - db.SaveChanges(); - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Create(bool allowClientCredentialsFlow) - { - // Setup - DateTime now = DateTime.UtcNow; - _clock.UtcNow().Returns(now); - - Client val; - await using (var db = _dbFixture.CreateDbContext()) - { - // Act - ClientService sut = new(db, _dataEncryptionService, _clock); - var response = await sut.Create( - _realmId, - new ClientCreateRequest - { - ClientId = "test-client", - Name = "Test Client", - Description = "A test client", - AllowClientCredentialsFlow = allowClientCredentialsFlow, - }, - TestContext.Current.CancellationToken); - - // Verify - val = ResultAssert.Success(response); - Assert.Equal(_realmId, val.RealmId); - Assert.Equal("test-client", val.ClientId); - Assert.Equal("Test Client", val.Name); - Assert.Equal("A test client", val.Description); - Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow); - Assert.Equal(now, val.CreatedAt); - } - - await using (var db = _dbFixture.CreateDbContext()) - { - var dbRecord = await db.Clients - .Include(e => e.Secrets) - .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - - if (allowClientCredentialsFlow) - Assert.Single(dbRecord.Secrets); - else - Assert.Empty(dbRecord.Secrets); - } - } - - [Theory] - [InlineData("existing-client", true)] - [InlineData("missing-client", false)] - public async Task GetByClientId(string clientId, bool shouldFind) - { - // Setup - _clock.UtcNow().Returns(DateTime.UtcNow); - await using (var setupContext = _dbFixture.CreateDbContext()) - { - setupContext.Clients.Add(new() - { - RealmId = _realmId, - ClientId = "existing-client", - CreatedAt = DateTime.UtcNow, - }); - - await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using var actContext = _dbFixture.CreateDbContext(); - // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); - Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); - - // Verify - if (shouldFind) - Assert.NotNull(result); - else - Assert.Null(result); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task FindById(bool shouldFind) - { - // Setup - _clock.UtcNow().Returns(DateTime.UtcNow); - int existingId; - await using (var setupContext = _dbFixture.CreateDbContext()) - { - Client client = new() - { - RealmId = _realmId, - ClientId = "find-by-id-client", - CreatedAt = DateTime.UtcNow, - }; - setupContext.Clients.Add(client); - await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); - existingId = client.Id; - } - - int searchId = shouldFind ? existingId : existingId + 9999; - - await using var actContext = _dbFixture.CreateDbContext(); - // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); - Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); - - // Verify - if (shouldFind) - Assert.NotNull(result); - else - Assert.Null(result); - } -} diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs deleted file mode 100644 index 4f88e48..0000000 --- a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Services; -using IdentityShroud.TestUtils.Substitutes; - -namespace IdentityShroud.Core.Tests.Services; - -public class DataEncryptionServiceTests -{ - private readonly IRealmContext _realmContext = Substitute.For(); - private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For(); - - private readonly DekId _activeDekId = DekId.NewId(); - private readonly DekId _secondDekId = DekId.NewId(); - private DataEncryptionService CreateSut() - => new(_realmContext, _dekCryptor); - - [Fact] - public void Encrypt_UsesActiveKey() - { - _realmContext.GetDeks(Arg.Any()).Returns([ - CreateRealmDek(_secondDekId, false), - CreateRealmDek(_activeDekId, true), - ]); - - var cipher = CreateSut().Encrypt("Hello"u8); - - Assert.Equal(_activeDekId, cipher.DekId); - } - - [Fact] - public void Decrypt_UsesCorrectKey() - { - var first = CreateRealmDek(_activeDekId, true); - _realmContext.GetDeks(Arg.Any()).Returns([ first ]); - - var sut = CreateSut(); - var cipher = sut.Encrypt("Hello"u8); - - // Deactivate original key - first.Active = false; - // Make new active - var second = CreateRealmDek(_secondDekId, true); - // Return both - _realmContext.GetDeks(Arg.Any()).Returns([ first, second ]); - - - var decoded = sut.Decrypt(cipher); - - Assert.Equal("Hello"u8, decoded); - } - - private RealmDek CreateRealmDek(DekId id, bool active) - => new() - { - Id = id, - Active = active, - Algorithm = "AES", - KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), - RealmId = default, - }; -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs deleted file mode 100644 index fc4a45f..0000000 --- a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core.Tests.Services; - -public class DekEncryptionServiceTests -{ - [Fact] - public void RoundtripWorks() - { - // Note this code will tend to only test the latest verion. - - // setup - byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - var secretProvider = Substitute.For(); - KeyEncryptionKey[] keys = - [ - new KeyEncryptionKey(KekId.NewId(), true, "AES", keyValue) - ]; - secretProvider.GetKeys("master").Returns(keys); - - - ReadOnlySpan input = "Hello, World!"u8; - - // act - DekEncryptionService sut = new(secretProvider); - EncryptedDek cipher = sut.Encrypt(input.ToArray()); - byte[] result = sut.Decrypt(cipher); - - // verify - Assert.Equal(input, result); - } - - [Fact] - public void DetectsCorruptInput() - { - // When introducing a new version we need version specific tests to - // make sure decoding of legacy data still works. - KekId kid = KekId.NewId(); - // setup - byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS - [ - 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, - 193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 - ]; - EncryptedDek secret = new(kid, cipher); - - byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - var secretProvider = Substitute.For(); - KeyEncryptionKey[] keys = - [ - new KeyEncryptionKey(kid, true, "AES", keyValue) - ]; - secretProvider.GetKeys("master").Returns(keys); - - // act - DekEncryptionService sut = new(secretProvider); - Assert.Throws( - () => sut.Decrypt(secret), - ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); - } - - [Fact] - public void DecodeSelectsRightKey() - { - // The key is marked inactive also it is the second key - - // setup - KekId kid1 = KekId.NewId(); - KekId kid2 = KekId.NewId(); - - byte[] cipher = - [ - 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, - 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 - ]; - EncryptedDek secret = new(kid1, cipher); - - byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); - var secretProvider = Substitute.For(); - KeyEncryptionKey[] keys = - [ - new KeyEncryptionKey(kid2, true, "AES", keyValue2), - new KeyEncryptionKey(kid1, false, "AES", keyValue1), - ]; - secretProvider.GetKeys("master").Returns(keys); - - // act - DekEncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(secret); - - // verify - Assert.Equal("Hello, World!"u8, result); - } - - [Fact] - public void EncryptionUsesActiveKey() - { - // setup - KekId kid1 = KekId.NewId(); - KekId kid2 = KekId.NewId(); - - byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); - var secretProvider = Substitute.For(); - KeyEncryptionKey[] keys = - [ - new KeyEncryptionKey(kid1, false, "AES", keyValue1), - new KeyEncryptionKey(kid2, true, "AES", keyValue2), - ]; - secretProvider.GetKeys("master").Returns(keys); - - ReadOnlySpan input = "Hello, World!"u8; - // act - DekEncryptionService sut = new(secretProvider); - EncryptedDek cipher = sut.Encrypt(input.ToArray()); - - // Verify - Assert.Equal(kid2, cipher.KekId); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs new file mode 100644 index 0000000..e97b2df --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core.Tests.Services; + +public class EncryptionServiceTests +{ + [Fact] + public void RoundtripWorks() + { + // setup + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + EncryptionService sut = new(key); + byte[] input = RandomNumberGenerator.GetBytes(16); + + // act + var cipher = sut.Encrypt(input); + var result = sut.Decrypt(cipher); + + Assert.Equal(input, result); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs deleted file mode 100644 index 2dfbb52..0000000 --- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core.Tests.Services; - -public class EncryptionTests -{ - [Fact] - public void DecodeV1_Success() - { - // When introducing a new version we need version specific tests to - // make sure decoding of legacy data still works. - - // setup - byte[] cipher = - [ - 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, - 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 - ]; - byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - - // act - byte[] result = Encryption.Decrypt(cipher, keyValue); - - // verify - Assert.Equal("Hello, World!"u8, result); - } - - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index fda233e..0ad00ef 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,29 +1,26 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Security.Keys; +using FluentResults; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.Core.Tests.Substitutes; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { - private readonly DbFixture _dbFixture; - private readonly IKeyService _keyService = Substitute.For(); + private readonly Db _db; public RealmServiceTests(DbFixture dbFixture) { - _dbFixture = dbFixture; - using Db db = dbFixture.CreateDbContext(); - if (!db.Database.EnsureCreated()) - TruncateTables(db); + _db = dbFixture.CreateDbContext("realmservice"); + + if (!_db.Database.EnsureCreated()) + TruncateTables(); } - private void TruncateTables(Db db) + private void TruncateTables() { - db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + _db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); } [Theory] @@ -31,113 +28,26 @@ public class RealmServiceTests : IClassFixture [InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")] public async Task Create(string? idString) { - // Setup Guid? realmId = null; if (idString is not null) realmId = new(idString); - - RealmCreateResponse? val; - await using (var db = _dbFixture.CreateDbContext()) - { - _keyService.CreateKey(Arg.Any()) - .Returns(new RealmKey() - { - Id = Guid.NewGuid(), - KeyType = "TST", - Key = new(KekId.NewId(), [21]), - CreatedAt = DateTime.UtcNow - }); - // Act - RealmService sut = new(db, _keyService); - var response = await sut.Create( - new(realmId, "slug", "New realm"), - TestContext.Current.CancellationToken); - - // Verify - val = ResultAssert.Success(response); - if (realmId.HasValue) - Assert.Equal(realmId, val.Id); - else - Assert.NotEqual(Guid.Empty, val.Id); - - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); - - _keyService.Received().CreateKey(Arg.Any()); - } - - await using (var db = _dbFixture.CreateDbContext()) - { - var dbRecord = await db.Realms - .Include(e => e.Keys) - .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - Assert.Equal("TST", dbRecord.Keys[0].KeyType); - } - } - - [Theory] - [InlineData("slug", null)] - [InlineData("foo", "Foo")] - public async Task FindBySlug(string slug, string? name) - { - await using (var setupContext = _dbFixture.CreateDbContext()) - { - setupContext.Realms.Add(new() - { - Slug = "foo", - Name = "Foo", - }); - setupContext.Realms.Add(new() - { - Slug = "bar", - Name = "Bar", - }); - - await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using var actContext = _dbFixture.CreateDbContext(); - // Act - RealmService sut = new(actContext, _keyService); - var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); - - // Verify - Assert.Equal(name, result?.Name); - } - - [Theory] - [InlineData("b0423bba-2411-497b-a5b6-c5adf404b862", true)] - [InlineData("65ac9dba-6d43-4fa4-b57f-133ed639fbcb", false)] - public async Task FindById(string idString, bool shouldFind) - { - Guid id = new(idString); - await using (var setupContext = _dbFixture.CreateDbContext()) - { - setupContext.Realms.Add(new() - { - Id = new("b0423bba-2411-497b-a5b6-c5adf404b862"), - Slug = "foo", - Name = "Foo", - }); - setupContext.Realms.Add(new() - { - Id = new("d4ffc7d0-7b2c-4f02-82b9-a74610435b0d"), - Slug = "bar", - Name = "Bar", - }); - - await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using var actContext = _dbFixture.CreateDbContext(); - // Act - RealmService sut = new(actContext, _keyService); - Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); - - // Verify - if (shouldFind) - Assert.NotNull(result); - else - Assert.Null(result); + + var encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + RealmService sut = new(_db, encryptionService); + + var response = await sut.Create( + new(realmId, "slug", "New realm"), + TestContext.Current.CancellationToken); + + RealmCreateResponse val = ResultAssert.Success(response); + if (realmId.HasValue) + Assert.Equal(realmId, val.Id); + else + Assert.NotEqual(Guid.Empty, val.Id); + + 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.Tests/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.Core.Tests/Substitutes/EncryptionServiceSubstitute.cs new file mode 100644 index 0000000..cf79318 --- /dev/null +++ b/IdentityShroud.Core.Tests/Substitutes/EncryptionServiceSubstitute.cs @@ -0,0 +1,18 @@ +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Tests.Substitutes; + +public static class EncryptionServiceSubstitute +{ + public static IEncryptionService CreatePassthrough() + { + var encryptionService = Substitute.For(); + encryptionService + .Encrypt(Arg.Any()) + .Returns(x => x.ArgAt(0)); + encryptionService + .Decrypt(Arg.Any()) + .Returns(x => x.ArgAt(0)); + return encryptionService; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 7506fd0..a998f4a 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using IdentityShroud.Core.DTO; +using IdentityShroud.Core.Messages; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; @@ -35,6 +35,7 @@ public class UnitTest1 // Option 3: Generate a new key for testing rsa.KeySize = 2048; + // Your already encoded header and payload string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0"; string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9"; @@ -50,15 +51,6 @@ public class UnitTest1 // Or generate complete JWT // string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa); // Console.WriteLine($"Complete JWT: {completeJwt}"); - - rsa.ExportRSAPublicKey(); // PKCS#1 - } - - using (ECDsa dsa = ECDsa.Create()) - { - dsa.ExportPkcs8PrivateKey(); - - dsa.ExportSubjectPublicKeyInfo(); // x509 } } } @@ -74,9 +66,9 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))), Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))), Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) }; } @@ -102,5 +94,14 @@ public static class RsaKeyLoader string pemContent = System.IO.File.ReadAllText(filePath); return LoadFromPem(pemContent); } - + + /// + /// Load RSA private key from PKCS#8 format + /// + public static RSA LoadFromPkcs8(byte[] pkcs8Key) + { + var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(pkcs8Key, out _); + return rsa; + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs deleted file mode 100644 index 20e270c..0000000 --- a/IdentityShroud.Core/Contracts/IClientService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Contracts; - -public interface IClientService -{ - Task> Create( - Guid realmId, - ClientCreateRequest request, - CancellationToken ct = default); - - Task GetByClientId(Guid realmId, string clientId, CancellationToken ct = default); - Task FindById(Guid realmId, int id, CancellationToken ct = default); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IClock.cs b/IdentityShroud.Core/Contracts/IClock.cs deleted file mode 100644 index 4ba7766..0000000 --- a/IdentityShroud.Core/Contracts/IClock.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IdentityShroud.Core.Contracts; - -public interface IClock -{ - DateTime UtcNow(); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs deleted file mode 100644 index 2810aaa..0000000 --- a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Contracts; - -public interface IDataEncryptionService -{ - EncryptedValue Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedValue input); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs deleted file mode 100644 index 3032040..0000000 --- a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Contracts; - - - -public interface IDekEncryptionService -{ - EncryptedDek Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedDek input); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs new file mode 100644 index 0000000..f85487d --- /dev/null +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -0,0 +1,7 @@ +namespace IdentityShroud.Core.Contracts; + +public interface IEncryptionService +{ + byte[] Encrypt(byte[] plain); + byte[] Decrypt(byte[] cipher); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs deleted file mode 100644 index 4f6b5f7..0000000 --- a/IdentityShroud.Core/Contracts/IKeyService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security.Keys; - -namespace IdentityShroud.Core.Contracts; - -public interface IKeyService -{ - RealmKey CreateKey(KeyPolicy policy); - - JsonWebKey? CreateJsonWebKey(RealmKey realmKey); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmContext.cs b/IdentityShroud.Core/Contracts/IRealmContext.cs deleted file mode 100644 index c757a02..0000000 --- a/IdentityShroud.Core/Contracts/IRealmContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Contracts; - -public interface IRealmContext -{ - public Realm GetRealm(); - Task> GetDeks(CancellationToken ct = default); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs deleted file mode 100644 index 4598b97..0000000 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using IdentityShroud.Core.Messages.Realm; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core.Contracts; - -public interface IRealmService -{ - Task FindById(Guid id, CancellationToken ct = default); - Task FindBySlug(string slug, CancellationToken ct = default); - - Task> Create(RealmCreateRequest request, CancellationToken ct = default); - Task LoadActiveKeys(Realm realm); - Task LoadDeks(Realm realm); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index 4d4182e..73cd3a6 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -1,14 +1,6 @@ -using IdentityShroud.Core.Security; - namespace IdentityShroud.Core.Contracts; public interface ISecretProvider { - string GetSecret(string name); - - /// - /// Should return one active key, might return inactive keys. - /// - /// - KeyEncryptionKey[] GetKeys(string name); + string GetSecretAsync(string name); } diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs deleted file mode 100644 index a162131..0000000 --- a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace IdentityShroud.Core.Contracts; - -public class ClientCreateRequest -{ - public required string ClientId { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } - public string? SignatureAlgorithm { get; set; } - public bool? AllowClientCredentialsFlow { get; set; } -} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs index 4f16955..e22a899 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,49 +1,34 @@ using System.Text.Json.Serialization; -using IdentityShroud.Core.Helpers; namespace IdentityShroud.Core.Messages; -// https://www.rfc-editor.org/rfc/rfc7517.html - - public class JsonWebKey { [JsonPropertyName("kty")] public string KeyType { get; set; } = "RSA"; - // Common values sig(nature) enc(ryption) [JsonPropertyName("use")] - public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption + public string Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption - // Per standard this field is optional, commented out for now as it seems not - // have any good use in an identity server. Anyone validating tokens should use - // the algorithm specified in the header of the token. - // [JsonPropertyName("alg")] - // public string? Algorithm { get; set; } = "RS256"; + [JsonPropertyName("alg")] + public string Algorithm { get; set; } = "RS256"; [JsonPropertyName("kid")] - public required string KeyId { get; set; } + public string KeyId { get; set; } // RSA Public Key Components [JsonPropertyName("n")] - public string? Modulus { get; set; } + public string Modulus { get; set; } [JsonPropertyName("e")] - public string? Exponent { get; set; } - - // ECdsa - public string? Curve { get; set; } - [JsonConverter(typeof(Base64UrlConverter))] - public byte[]? X { get; set; } - [JsonConverter(typeof(Base64UrlConverter))] - public byte[]? Y { get; set; } + public string Exponent { get; set; } // Optional fields - // [JsonPropertyName("x5c")] - // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - // public List? X509CertificateChain { get; set; } - // - // [JsonPropertyName("x5t")] - // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - // public string? X509CertificateThumbprint { get; set; } + [JsonPropertyName("x5c")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List X509CertificateChain { get; set; } + + [JsonPropertyName("x5t")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string X509CertificateThumbprint { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebToken.cs b/IdentityShroud.Core/DTO/JsonWebToken.cs index 75b7dae..65d671f 100644 --- a/IdentityShroud.Core/DTO/JsonWebToken.cs +++ b/IdentityShroud.Core/DTO/JsonWebToken.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace IdentityShroud.Core.DTO; +namespace IdentityShroud.Core.Messages; public class JsonWebTokenHeader { @@ -9,32 +9,31 @@ public class JsonWebTokenHeader [JsonPropertyName("typ")] public string Type { get; set; } = "JWT"; [JsonPropertyName("kid")] - public required string KeyId { get; set; } + public string KeyId { get; set; } } -// public class JsonWebTokenPayload { [JsonPropertyName("iss")] - public string? Issuer { get; set; } + public string Issuer { get; set; } [JsonPropertyName("aud")] - public string[]? Audience { get; set; } + public string[] Audience { get; set; } [JsonPropertyName("sub")] - public string? Subject { get; set; } + public string Subject { get; set; } [JsonPropertyName("exp")] - public long? Expires { get; set; } + public long Expires { get; set; } [JsonPropertyName("iat")] - public long? IssuedAt { get; set; } + public long IssuedAt { get; set; } [JsonPropertyName("nbf")] - public long? NotBefore { get; set; } + public long NotBefore { get; set; } [JsonPropertyName("jti")] - public Guid? JwtId { get; set; } + public Guid JwtId { get; set; } } public class JsonWebToken { - public required JsonWebTokenHeader Header { get; set; } - public required JsonWebTokenPayload Payload { get; set; } - public required byte[] Signature { get; set; } = []; + public JsonWebTokenHeader Header { get; set; } = new(); + public JsonWebTokenPayload Payload { get; set; } = new(); + public byte[] Signature { get; set; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index a37136c..2f95902 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -1,7 +1,5 @@ using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -18,44 +16,8 @@ public class Db( ILoggerFactory? loggerFactory) : DbContext { - public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } - public virtual DbSet Deks { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var dekIdConverter = new ValueConverter( - id => id.Id, - guid => new DekId(guid)); - - var kekIdConverter = new ValueConverter( - id => id.Id, - guid => new KekId(guid)); - - modelBuilder.Entity() - .Property(d => d.Id) - .HasConversion(dekIdConverter); - - modelBuilder.Entity() - .OwnsOne(d => d.KeyData, keyData => - { - keyData.Property(k => k.KekId).HasConversion(kekIdConverter); - }); - - modelBuilder.Entity() - .OwnsOne(k => k.Key, key => - { - key.Property(k => k.KekId).HasConversion(kekIdConverter); - }); - - modelBuilder.Entity() - .OwnsOne(c => c.Secret, secret => - { - secret.Property(s => s.DekId).HasConversion(dekIdConverter); - }); - } - + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(""); diff --git a/IdentityShroud.Core/Helpers/Base64UrlConverter.cs b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs deleted file mode 100644 index 77f05f2..0000000 --- a/IdentityShroud.Core/Helpers/Base64UrlConverter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Buffers; -using System.Buffers.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace IdentityShroud.Core.Helpers; - -public class Base64UrlConverter : JsonConverter -{ - public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value - if (reader.HasValueSequence) - { - var valueSequence = reader.ValueSequence.ToArray(); - return Base64Url.DecodeFromUtf8(valueSequence); - } - return Base64Url.DecodeFromUtf8(reader.ValueSpan); - } - - public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) - { - int encodedLength = Base64Url.GetEncodedLength(value.Length); - Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; - Base64Url.EncodeToUtf8(value, buffer); - writer.WriteStringValue(buffer); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Helpers/SlugHelper.cs b/IdentityShroud.Core/Helpers/SlugHelper.cs index 51aa0c3..0c74455 100644 --- a/IdentityShroud.Core/Helpers/SlugHelper.cs +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -72,9 +73,9 @@ public static class SlugHelper private static string GenerateHashSuffix(string text) { - using (var md5 = MD5.Create()) + using (var sha256 = SHA256.Create()) { - byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(text)); + 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); diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 9dd3e34..a87c996 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -11,10 +11,7 @@ - - - @@ -22,4 +19,10 @@ + + + ..\..\..\.nuget\packages\microsoft.aspnetcore.webutilities\10.0.2\lib\net10.0\Microsoft.AspNetCore.WebUtilities.dll + + + diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 5df6c1a..0be04ed 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,29 +1,7 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Microsoft.EntityFrameworkCore; - namespace IdentityShroud.Core.Model; -[Table("client")] -[Index(nameof(ClientId), IsUnique = true)] public class Client { - [Key] - public int Id { get; set; } - public Guid RealmId { get; set; } - [MaxLength(40)] - public required string ClientId { get; set; } - [MaxLength(80)] - public string? Name { get; set; } - [MaxLength(2048)] - public string? Description { get; set; } - - [MaxLength(20)] - public string? SignatureAlgorithm { get; set; } - - public bool AllowClientCredentialsFlow { get; set; } = false; - - public required DateTime CreatedAt { get; set; } - - public List Secrets { get; set; } = []; + public Guid Id { get; set; } + public string Name { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs deleted file mode 100644 index 52d25cc..0000000 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Model; - -[Table("client_secret")] -public class ClientSecret -{ - [Key] - public int Id { get; set; } - public Guid ClientId { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? RevokedAt { get; set; } - public required EncryptedValue Secret { get; set; } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index bbe9631..5fc9639 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Security; +using IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Model; [Table("realm")] public class Realm { + private byte[] _privateKeyDecrypted = []; public Guid Id { get; set; } /// @@ -19,22 +20,26 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + public byte[] PrivateKeyEncrypted + { + get; + set + { + field = value; + _privateKeyDecrypted = []; + } + } = []; - public List Deks { get; init; } = []; + public byte[] GetPrivateKey(IEncryptionService encryptionService) + { + if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + return _privateKeyDecrypted; + } - /// - /// Can be overriden per client - /// - public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; -} - -[Table("realm_dek")] -public record RealmDek -{ - public required DekId Id { get; init; } - public required bool Active { get; set; } - public required string Algorithm { get; init; } - public required EncryptedDek KeyData { get; init; } - public required Guid RealmId { get; init; } -} + public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) + { + PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); + _privateKeyDecrypted = privateKey; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs deleted file mode 100644 index 3fcf2d1..0000000 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; - -namespace IdentityShroud.Core.Model; - - -[Table("realm_key")] -public record RealmKey -{ - public required Guid Id { get; init; } - public required string KeyType { get; init; } - - - public required EncryptedDek Key { get; init; } - public required DateTime CreatedAt { get; init; } - public DateTime? RevokedAt { get; set; } - - /// - /// Key with highest priority will be used. While there is not really a use case for this I know some users - /// are more comfortable replacing keys by using priority then directly deactivating the old key. - /// - public int Priority { get; set; } = 10; - - -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs new file mode 100644 index 0000000..1f0e9de --- /dev/null +++ b/IdentityShroud.Core/Security/AesGcmHelper.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; + +namespace IdentityShroud.Core.Security; + +public static class AesGcmHelper +{ + + public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) + { + using var aes = new AesGcm(key); + byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; + + aes.Encrypt(nonce, plaintext, ciphertext, tag); + // Return concatenated nonce|ciphertext|tag (or store separately) + return nonce.Concat(ciphertext).Concat(tag).ToArray(); + } + + // -------------------------------------------------------------------- + // DecryptAesGcm + // • key – 32‑byte (256‑bit) secret key (same key used for encryption) + // • payload – byte[] containing nonce‖ciphertext‖tag + // • returns – the original plaintext bytes + // -------------------------------------------------------------------- + public static byte[] DecryptAesGcm(byte[] payload, byte[] key) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (key.Length != 32) // 256‑bit key + throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); + + // ---------------------------------------------------------------- + // 1️⃣ Extract the three components. + // ---------------------------------------------------------------- + // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) + // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) + int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 + int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 + + if (payload.Length < nonceSize + tagSize) + throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); + + ReadOnlySpan nonce = new(payload, 0, nonceSize); + ReadOnlySpan ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize); + ReadOnlySpan tag = new(payload, payload.Length - tagSize, tagSize); + + + byte[] plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(key); + try + { + aes.Decrypt(nonce, ciphertext, tag, plaintext); + } + catch (CryptographicException ex) + { + // Tag verification failed → tampering or wrong key/nonce. + throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); + } + + return plaintext; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index 9355c0b..01be0a9 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -10,13 +10,8 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret { private readonly IConfigurationSection secrets = configuration.GetSection("secrets"); - public string GetSecret(string name) + public string GetSecretAsync(string name) { return secrets.GetValue(name) ?? ""; } - - public KeyEncryptionKey[] GetKeys(string name) - { - return secrets.GetSection(name).Get() ?? []; - } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs deleted file mode 100644 index 276178e..0000000 --- a/IdentityShroud.Core/Security/DekId.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IdentityShroud.Core.Security; - -public record struct DekId(Guid Id) -{ - public static DekId NewId() => new(Guid.NewGuid()); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs deleted file mode 100644 index 377a2f6..0000000 --- a/IdentityShroud.Core/Security/EncryptedDek.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace IdentityShroud.Core.Security; - -[Owned] -public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs deleted file mode 100644 index 173c295..0000000 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace IdentityShroud.Core.Security; - -[Owned] -public record EncryptedValue(DekId DekId, byte[] Value); - - diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs deleted file mode 100644 index 47344c1..0000000 --- a/IdentityShroud.Core/Security/Encryption.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class Encryption -{ - private record struct AlgVersion(int Version, int NonceSize, int TagSize); - - private static AlgVersion[] _versions = - [ - new(0, 0, 0), // version 0 does not realy exist - new(1, 12, 16), // version 1 - ]; - - public static byte[] Encrypt(ReadOnlySpan plaintext, ReadOnlySpan key) - { - const int versionNumber = 1; - AlgVersion versionParams = _versions[versionNumber]; - - int resultSize = 1 + versionParams.NonceSize + versionParams.TagSize + plaintext.Length; - // allocate buffer for complete response - var result = new byte[resultSize]; - - result[0] = (byte)versionParams.Version; - - // make the spans that point to the parts of the result where their data is located - var nonce = result.AsSpan(1, versionParams.NonceSize); - var tag = result.AsSpan(1 + versionParams.NonceSize, versionParams.TagSize); - var cipher = result.AsSpan(1 + versionParams.NonceSize + versionParams.TagSize); - - // use the spans to place the data directly in its place - RandomNumberGenerator.Fill(nonce); - using var aes = new AesGcm(key, versionParams.TagSize); - aes.Encrypt(nonce, plaintext, cipher, tag); - return result; - } - - public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) - { - var payload = input.Span; - int versionNumber = (int)payload[0]; - if (versionNumber != 1) - throw new ArgumentException("Invalid payload"); - - AlgVersion versionParams = _versions[versionNumber]; - - - if (payload.Length < 1 + versionParams.NonceSize + versionParams.TagSize) - throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - - ReadOnlySpan nonce = payload.Slice(1, versionParams.NonceSize); - ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); - ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); - - byte[] plaintext = new byte[cipher.Length]; - - using var aes = new AesGcm(key, versionParams.TagSize); - try - { - aes.Decrypt(nonce, cipher, tag, plaintext); - } - catch (CryptographicException ex) - { - // Tag verification failed → tampering or wrong key/nonce. - throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); - } - - return plaintext; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs deleted file mode 100644 index dc9bc28..0000000 --- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IdentityShroud.Core.Security; - -public static class JsonWebAlgorithm -{ - public const string RS256 = "RS256"; -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs index e22cfca..11f8dc2 100644 --- a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs +++ b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core; -public static class JwtSignatureGenerator +public class JwtSignatureGenerator { /// /// Generates a JWT signature using RS256 algorithm diff --git a/IdentityShroud.Core/Security/KekId.cs b/IdentityShroud.Core/Security/KekId.cs deleted file mode 100644 index c794078..0000000 --- a/IdentityShroud.Core/Security/KekId.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.ComponentModel; -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace IdentityShroud.Core.Security; - -[JsonConverter(typeof(KekIdJsonConverter))] -[TypeConverter(typeof(KekIdTypeConverter))] -public readonly record struct KekId -{ - public Guid Id { get; } - - public KekId(Guid id) - { - Id = id; - } - - public static KekId NewId() - { - return new KekId(Guid.NewGuid()); - } -} - -public class KekIdJsonConverter : JsonConverter -{ - public override KekId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => new KekId(reader.GetGuid()); - - public override void Write(Utf8JsonWriter writer, KekId value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Id); -} - -public class KekIdTypeConverter : TypeConverter -{ - public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) - => sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); - - public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) - => value is string s ? new KekId(Guid.Parse(s)) : base.ConvertFrom(context, culture, value); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KeyEncryptionKey.cs b/IdentityShroud.Core/Security/KeyEncryptionKey.cs deleted file mode 100644 index 35f7917..0000000 --- a/IdentityShroud.Core/Security/KeyEncryptionKey.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace IdentityShroud.Core.Security; - -/// -/// Contains a KEK and associated relevant data. This structure -/// -/// -/// -/// -/// -public record KeyEncryptionKey(KekId Id, bool Active, string Algorithm, byte[] Key); diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs deleted file mode 100644 index 8e32309..0000000 --- a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using IdentityShroud.Core.Messages; - -namespace IdentityShroud.Core.Security.Keys; - -public abstract class KeyPolicy -{ - public abstract string KeyType { get; } -} - - -public interface IKeyProvider -{ - byte[] CreateKey(KeyPolicy policy); - - void SetJwkParameters(byte[] key, JsonWebKey jwk); -} - - - diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs deleted file mode 100644 index 485e6e5..0000000 --- a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace IdentityShroud.Core.Security.Keys; - - -public interface IKeyProviderFactory -{ - public IKeyProvider CreateProvider(string keyType); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs deleted file mode 100644 index a1c3472..0000000 --- a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -using IdentityShroud.Core.Security.Keys.Rsa; - -namespace IdentityShroud.Core.Security.Keys; - -public class KeyProviderFactory : IKeyProviderFactory -{ - public IKeyProvider CreateProvider(string keyType) - { - switch (keyType) - { - case "RSA": - return new RsaProvider(); - default: - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs deleted file mode 100644 index daf2b7f..0000000 --- a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Buffers.Text; -using System.Security.Cryptography; -using IdentityShroud.Core.Messages; - -namespace IdentityShroud.Core.Security.Keys.Rsa; - -public class RsaKeyPolicy : KeyPolicy -{ - public override string KeyType => "RSA"; - public int KeySize { get; } = 2048; -} - -public class RsaProvider : IKeyProvider -{ - public byte[] CreateKey(KeyPolicy policy) - { - if (policy is RsaKeyPolicy p) - { - using var rsa = RSA.Create(p.KeySize); - return rsa.ExportPkcs8PrivateKey(); - } - - throw new ArgumentException("Incorrect policy type", nameof(policy)); - } - - public void SetJwkParameters(byte[] key, JsonWebKey jwk) - { - using var rsa = RSA.Create(); - rsa.ImportPkcs8PrivateKey(key, out _); - var parameters = rsa.ExportParameters(includePrivateParameters: false); - - jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent); - jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/RsaHelper.cs b/IdentityShroud.Core/Security/RsaHelper.cs new file mode 100644 index 0000000..9d35ad7 --- /dev/null +++ b/IdentityShroud.Core/Security/RsaHelper.cs @@ -0,0 +1,7 @@ +using System.Security.Cryptography; + +namespace IdentityShroud.Core.Security; + +public static class RsaHelper +{ +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs deleted file mode 100644 index 0887ccd..0000000 --- a/IdentityShroud.Core/Services/ClientService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using Microsoft.EntityFrameworkCore; - -namespace IdentityShroud.Core.Services; - -public class ClientService( - Db db, - IDataEncryptionService cryptor, - IClock clock) : IClientService -{ - public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) - { - Client client = new() - { - RealmId = realmId, - ClientId = request.ClientId, - Name = request.Name, - Description = request.Description, - SignatureAlgorithm = request.SignatureAlgorithm, - AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false, - CreatedAt = clock.UtcNow(), - }; - - if (client.AllowClientCredentialsFlow) - { - client.Secrets.Add(CreateSecret()); - } - - await db.AddAsync(client, ct); - await db.SaveChangesAsync(ct); - - return client; - } - - public async Task GetByClientId( - Guid realmId, - string clientId, - CancellationToken ct = default) - { - return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId && c.RealmId == realmId, ct); - } - - public async Task FindById( - Guid realmId, - int id, - CancellationToken ct = default) - { - return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); - } - - private ClientSecret CreateSecret() - { - Span secret = stackalloc byte[24]; - RandomNumberGenerator.Fill(secret); - - return new ClientSecret() - { - CreatedAt = clock.UtcNow(), - Secret = cryptor.Encrypt(secret.ToArray()), - }; - - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClockService.cs b/IdentityShroud.Core/Services/ClockService.cs deleted file mode 100644 index 26eb3dd..0000000 --- a/IdentityShroud.Core/Services/ClockService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Core.Services; - -public class ClockService : IClock -{ - public DateTime UtcNow() - { - return DateTime.UtcNow; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs deleted file mode 100644 index a06cbae..0000000 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Services; - -public class DataEncryptionService( - IRealmContext realmContext, - IDekEncryptionService dekCryptor) : IDataEncryptionService -{ - - // Note this array is expected to have one item in it most of the during key rotation it will have two - // until it is ensured the old key can safely be removed. More then two will work but is not really expected. - private IList? _deks = null; - - private IList GetDeks() - { - if (_deks is null) - _deks = realmContext.GetDeks().Result; - - return _deks; - } - - private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active); - private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id); - - public byte[] Decrypt(EncryptedValue input) - { - var dek = GetKey(input.DekId); - var key = dekCryptor.Decrypt(dek.KeyData); - return Encryption.Decrypt(input.Value, key); - } - - public EncryptedValue Encrypt(ReadOnlySpan plain) - { - var dek = GetActiveDek(); - var key = dekCryptor.Decrypt(dek.KeyData); - byte[] cipher = Encryption.Encrypt(plain, key); - return new (dek.Id, cipher); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs deleted file mode 100644 index add9267..0000000 --- a/IdentityShroud.Core/Services/DekEncryptionService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Services; - -/// -/// -/// -public class DekEncryptionService : IDekEncryptionService -{ - // Note this array is expected to have one item in it most of the during key rotation it will have two - // until it is ensured the old key can safely be removed. More then two will work but is not really expected. - private readonly KeyEncryptionKey[] _encryptionKeys; - - private KeyEncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active); - private KeyEncryptionKey GetKey(KekId keyId) => _encryptionKeys.Single(k => k.Id == keyId); - - public DekEncryptionService(ISecretProvider secretProvider) - { - _encryptionKeys = secretProvider.GetKeys("master"); - // if (_encryptionKey.Length != 32) // 256‑bit key - // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); - } - - public EncryptedDek Encrypt(ReadOnlySpan plaintext) - { - var encryptionKey = ActiveKey; - byte[] cipher = Encryption.Encrypt(plaintext, encryptionKey.Key); - return new (encryptionKey.Id, cipher); - } - - public byte[] Decrypt(EncryptedDek input) - { - var encryptionKey = GetKey(input.KekId); - - return Encryption.Decrypt(input.Value, encryptionKey.Key); - } -} \ 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/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs deleted file mode 100644 index a2ce9dc..0000000 --- a/IdentityShroud.Core/Services/KeyService.cs +++ /dev/null @@ -1,46 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security.Keys; - -namespace IdentityShroud.Core.Services; - -public class KeyService( - IDekEncryptionService cryptor, - IKeyProviderFactory keyProviderFactory, - IClock clock) : IKeyService -{ - public RealmKey CreateKey(KeyPolicy policy) - { - IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType); - var plainKey = provider.CreateKey(policy); - - return CreateKey(policy.KeyType, plainKey); - } - - public JsonWebKey? CreateJsonWebKey(RealmKey realmKey) - { - JsonWebKey jwk = new() - { - KeyId = realmKey.Id.ToString(), - KeyType = realmKey.KeyType, - Use = "sig", - }; - - IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType); - provider.SetJwkParameters( - cryptor.Decrypt(realmKey.Key), - jwk); - - return jwk; - } - - private RealmKey CreateKey(string keyType, byte[] plainKey) => - new RealmKey() - { - Id = Guid.NewGuid(), - KeyType = keyType, - Key = cryptor.Encrypt(plainKey), - CreatedAt = clock.UtcNow(), - }; -} diff --git a/IdentityShroud.Core/Services/MasterEncryptionService.cs b/IdentityShroud.Core/Services/MasterEncryptionService.cs new file mode 100644 index 0000000..d0b5eda --- /dev/null +++ b/IdentityShroud.Core/Services/MasterEncryptionService.cs @@ -0,0 +1,23 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +/// +/// +/// +/// Encryption key as base64, must be 32 bytes +public class EncryptionService(string keyBase64) : IEncryptionService +{ + private readonly byte[] encryptionKey = Convert.FromBase64String(keyBase64); + + public byte[] Encrypt(byte[] plain) + { + return AesGcmHelper.EncryptAesGcm(plain, encryptionKey); + } + + public byte[] Decrypt(byte[] cipher) + { + return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs deleted file mode 100644 index 7daa399..0000000 --- a/IdentityShroud.Core/Services/RealmContext.cs +++ /dev/null @@ -1,26 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; -using Microsoft.AspNetCore.Http; - -namespace IdentityShroud.Core.Services; - -public class RealmContext( - IHttpContextAccessor accessor, - IRealmService realmService) : IRealmContext -{ - public Realm GetRealm() - { - return (Realm)accessor.HttpContext.Items["RealmEntity"]; - } - - public async Task> GetDeks(CancellationToken ct = default) - { - Realm realm = GetRealm(); - if (realm.Deks.Count == 0) - { - await realmService.LoadDeks(realm); - } - - return realm.Deks; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 949c9fe..50cb61d 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,10 +1,8 @@ +using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security.Keys; -using IdentityShroud.Core.Security.Keys.Rsa; -using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Services; @@ -12,20 +10,8 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IKeyService keyService) : IRealmService + IEncryptionService encryptionService) : IRealmService { - public async Task FindById(Guid id, CancellationToken ct = default) - { - return await db.Realms - .SingleOrDefaultAsync(r => r.Id == id, ct); - } - - public async Task FindBySlug(string slug, CancellationToken ct = default) - { - return await db.Realms - .SingleOrDefaultAsync(r => r.Slug == slug, ct); - } - public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() @@ -35,7 +21,8 @@ public class RealmService( Name = request.Name, }; - realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); + using RSA rsa = RSA.Create(2048); + realm.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); db.Add(realm); await db.SaveChangesAsync(ct); @@ -43,27 +30,4 @@ public class RealmService( return new RealmCreateResponse( realm.Id, realm.Slug, realm.Name); } - - /// - /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. - /// - /// - /// - private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy(); - - - public async Task LoadActiveKeys(Realm realm) - { - await db.Entry(realm).Collection(r => r.Keys) - .Query() - .Where(k => k.RevokedAt == null) - .LoadAsync(); - } - - public async Task LoadDeks(Realm realm) - { - await db.Entry(realm).Collection(r => r.Deks) - .Query() - .LoadAsync(); - } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs b/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs deleted file mode 100644 index 1ccaf34..0000000 --- a/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json.Nodes; -using IdentityShroud.TestUtils.Asserts; -using Xunit.Sdk; - -namespace IdentityShroud.TestUtils.Tests.Asserts; - -public class JsonObjectAssertTests -{ - [Theory] - [InlineData("foo", new string[] { "foo" })] - [InlineData("foo.bar", new string[] { "foo", "bar" })] - [InlineData("foo[1].bar", new string[] { "foo", "1", "bar" })] - public void ParsePath(string path, string[] expected) - { - var result = JsonObjectAssert.ParsePath(path); - Assert.Equal(expected, result); - } - - [Fact] - public void NavigateToPath_Success() - { - JsonObject foo = new(); - foo["bar"] = 1; - JsonObject obj = new(); - obj["foo"] = foo; - - JsonNode? node = JsonObjectAssert.NavigateToPath(obj, ["foo", "bar"]); - Assert.NotNull(node); - } - - [Fact] - public void NavigateToPath_PathDoesNotExist() - { - JsonObject obj = new(); - Assert.Throws( - () => JsonObjectAssert.NavigateToPath(obj, ["test"]), - ex => ex.Message.StartsWith("Path 'test' does not exist") ? null : ex.Message); - } - - [Fact] - public void NavigateToPath_MemberOfNullObject() - { - JsonObject obj = new(); - obj["foo"] = null; - - Assert.Throws( - () => JsonObjectAssert.NavigateToPath(obj, ["foo", "bar"]), - ex => ex.Message.StartsWith("Path 'foo.bar' does not exist") ? null : ex.Message); - } - - [Fact] - public void Equal_WrongType() - { - JsonObject obj = new(); - obj["test"] = new JsonObject(); - - Assert.Throws( - () => JsonObjectAssert.Equal("str", obj, ["test"]), - ex => ex.Message.StartsWith("Type mismatch") ? null : ex.Message); - } - - [Fact] - public void Equal_Match() - { - JsonObject obj = new(); - obj["test"] = "str"; - - JsonObjectAssert.Equal("str", obj, ["test"]); - } -} \ No newline at end of file diff --git a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj deleted file mode 100644 index 9ce8074..0000000 --- a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs deleted file mode 100644 index 016f358..0000000 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ /dev/null @@ -1,363 +0,0 @@ -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace IdentityShroud.TestUtils.Asserts; - -public static class JsonObjectAssert -{ - /// - /// Parses a path string that may contain array indices (e.g., "items[0].name") into individual segments. - /// - /// The path string with optional array indices - /// Array of path segments where array indices are separate segments - public static string[] ParsePath(string path) - { - var segments = new List(); - var parts = path.Split('.'); - - foreach (var part in parts) - { - // Check if the part contains array indexing like "items[0]" - var match = Regex.Match(part, @"^(.+?)\[(\d+)\]$"); - if (match.Success) - { - // Add the property name - segments.Add(match.Groups[1].Value); - // Add the array index - segments.Add(match.Groups[2].Value); - } - else - { - segments.Add(part); - } - } - - return segments.ToArray(); - } - - /// - /// Navigates to a JsonNode at the specified path and returns it. - /// Throws XunitException if the path doesn't exist or is invalid. - /// - /// The root JsonObject to navigate from - /// The path segments to navigate - /// The JsonNode at the specified path (can be null if the value is null) - public static JsonNode? NavigateToPath(JsonObject jsonObject, string[] pathArray) - { - if (pathArray.Length == 0) - throw new ArgumentException("Path cannot be empty"); - - JsonNode? current = jsonObject; - string currentPath = ""; - - foreach (var segment in pathArray) - { - currentPath = string.IsNullOrEmpty(currentPath) ? segment : $"{currentPath}.{segment}"; - - if (current == null) - throw new Xunit.Sdk.XunitException( - $"Path '{currentPath}' does not exist - parent node is null"); - - if (current is JsonObject obj) - { - if (!obj.ContainsKey(segment)) - throw new Xunit.Sdk.XunitException( - $"Path '{currentPath}' does not exist - property '{segment}' not found"); - - current = obj[segment]; - } - else if (current is JsonArray arr && int.TryParse(segment, out int index)) - { - if (index < 0 || index >= arr.Count) - throw new Xunit.Sdk.XunitException( - $"Path '{currentPath}' does not exist - array index {index} out of bounds (array length: {arr.Count})"); - - current = arr[index]; - } - else - { - throw new Xunit.Sdk.XunitException( - $"Path '{currentPath}' is invalid - cannot navigate through non-object/non-array node at '{segment}'"); - } - } - - return current; - } - - /// - /// Asserts that a JsonObject contains the expected value at the specified path. - /// Validates that the path exists, field types match, and values are equal. - /// - /// The expected type of the value - /// The expected value - /// The JsonObject to validate - /// The path to the field as an enumerable of property names - public static void Equal(T expected, JsonObject jsonObject, IEnumerable path) - { - var pathArray = path.ToArray(); - var current = NavigateToPath(jsonObject, pathArray); - - if (current == null) - { - if (expected != null) - throw new Xunit.Sdk.XunitException( - $"Expected value '{expected}' at path '{string.Join(".", pathArray)}', but found null"); - return; - } - - // Type and value validation - try - { - T? actualValue = current.GetValue(); - Assert.Equal(expected, actualValue); - } - catch (InvalidOperationException ex) - { - throw new Xunit.Sdk.XunitException( - $"Type mismatch at path '{string.Join(".", pathArray)}': cannot convert JsonNode to {typeof(T).Name}. {ex.Message}"); - } - catch (FormatException ex) - { - throw new Xunit.Sdk.XunitException( - $"Format error at path '{string.Join(".", pathArray)}': cannot convert value to {typeof(T).Name}. {ex.Message}"); - } - } - - /// - /// Asserts that a JsonObject contains the expected value at the specified path. - /// Validates that the path exists, field types match, and values are equal. - /// - /// The expected type of the value - /// The expected value - /// The JsonObject to validate - /// The path to the field as dot-separated string with optional array indices (e.g., "user.addresses[0].city") - public static void Equal(T expected, JsonObject jsonObject, string path) - { - Equal(expected, jsonObject, ParsePath(path)); - } - - /// - /// Asserts that a path exists in the JsonObject without validating the value. - /// - /// The JsonObject to validate - /// The path to check for existence - public static void PathExists(JsonObject jsonObject, IEnumerable path) - { - var pathArray = path.ToArray(); - NavigateToPath(jsonObject, pathArray); - // If NavigateToPath doesn't throw, the path exists - } - - /// - /// Asserts that a path exists in the JsonObject without validating the value. - /// - /// The JsonObject to validate - /// The path to check for existence as dot-separated string with optional array indices - public static void PathExists(JsonObject jsonObject, string path) - { - PathExists(jsonObject, ParsePath(path)); - } - - /// - /// Asserts that a JsonArray at the specified path has the expected count. - /// Validates that the path exists, is a JsonArray, and has the expected number of elements. - /// - /// The expected number of elements in the array - /// The JsonObject to validate - /// The path to the array as an enumerable of property names - public static void Count(int expectedCount, JsonObject jsonObject, IEnumerable path) - { - var pathArray = path.ToArray(); - var current = NavigateToPath(jsonObject, pathArray); - var pathString = string.Join(".", pathArray); - - if (current == null) - throw new Xunit.Sdk.XunitException( - $"Path '{pathString}' contains null - cannot verify count on null value"); - - if (current is not JsonArray array) - throw new Xunit.Sdk.XunitException( - $"Path '{pathString}' does not contain a JsonArray - found {current.GetType().Name} instead"); - - if (array.Count != expectedCount) - throw new Xunit.Sdk.XunitException( - $"Expected array at path '{pathString}' to have {expectedCount} element(s), but found {array.Count}"); - } - - /// - /// Asserts that a JsonArray at the specified path has the expected count. - /// Validates that the path exists, is a JsonArray, and has the expected number of elements. - /// - /// The expected number of elements in the array - /// The JsonObject to validate - /// The path to the array as dot-separated string with optional array indices (e.g., "user.addresses") - public static void Count(int expectedCount, JsonObject jsonObject, string path) - { - Count(expectedCount, jsonObject, ParsePath(path)); - } - - /// - /// Gets a JsonArray at the specified path for performing custom assertions on its elements. - /// Validates that the path exists and is a JsonArray. - /// - /// The JsonObject to navigate - /// The path to the array as an enumerable of property names - /// The JsonArray at the specified path - public static JsonArray GetArray(JsonObject jsonObject, IEnumerable path) - { - var pathArray = path.ToArray(); - var current = NavigateToPath(jsonObject, pathArray); - var pathString = string.Join(".", pathArray); - - if (current == null) - throw new Xunit.Sdk.XunitException( - $"Path '{pathString}' contains null - expected a JsonArray"); - - if (current is not JsonArray array) - throw new Xunit.Sdk.XunitException( - $"Path '{pathString}' does not contain a JsonArray - found {current.GetType().Name} instead"); - - return array; - } - - /// - /// Gets a JsonArray at the specified path for performing custom assertions on its elements. - /// Validates that the path exists and is a JsonArray. - /// - /// The JsonObject to navigate - /// The path to the array as dot-separated string with optional array indices (e.g., "user.addresses") - /// The JsonArray at the specified path - public static JsonArray GetArray(JsonObject jsonObject, string path) - { - return GetArray(jsonObject, ParsePath(path)); - } - - /// - /// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate. - /// - /// The JsonObject to validate - /// The path to the array - /// The predicate to test each element against - public static void All(JsonObject jsonObject, IEnumerable path, Func predicate) - { - var array = GetArray(jsonObject, path); - var pathString = string.Join(".", path); - - for (int i = 0; i < array.Count; i++) - { - if (!predicate(array[i])) - throw new Xunit.Sdk.XunitException( - $"Predicate failed for element at index {i} in array at path '{pathString}'"); - } - } - - /// - /// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate. - /// - /// The JsonObject to validate - /// The path to the array as dot-separated string - /// The predicate to test each element against - public static void All(JsonObject jsonObject, string path, Func predicate) - { - All(jsonObject, ParsePath(path), predicate); - } - - /// - /// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate. - /// - /// The JsonObject to validate - /// The path to the array - /// The predicate to test each element against - public static void Any(JsonObject jsonObject, IEnumerable path, Func predicate) - { - var array = GetArray(jsonObject, path); - var pathString = string.Join(".", path); - - foreach (var element in array) - { - if (predicate(element)) - return; - } - - throw new Xunit.Sdk.XunitException( - $"No element in array at path '{pathString}' satisfies the predicate"); - } - - /// - /// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate. - /// - /// The JsonObject to validate - /// The path to the array as dot-separated string - /// The predicate to test each element against - public static void Any(JsonObject jsonObject, string path, Func predicate) - { - Any(jsonObject, ParsePath(path), predicate); - } - - /// - /// Performs an action on each element in a JsonArray at the specified path. - /// Useful for running custom assertions on each element. - /// - /// The JsonObject to validate - /// The path to the array - /// The action to perform on each element - public static void ForEach(JsonObject jsonObject, IEnumerable path, Action assertAction) - { - var array = GetArray(jsonObject, path); - - for (int i = 0; i < array.Count; i++) - { - assertAction(array[i], i); - } - } - - /// - /// Performs an action on each element in a JsonArray at the specified path. - /// Useful for running custom assertions on each element. - /// - /// The JsonObject to validate - /// The path to the array as dot-separated string - /// The action to perform on each element (element, index) - public static void ForEach(JsonObject jsonObject, string path, Action assertAction) - { - ForEach(jsonObject, ParsePath(path), assertAction); - } - - /// - /// Asserts that a JsonArray at the specified path contains an element with a specific value at a property path. - /// - /// The expected type of the value - /// The JsonObject to validate - /// The path to the array - /// The property path within each array element to check - /// The expected value - public static void Contains(JsonObject jsonObject, string arrayPath, string propertyPath, T expectedValue) - { - var array = GetArray(jsonObject, arrayPath); - var propertySegments = ParsePath(propertyPath); - - foreach (var element in array) - { - if (element is JsonObject elementObj) - { - try - { - var current = NavigateToPath(elementObj, propertySegments); - if (current != null) - { - var actualValue = current.GetValue(); - if (EqualityComparer.Default.Equals(actualValue, expectedValue)) - return; - } - } - catch - { - // Continue checking other elements - } - } - } - - throw new Xunit.Sdk.XunitException( - $"Array at path '{arrayPath}' does not contain an element with {propertyPath} = {expectedValue}"); - } -} diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj deleted file mode 100644 index 4b68445..0000000 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - diff --git a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs deleted file mode 100644 index 4e97bfc..0000000 --- a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.TestUtils.Substitutes; - -public class NullDataEncryptionService : IDataEncryptionService -{ - public DekId KeyId { get; } = DekId.NewId(); - public EncryptedValue Encrypt(ReadOnlySpan plain) - { - return new(KeyId, plain.ToArray()); - } - - public byte[] Decrypt(EncryptedValue input) - { - return input.Value; - } -} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs deleted file mode 100644 index 879f932..0000000 --- a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.TestUtils.Substitutes; - -public class NullDekEncryptionService : IDekEncryptionService -{ - public KekId KeyId { get; } = KekId.NewId(); - public EncryptedDek Encrypt(ReadOnlySpan plain) - { - return new(KeyId, plain.ToArray()); - } - - public byte[] Decrypt(EncryptedDek input) - { - return input.Value; - } -} \ No newline at end of file diff --git a/IdentityShroud.sln b/IdentityShroud.sln index ef65bf2..b0de020 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -12,12 +12,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils", "IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj", "{A8554BCC-C9B6-4D96-90AD-FE80E95441F4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils.Tests", "IdentityShroud.TestUtils.Tests\IdentityShroud.TestUtils.Tests.csproj", "{35D33207-27A8-43E9-A8CA-A158A1E4448C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{980900AA-E052-498B-A41A-4F33A8678828}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,19 +38,5 @@ Global {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 - {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Release|Any CPU.Build.0 = Release|Any CPU - {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {4758FE2E-A437-44F0-B58E-09E52D67D288} = {980900AA-E052-498B-A41A-4F33A8678828} - {DC887623-8680-4D3B-B23A-D54F7DA91891} = {980900AA-E052-498B-A41A-4F33A8678828} - {35D33207-27A8-43E9-A8CA-A158A1E4448C} = {980900AA-E052-498B-A41A-4F33A8678828} - {A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 88c8f46..21dd76b 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -1,48 +1,16 @@  ForceIncluded - ForceIncluded ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded ForceIncluded - ForceIncluded ForceIncluded - ForceIncluded ForceIncluded ForceIncluded ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - 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"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 8bd5aa3..0000000 --- a/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# IdentityShroud - -IdentityShroud is a .NET project for identity management and protection. -