From ccb06b260cb6a7a92119db9687c18d9ccf5baa66 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 19:06:09 +0100 Subject: [PATCH] Implement jwks endpoint and add test for it. This also let to some improvements/cleanups of the other tests and fixtures. --- .../Apis/RealmApisTests.cs | 102 ++++++++++++++---- .../Fixtures/ApplicationFactory.cs | 42 +++++++- .../Mappers/KeyMapperTests.cs | 41 +++++++ .../Apis}/DTO/JsonWebKey.cs | 12 ++- .../Apis}/DTO/JsonWebKeySet.cs | 0 .../Apis/Filters/SlugValidationFilter.cs | 26 +++++ IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 34 ++++++ IdentityShroud.Api/Apis/RealmApi.cs | 61 ++++------- .../IdentityShroud.Api.csproj.DotSettings | 3 + IdentityShroud.Api/Program.cs | 9 ++ .../Fixtures/DbFixture.cs | 19 +--- .../IdentityShroud.Core.Tests.csproj | 1 + .../Services/RealmServiceTests.cs | 3 +- IdentityShroud.Core.Tests/UnitTest1.cs | 11 +- IdentityShroud.Core/Model/Client.cs | 4 + IdentityShroud.Core/Model/Realm.cs | 10 +- IdentityShroud.Core/Security/AesGcmHelper.cs | 23 ++-- .../Security/JsonWebAlgorithm.cs | 8 ++ IdentityShroud.Core/Security/RsaHelper.cs | 9 ++ IdentityShroud.Core/Services/IRealmService.cs | 1 + IdentityShroud.Core/Services/RealmService.cs | 12 ++- .../IdentityShroud.TestUtils.csproj | 15 +++ .../EncryptionServiceSubstitute.cs | 2 +- IdentityShroud.sln.DotSettings.user | 12 ++- 24 files changed, 353 insertions(+), 107 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs rename {IdentityShroud.Core => IdentityShroud.Api/Apis}/DTO/JsonWebKey.cs (67%) rename {IdentityShroud.Core => IdentityShroud.Api/Apis}/DTO/JsonWebKeySet.cs (100%) create mode 100644 IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs create mode 100644 IdentityShroud.Api/Apis/Mappers/KeyMapper.cs create mode 100644 IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings create mode 100644 IdentityShroud.Core/Security/JsonWebAlgorithm.cs rename {IdentityShroud.Core.Tests => IdentityShroud.TestUtils}/Substitutes/EncryptionServiceSubstitute.cs (90%) diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 1b1743b..350149b 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,19 +1,35 @@ using System.Net; using System.Net.Http.Json; +using System.Security.Cryptography; using System.Text.Json.Nodes; -using FluentResults; -using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; -using NSubstitute.ClearExtensions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace IdentityShroud.Api.Tests.Apis; -public class RealmApisTests(ApplicationFactory factory) : IClassFixture +public class RealmApisTests : 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, "")] @@ -25,11 +41,7 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture(), Arg.Any()) - .Returns(Result.Ok(new RealmCreateResponse(Guid.NewGuid(), "foo", "Foo"))); + var client = _factory.CreateClient(); Guid? inputId = id is null ? (Guid?)null : new Guid(id); var response = await client.PostAsync("/realms", JsonContent.Create(new @@ -46,9 +58,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture(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 { @@ -58,9 +70,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture e.Key == fieldName); - await factory.RealmService.DidNotReceive().Create( - Arg.Any(), - Arg.Any()); + // await factory.RealmService.DidNotReceive().Create( + // Arg.Any(), + // Arg.Any()); } } @@ -68,11 +80,14 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture("foo"), Arg.Any()) - .Returns(new Realm()); - + 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 client = _factory.CreateClient(); var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", TestContext.Current.CancellationToken); @@ -91,11 +106,56 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture(); + + using var rsa = RSA.Create(2048); + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + Key key = new() + { + Id = Guid.NewGuid(), + CreatedAt = DateTime.UtcNow, + }; + key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + await ScopedContextAsync(async db => + { + db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ key ]}); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + }); + + // act + var client = _factory.CreateClient(); + var response = await client.GetAsync("/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(key.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 6135df6..6f4c461 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -1,24 +1,58 @@ using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestPlatform.TestHost; +using Npgsql; +using Testcontainers.PostgreSql; namespace IdentityShroud.Core.Tests.Fixtures; -public class ApplicationFactory : WebApplicationFactory +public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime { - public IRealmService RealmService { get; } = Substitute.For(); + private readonly PostgreSqlContainer _postgresqlServer; +// 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.ConfigureServices(services => + builder.ConfigureAppConfiguration((context, configBuilder) => { - services.AddScoped(c => RealmService); + configBuilder.AddInMemoryCollection( + new Dictionary + { + ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), + ["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", + }); }); + // 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/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs new file mode 100644 index 0000000..6c57971 --- /dev/null +++ b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; +using IdentityShroud.Api.Mappers; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.TestUtils.Substitutes; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentityShroud.Api.Tests.Mappers; + +public class KeyMapperTests +{ + private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + + [Fact] + public void Test() + { + // Setup + using RSA rsa = RSA.Create(2048); + + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + Key key = new() + { + Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), + CreatedAt = DateTime.UtcNow, + Priority = 10, + }; + key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); + + // Act + KeyMapper mapper = new(_encryptionService); + JsonWebKey jwk = mapper.KeyToJsonWebKey(key); + + Assert.Equal("RSA", jwk.KeyType); + Assert.Equal(key.Id.ToString(), jwk.KeyId); + Assert.Equal("sig", jwk.Use); + Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent)); + Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus)); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs similarity index 67% rename from IdentityShroud.Core/DTO/JsonWebKey.cs rename to IdentityShroud.Api/Apis/DTO/JsonWebKey.cs index 7ccec61..e46107f 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs @@ -14,9 +14,11 @@ public class JsonWebKey [JsonPropertyName("use")] public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption - // Per standard this field is optional for now we will use RS256 - [JsonPropertyName("alg")] - public string? Algorithm { get; set; } = "RS256"; + // 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("kid")] public required string KeyId { get; set; } @@ -31,9 +33,9 @@ public class JsonWebKey // Optional fields [JsonPropertyName("x5c")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List X509CertificateChain { get; set; } + public List? X509CertificateChain { get; set; } [JsonPropertyName("x5t")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string X509CertificateThumbprint { get; set; } + public string? X509CertificateThumbprint { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKeySet.cs b/IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Core/DTO/JsonWebKeySet.cs rename to IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs new file mode 100644 index 0000000..5bc699e --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs @@ -0,0 +1,26 @@ +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; + +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 SlugValidationFilter(IRealmService realmService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + string slug = context.Arguments.OfType().First(); + Realm? realm = await realmService.FindBySlug(slug); + 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/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs new file mode 100644 index 0000000..00f5d7b --- /dev/null +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentityShroud.Api.Mappers; + +public class KeyMapper(IEncryptionService encryptionService) +{ + public JsonWebKey KeyToJsonWebKey(Key key) + { + using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService)); + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + return new JsonWebKey() + { + KeyType = rsa.SignatureAlgorithm, + KeyId = key.Id.ToString(), + Use = "sig", + Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!), + Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!), + }; + } + + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) + { + return new JsonWebKeySet() + { + Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(), + }; + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 6eb9dab..ea62361 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,13 +1,22 @@ using FluentResults; +using IdentityShroud.Api.Mappers; 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"]!; +} + + + public static class RealmApi { public static void MapRealmEndpoints(this IEndpointRouteBuilder app) @@ -18,7 +27,8 @@ public static class RealmApi .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{slug}"); + var realmSlugGroup = realmsGroup.MapGroup("{slug}") + .AddEndpointFilter(); realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); @@ -39,9 +49,15 @@ public static class RealmApi return TypedResults.InternalServerError(); } - private static Task OpenIdConnectJwks(HttpContext context) + private static async Task, BadRequest>> OpenIdConnectJwks( + string slug, + [FromServices]IRealmService realmService, + [FromServices]KeyMapper keyMapper, + HttpContext context) { - throw new NotImplementedException(); + Realm realm = context.GetValidatedRealm(); + await realmService.LoadActiveKeys(realm); + return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); } private static Task OpenIdConnectToken(HttpContext context) @@ -54,17 +70,12 @@ public static class RealmApi throw new NotImplementedException(); } - private static async Task, BadRequest, NotFound>> GetOpenIdConfiguration( + private static async Task> GetOpenIdConfiguration( + string slug, [FromServices]IRealmService realmService, - HttpContext context, - string slug) + HttpContext context) { - if (string.IsNullOrEmpty(slug)) - return TypedResults.BadRequest(); - - var realm = await realmService.FindBySlug(slug); - if (realm is null) - return TypedResults.NotFound(); + Realm realm = context.GetValidatedRealm(); var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; var searchString = $"realms/{slug}"; @@ -94,30 +105,4 @@ public static class RealmApi } */ } - - // [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/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings new file mode 100644 index 0000000..bd2aa2d --- /dev/null +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 510c626..57aaed4 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,9 +1,11 @@ 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.Services; using Serilog; using Serilog.Formatting.Json; @@ -34,8 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder) // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi services.AddOpenApi(); services.AddScoped(); + services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(c => + { + var configuration = c.GetRequiredService(); + return new EncryptionService(configuration.GetValue("Secrets:Master")); + }); services.AddValidatorsFromAssemblyContaining(); diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 186094e..85c2fbe 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -8,23 +8,13 @@ namespace IdentityShroud.Core.Tests.Fixtures; public class DbFixture : IAsyncLifetime { - private readonly IContainer _postgresqlServer; - - 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); + private readonly PostgreSqlContainer _postgresqlServer; public Db CreateDbContext(string dbName = "testdb") { var db = new Db(Options.Create(new() { - ConnectionString = ConnectionString + ";Database=" + dbName, + ConnectionString = _postgresqlServer.GetConnectionString(), LogSensitiveData = false, }), new NullLoggerFactory()); return db; @@ -33,8 +23,7 @@ public class DbFixture : IAsyncLifetime public DbFixture() { _postgresqlServer = new PostgreSqlBuilder("postgres:18.1") - .WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D")) - .WithPassword(Password) + .WithName("is-dbfixture-" + Guid.NewGuid().ToString("D")) .Build(); } @@ -50,7 +39,7 @@ public class DbFixture : IAsyncLifetime public NpgsqlConnection GetConnection(string dbname) { - string connString = ConnectionString + string connString = _postgresqlServer.GetConnectionString() + $";Database={dbname}"; var connection = new NpgsqlConnection(connString); connection.Open(); diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index bc2d19b..8af08c1 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index d3b884e..5b830ea 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,8 +1,7 @@ -using FluentResults; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.Core.Tests.Substitutes; +using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 45a70a2..2d28047 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -95,14 +95,5 @@ 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/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 0be04ed..d412632 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,7 +1,11 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Model; public class Client { public Guid Id { get; set; } public string Name { get; set; } + + public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 641f4b8..35c76e8 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; @@ -19,4 +21,10 @@ public class Realm public List Clients { get; init; } = []; public List Keys { get; init; } = []; -} \ No newline at end of file + + /// + /// Can be overriden per client + /// + public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + +} diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs index 1f0e9de..62abf6a 100644 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ b/IdentityShroud.Core/Security/AesGcmHelper.cs @@ -7,14 +7,22 @@ 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]; + int tagSize = AesGcm.TagByteSizes.MaxSize; + using var aes = new AesGcm(key, tagSize); + + Span nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize]; + RandomNumberGenerator.Fill(nonce); + Span ciphertext = stackalloc byte[plaintext.Length]; + Span tag = stackalloc byte[tagSize]; aes.Encrypt(nonce, plaintext, ciphertext, tag); - // Return concatenated nonce|ciphertext|tag (or store separately) - return nonce.Concat(ciphertext).Concat(tag).ToArray(); + + // Return concatenated nonce|ciphertext|tag + var result = new byte[nonce.Length + ciphertext.Length + tag.Length]; + nonce.CopyTo(result.AsSpan(0, nonce.Length)); + ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length)); + tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length)); + return result; } // -------------------------------------------------------------------- @@ -44,11 +52,10 @@ public static class AesGcmHelper 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); + using var aes = new AesGcm(key, tagSize); try { aes.Decrypt(nonce, ciphertext, tag, plaintext); diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs new file mode 100644 index 0000000..cbdcf05 --- /dev/null +++ b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs @@ -0,0 +1,8 @@ +using System.Security.Cryptography; + +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/RsaHelper.cs b/IdentityShroud.Core/Security/RsaHelper.cs index 9d35ad7..ab49ebd 100644 --- a/IdentityShroud.Core/Security/RsaHelper.cs +++ b/IdentityShroud.Core/Security/RsaHelper.cs @@ -4,4 +4,13 @@ namespace IdentityShroud.Core.Security; public static class RsaHelper { + /// + /// 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/Services/IRealmService.cs b/IdentityShroud.Core/Services/IRealmService.cs index 26af4d6..4ce1da4 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Services/IRealmService.cs @@ -8,4 +8,5 @@ public interface IRealmService Task FindBySlug(string slug, CancellationToken ct = default); Task> Create(RealmCreateRequest request, CancellationToken ct = default); + Task LoadActiveKeys(Realm realm); } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 7ed114a..57c4cf2 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -15,7 +15,8 @@ public class RealmService( { public async Task FindBySlug(string slug, CancellationToken ct = default) { - return await db.Realms.SingleOrDefaultAsync(r => r.Slug == slug, ct); + return await db.Realms + .SingleOrDefaultAsync(r => r.Slug == slug, ct); } public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) @@ -35,6 +36,15 @@ public class RealmService( realm.Id, realm.Slug, realm.Name); } + public async Task LoadActiveKeys(Realm realm) + { + await db.Entry(realm).Collection(r => r.Keys) + .Query() + .Where(k => k.DeactivatedAt == null) + .LoadAsync(); + + } + private Key CreateKey() { using RSA rsa = RSA.Create(2048); diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 1b6abab..0b8cba9 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -12,4 +12,19 @@ + + + + + + + + + + + + ..\..\..\.nuget\packages\nsubstitute\5.3.0\lib\net6.0\NSubstitute.dll + + + diff --git a/IdentityShroud.Core.Tests/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs similarity index 90% rename from IdentityShroud.Core.Tests/Substitutes/EncryptionServiceSubstitute.cs rename to IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index cf79318..bb26ee9 100644 --- a/IdentityShroud.Core.Tests/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -1,6 +1,6 @@ using IdentityShroud.Core.Contracts; -namespace IdentityShroud.Core.Tests.Substitutes; +namespace IdentityShroud.TestUtils.Substitutes; public static class EncryptionServiceSubstitute { diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 3107fc6..a850ec0 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -1,18 +1,27 @@  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 #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> + + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -20,4 +29,5 @@ <Solution /> </SessionState> + \ No newline at end of file