diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 232d3ca..a77becf 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index cb178ff..12cd304 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,5 +1,7 @@ using IdentityShroud.Api; using IdentityShroud.Core; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; using Serilog; using Serilog.Formatting.Json; @@ -30,6 +32,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddOpenApi(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); + services.AddSingleton(); builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() diff --git a/IdentityShroud.Api/RealmController.cs b/IdentityShroud.Api/RealmController.cs index 023e251..1e647da 100644 --- a/IdentityShroud.Api/RealmController.cs +++ b/IdentityShroud.Api/RealmController.cs @@ -1,5 +1,8 @@ using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; namespace IdentityShroud.Api; @@ -10,6 +13,9 @@ public static class RealmController var realm = app.MapGroup("/realms/{slug}"); realm.MapGet("", GetRoot); + realm.MapPost("", (RealmCreateRequest request, [FromServices] RealmService service) => + service.Create(request)) + .WithName("Create Realm"); realm.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); var openidConnect = realm.MapGroup("openid-connect"); diff --git a/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs b/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs new file mode 100644 index 0000000..ff00c06 --- /dev/null +++ b/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs @@ -0,0 +1,65 @@ +using FluentResults; + +namespace IdentityShroud.Core.Tests; + +public static class ResultAssert +{ + public static void Success(Result result) + { + if (!result.IsSuccess) + { + var errors = string.Join("\n", result.Errors.Select(e => "\t" + e.Message)); + Assert.True(result.IsSuccess, $"ResultAssert.Success: failed, got errors:\n{errors}"); + } + } + + public static T Success(Result result) + { + Success(result.ToResult()); + return result.Value; + } + + public static void Failed(Result result, Predicate? filter = null) + { + if (!result.IsFailed) + { + Assert.Fail("ResultAssert.Failed: failed, unexpected success result"); + } + + if (filter is not null) + Assert.Contains(result.Errors, filter); + } + + public static void Failed(Result result, Predicate? filter = null) + { + Failed(result.ToResult(), filter); + } + + public static void FailedWith(Result result) where TError : IError + { + if (!result.IsFailed) + { + Assert.Fail("ResultAssert.Failed: failed, unexpected success result"); + } + + if (!result.Errors.Any(e => e is TError)) + { + string typeName = typeof(TError).Name; + Assert.Fail($"ResultAssert.Failed: failed, no error of the type {typeName} found"); + } + } + + public static void FailedWith(Result result) where TError : IError + { + if (!result.IsFailed) + { + Assert.Fail("ResultAssert.Failed: failed, unexpected success result"); + } + + if (!result.Errors.Any(e => e is TError)) + { + string typeName = typeof(TError).Name; + Assert.Fail($"ResultAssert.Failed: failed, no error of the type {typeName} found"); + } + } +} diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs new file mode 100644 index 0000000..573fb8b --- /dev/null +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -0,0 +1,70 @@ +using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Npgsql; +using Testcontainers.PostgreSql; + +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); + + public Db CreateDbContext(string dbName) + { + var db = new Db(Options.Create(new() + { + ConnectionString = ConnectionString + ";Database=" + dbName, + LogSensitiveData = false, + }), new NullLoggerFactory()); + return db; + } + + public DbFixture() + { + _postgresqlServer = new PostgreSqlBuilder("postgres:18.1") + .WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D")) + .WithPassword(Password) + .Build(); + } + + public async ValueTask DisposeAsync() + { + await _postgresqlServer.StopAsync(); + } + + public async ValueTask InitializeAsync() + { + await _postgresqlServer.StartAsync(); + } + + public NpgsqlConnection GetConnection(string dbname) + { + string connString = ConnectionString + + $";Database={dbname}"; + var connection = new NpgsqlConnection(connString); + connection.Open(); + return connection; + } +} + +/* + +[CollectionDefinition("PostgresqlFixtureCollection", DisableParallelization = false)] +public class PostgresqlFactoryCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} +*/ \ 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 02a3ef9..3cb7db3 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -12,12 +12,16 @@ - + + + + + diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj.DotSettings b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj.DotSettings new file mode 100644 index 0000000..cec61c0 --- /dev/null +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ 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/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/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs new file mode 100644 index 0000000..8879e01 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -0,0 +1,52 @@ +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 Db _db; + + public RealmServiceTests(DbFixture dbFixture) + { + _db = dbFixture.CreateDbContext("realmservice"); + + if (!_db.Database.EnsureCreated()) + TruncateTables(); + } + + private void TruncateTables() + { + _db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + } + + [Theory] + [InlineData(null)] + [InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")] + public async Task Create(string? idString) + { + Guid? realmId = null; + if (idString is not null) + realmId = new(idString); + + 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.Realm.Id); + else + Assert.NotEqual(Guid.Empty, val.Realm.Id); + + Assert.Equal("slug", val.Realm.Slug); + Assert.Equal("New realm", val.Realm.Name); + Assert.NotEmpty(val.Realm.PrivateKeyEncrypted); + } +} \ 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/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/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index 8a1042b..73cd3a6 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -2,5 +2,5 @@ namespace IdentityShroud.Core.Contracts; public interface ISecretProvider { - Task GetSecretAsync(string name); + string GetSecretAsync(string name); } diff --git a/IdentityShroud.Core/Messages/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs similarity index 100% rename from IdentityShroud.Core/Messages/JsonWebKey.cs rename to IdentityShroud.Core/DTO/JsonWebKey.cs diff --git a/IdentityShroud.Core/Messages/JsonWebKeySet.cs b/IdentityShroud.Core/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Core/Messages/JsonWebKeySet.cs rename to IdentityShroud.Core/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Core/Messages/JsonWebToken.cs b/IdentityShroud.Core/DTO/JsonWebToken.cs similarity index 100% rename from IdentityShroud.Core/Messages/JsonWebToken.cs rename to IdentityShroud.Core/DTO/JsonWebToken.cs diff --git a/IdentityShroud.Core/Messages/OpenIdConfiguration.cs b/IdentityShroud.Core/DTO/OpenIdConfiguration.cs similarity index 100% rename from IdentityShroud.Core/Messages/OpenIdConfiguration.cs rename to IdentityShroud.Core/DTO/OpenIdConfiguration.cs diff --git a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs new file mode 100644 index 0000000..9457aab --- /dev/null +++ b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs @@ -0,0 +1,3 @@ +namespace IdentityShroud.Core.Messages.Realm; + +public record RealmCreateRequest(Guid? Id, string Slug, string Description); \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 9949c5a..a87c996 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -8,9 +8,16 @@ + + + + + + + diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 458ad59..5fc9639 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,10 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; + namespace IdentityShroud.Core.Model; +[Table("realm")] public class Realm { + private byte[] _privateKeyDecrypted = []; + public Guid Id { get; set; } + /// + /// Note this is part of the url we should encourage users to keep it short but we do not want to limit them too much + /// + [MaxLength(40)] public string Slug { get; set; } = ""; - public string Description { get; set; } = ""; - public List Clients { get; set; } = []; - public byte[] PrivateKey { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = ""; + public List Clients { get; init; } = []; + + public byte[] PrivateKeyEncrypted + { + get; + set + { + field = value; + _privateKeyDecrypted = []; + } + } = []; + + public byte[] GetPrivateKey(IEncryptionService encryptionService) + { + if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + return _privateKeyDecrypted; + } + + public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) + { + PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); + _privateKeyDecrypted = privateKey; + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs new file mode 100644 index 0000000..01be0a9 --- /dev/null +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -0,0 +1,17 @@ +using IdentityShroud.Core.Contracts; +using Microsoft.Extensions.Configuration; + +namespace IdentityShroud.Core.Security; + +/// +/// Secret provider that retrieves secrets from configuration. +/// +public class ConfigurationSecretProvider(IConfiguration configuration) : ISecretProvider +{ + private readonly IConfigurationSection secrets = configuration.GetSection("secrets"); + + public string GetSecretAsync(string name) + { + return secrets.GetValue(name) ?? ""; + } +} \ 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/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/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs new file mode 100644 index 0000000..1989e93 --- /dev/null +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Services; + +public record RealmCreateResponse(Realm Realm); + +public class RealmService( + Db db, + IEncryptionService encryptionService) +{ + public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) + { + Realm realm = new() + { + Id = request.Id ?? Guid.CreateVersion7(), + Slug = request.Slug, + Name = request.Description, + }; + + using RSA rsa = RSA.Create(2048); + realm.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + db.Add(realm); + await db.SaveChangesAsync(ct); + + return new RealmCreateResponse(realm); + } +} \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 3f70f92..125edc1 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -1,14 +1,15 @@  + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="DecodeTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.UnitTest1.DecodeTest</TestId> - <TestId>xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.UnitTest1.CreateTest</TestId> - <TestId>xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.Security.AesGcmHelperTests.EncryptDecryptCycleWorks</TestId> - </TestAncestor> -</SessionState> \ No newline at end of file + <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