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