From e07d6e3ea5373f4a986b46d4c49763ceae2c0d0e Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 14 Feb 2026 14:38:30 +0100 Subject: [PATCH 01/14] Add test for JwtSignatureGenerator --- .../JwtSignatureGeneratorTests.cs | 83 +++++++++++++++++++ .../Security/JwtSignatureGenerator.cs | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs new file mode 100644 index 0000000..0fb0a42 --- /dev/null +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -0,0 +1,83 @@ +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/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs index 11f8dc2..e22cfca 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 class JwtSignatureGenerator +public static class JwtSignatureGenerator { /// /// Generates a JWT signature using RS256 algorithm From d440979451a39890afa6a718cdcd9481178d033d Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 14 Feb 2026 14:50:06 +0100 Subject: [PATCH 02/14] Add tests and fixes to .well-known/openid-configuration and create realm --- .../Apis/RealmApisTests.cs | 54 ++- .../IdentityShroud.Api.Tests.csproj | 1 + IdentityShroud.Api/Apis/RealmApi.cs | 12 +- .../IdentityShroud.Core.Tests.csproj | 1 + .../Model/{RealmTests.cs => KeyTests.cs} | 18 +- IdentityShroud.Core/Db.cs | 1 + IdentityShroud.Core/Model/Key.cs | 45 +++ IdentityShroud.Core/Model/Realm.cs | 25 +- IdentityShroud.Core/Services/IRealmService.cs | 3 + IdentityShroud.Core/Services/RealmService.cs | 22 +- .../Asserts/JsonObjectAssertTests.cs | 70 ++++ .../IdentityShroud.TestUtils.Tests.csproj | 26 ++ .../Asserts/JsonObjectAssert.cs | 364 ++++++++++++++++++ .../Asserts/ResultAssert.cs | 1 + .../IdentityShroud.TestUtils.csproj | 15 + IdentityShroud.sln | 20 + IdentityShroud.sln.DotSettings.user | 9 +- 17 files changed, 642 insertions(+), 45 deletions(-) rename IdentityShroud.Core.Tests/Model/{RealmTests.cs => KeyTests.cs} (70%) create mode 100644 IdentityShroud.Core/Model/Key.cs create mode 100644 IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs create mode 100644 IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj create mode 100644 IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs rename {IdentityShroud.Core.Tests => IdentityShroud.TestUtils}/Asserts/ResultAssert.cs (99%) create mode 100644 IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 7e6192e..1b1743b 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,9 +1,12 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json.Nodes; using FluentResults; using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; using NSubstitute.ClearExtensions; @@ -34,28 +37,65 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture(r => r.Id == inputId && r.Slug == slug && r.Name == name), + 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(), Arg.Any()); } } + + [Fact] + public async Task GetOpenIdConfiguration_Success() + { + // setup + factory.RealmService.FindBySlug(Arg.Is("foo"), Arg.Any()) + .Returns(new Realm()); + + // act + var client = factory.CreateClient(); + var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", + TestContext.Current.CancellationToken); + + // verify + var result = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/auth", result, "authorization_endpoint"); + JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer"); + JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint"); + JsonObjectAssert.Equal("http://localhost/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/bar/.well-known/openid-configuration", + TestContext.Current.CancellationToken); + + // verify + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } } \ 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 6351c40..a3aa6a8 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 265fbb9..6eb9dab 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -18,7 +18,7 @@ public static class RealmApi .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = app.MapGroup("{slug}"); + var realmSlugGroup = realmsGroup.MapGroup("{slug}"); realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); @@ -54,10 +54,18 @@ public static class RealmApi throw new NotImplementedException(); } - private static async Task, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context) + private static async Task, BadRequest, NotFound>> GetOpenIdConfiguration( + [FromServices]IRealmService realmService, + HttpContext context, + string slug) { if (string.IsNullOrEmpty(slug)) return TypedResults.BadRequest(); + + var realm = await realmService.FindBySlug(slug); + if (realm is null) + return TypedResults.NotFound(); + var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; var searchString = $"realms/{slug}"; int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 3cb7db3..bc2d19b 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -26,6 +26,7 @@ + \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Model/RealmTests.cs b/IdentityShroud.Core.Tests/Model/KeyTests.cs similarity index 70% rename from IdentityShroud.Core.Tests/Model/RealmTests.cs rename to IdentityShroud.Core.Tests/Model/KeyTests.cs index 959d8b1..e7e9b45 100644 --- a/IdentityShroud.Core.Tests/Model/RealmTests.cs +++ b/IdentityShroud.Core.Tests/Model/KeyTests.cs @@ -3,7 +3,7 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Tests.Model; -public class RealmTests +public class KeyTests { [Fact] public void SetNewKey() @@ -16,12 +16,12 @@ public class RealmTests .Encrypt(Arg.Any()) .Returns(x => encryptedPrivateKey); - Realm realm = new(); - realm.SetPrivateKey(encryptionService, privateKey); + Key key = new(); + key.SetPrivateKey(encryptionService, privateKey); // should be able to return original without calling decrypt - Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); encryptionService.Received(1).Encrypt(privateKey); encryptionService.DidNotReceive().Decrypt(Arg.Any()); @@ -38,12 +38,12 @@ public class RealmTests .Decrypt(encryptedPrivateKey) .Returns(x => privateKey); - Realm realm = new(); - realm.PrivateKeyEncrypted = encryptedPrivateKey; + Key key = new(); + key.PrivateKeyEncrypted = encryptedPrivateKey; // should be able to return original without calling decrypt - Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); encryptionService.Received(1).Decrypt(encryptedPrivateKey); } diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index 2f95902..b476787 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -17,6 +17,7 @@ public class Db( : DbContext { public virtual DbSet Realms { get; set; } + public virtual DbSet Keys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/IdentityShroud.Core/Model/Key.cs b/IdentityShroud.Core/Model/Key.cs new file mode 100644 index 0000000..ee09d31 --- /dev/null +++ b/IdentityShroud.Core/Model/Key.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Model; + + +[Table("key")] +public class Key +{ + private byte[] _privateKeyDecrypted = []; + + public Guid Id { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? DeactivatedAt { 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; + + 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/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 5fc9639..641f4b8 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,13 +1,11 @@ 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; } /// @@ -20,26 +18,5 @@ public class Realm 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; - } + public List Keys { get; init; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/IRealmService.cs b/IdentityShroud.Core/Services/IRealmService.cs index 7a8ef79..26af4d6 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Services/IRealmService.cs @@ -1,8 +1,11 @@ using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Services; public interface IRealmService { + Task FindBySlug(string slug, CancellationToken ct = default); + Task> Create(RealmCreateRequest request, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 50cb61d..00de987 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -12,6 +12,11 @@ public class RealmService( Db db, IEncryptionService encryptionService) : IRealmService { + public Task FindBySlug(string slug, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() @@ -19,10 +24,10 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, + Keys = [ CreateKey() ], }; - using RSA rsa = RSA.Create(2048); - realm.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + db.Add(realm); await db.SaveChangesAsync(ct); @@ -30,4 +35,17 @@ public class RealmService( return new RealmCreateResponse( realm.Id, realm.Slug, realm.Name); } + + private Key CreateKey() + { + using RSA rsa = RSA.Create(2048); + + Key key = new() + { + Priority = 10, + }; + key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + return key; + } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs b/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs new file mode 100644 index 0000000..1ccaf34 --- /dev/null +++ b/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..9ce8074 --- /dev/null +++ b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs new file mode 100644 index 0000000..3352bc6 --- /dev/null +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -0,0 +1,364 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Xunit; + +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.Core.Tests/Asserts/ResultAssert.cs b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs similarity index 99% rename from IdentityShroud.Core.Tests/Asserts/ResultAssert.cs rename to IdentityShroud.TestUtils/Asserts/ResultAssert.cs index ff00c06..28a0b11 100644 --- a/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs @@ -1,4 +1,5 @@ using FluentResults; +using Xunit; namespace IdentityShroud.Core.Tests; diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj new file mode 100644 index 0000000..1b6abab --- /dev/null +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + false + + + + + + + + diff --git a/IdentityShroud.sln b/IdentityShroud.sln index b0de020..ef65bf2 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -12,6 +12,12 @@ 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 @@ -38,5 +44,19 @@ 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 21dd76b..3107fc6 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -10,7 +10,14 @@ /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 #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> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + \ No newline at end of file From ed52e2f789b1fec59227de2600a8e982619effb4 Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 14 Feb 2026 14:54:48 +0100 Subject: [PATCH 03/14] Fixes some warnings. --- .../AppJsonSerializerContext.cs | 2 ++ IdentityShroud.Core/DTO/JsonWebKey.cs | 15 +++++++---- IdentityShroud.Core/DTO/JsonWebToken.cs | 25 ++++++++++--------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index 24d042f..9b075ce 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -1,8 +1,10 @@ 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.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs index e22a899..7ccec61 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -2,26 +2,31 @@ using System.Text.Json.Serialization; 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 for now we will use RS256 [JsonPropertyName("alg")] - public string Algorithm { get; set; } = "RS256"; + public string? Algorithm { get; set; } = "RS256"; [JsonPropertyName("kid")] - public string KeyId { get; set; } + public required string KeyId { get; set; } // RSA Public Key Components [JsonPropertyName("n")] - public string Modulus { get; set; } + public required string Modulus { get; set; } [JsonPropertyName("e")] - public string Exponent { get; set; } + public required string Exponent { get; set; } // Optional fields [JsonPropertyName("x5c")] diff --git a/IdentityShroud.Core/DTO/JsonWebToken.cs b/IdentityShroud.Core/DTO/JsonWebToken.cs index 65d671f..75b7dae 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.Messages; +namespace IdentityShroud.Core.DTO; public class JsonWebTokenHeader { @@ -9,31 +9,32 @@ public class JsonWebTokenHeader [JsonPropertyName("typ")] public string Type { get; set; } = "JWT"; [JsonPropertyName("kid")] - public string KeyId { get; set; } + public required 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 JsonWebTokenHeader Header { get; set; } = new(); - public JsonWebTokenPayload Payload { get; set; } = new(); - public byte[] Signature { get; set; } = []; + public required JsonWebTokenHeader Header { get; set; } + public required JsonWebTokenPayload Payload { get; set; } + public required byte[] Signature { get; set; } = []; } \ No newline at end of file From 7a5cb703ec99fe8759b47df08a15b6f2f26751ee Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 06:54:25 +0100 Subject: [PATCH 04/14] SlugHelperTests --- .../Helpers/SlugHelperTests.cs | 26 +++++++++++++++++++ IdentityShroud.Core.Tests/UnitTest1.cs | 1 + IdentityShroud.Core/Helpers/SlugHelper.cs | 4 +-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs diff --git a/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs b/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs new file mode 100644 index 0000000..de94511 --- /dev/null +++ b/IdentityShroud.Core.Tests/Helpers/SlugHelperTests.cs @@ -0,0 +1,26 @@ +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/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index a998f4a..45a70a2 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -1,6 +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; diff --git a/IdentityShroud.Core/Helpers/SlugHelper.cs b/IdentityShroud.Core/Helpers/SlugHelper.cs index 0c74455..beef894 100644 --- a/IdentityShroud.Core/Helpers/SlugHelper.cs +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -73,9 +73,9 @@ public static class SlugHelper private static string GenerateHashSuffix(string text) { - using (var sha256 = SHA256.Create()) + using (var md5 = MD5.Create()) { - byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(text)); + byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(text)); // Take first 4 bytes (will become ~5-6 base64url chars) string base64Url = WebEncoders.Base64UrlEncode(hash, 0, 4); From a80c133e2a40946d1b4d902f2db2f5dc075e61d9 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 07:15:11 +0100 Subject: [PATCH 05/14] Tests for RealmService.FindBySlug --- .../Fixtures/DbFixture.cs | 4 +- .../Services/RealmServiceTests.cs | 54 +++++++++++++++---- IdentityShroud.Core/Services/RealmService.cs | 7 ++- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 573fb8b..186094e 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -19,8 +19,8 @@ public class DbFixture : IAsyncLifetime private string Password => "password"; private string DbHostname => _postgresqlServer.Hostname; private int DbPort => _postgresqlServer.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort); - - public Db CreateDbContext(string dbName) + + public Db CreateDbContext(string dbName = "testdb") { var db = new Db(Options.Create(new() { diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 0ad00ef..d3b884e 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,4 +1,5 @@ using FluentResults; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Substitutes; @@ -8,19 +9,20 @@ namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { - private readonly Db _db; + private readonly DbFixture _dbFixture; + private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); public RealmServiceTests(DbFixture dbFixture) { - _db = dbFixture.CreateDbContext("realmservice"); - - if (!_db.Database.EnsureCreated()) - TruncateTables(); + _dbFixture = dbFixture; + using Db db = dbFixture.CreateDbContext(); + if (!db.Database.EnsureCreated()) + TruncateTables(db); } - private void TruncateTables() + private void TruncateTables(Db db) { - _db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); } [Theory] @@ -28,17 +30,20 @@ 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); - - var encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); - RealmService sut = new(_db, encryptionService); + + using Db db = _dbFixture.CreateDbContext(); + RealmService sut = new(db, _encryptionService); + // Act var response = await sut.Create( new(realmId, "slug", "New realm"), TestContext.Current.CancellationToken); + // Verify RealmCreateResponse val = ResultAssert.Success(response); if (realmId.HasValue) Assert.Equal(realmId, val.Id); @@ -50,4 +55,33 @@ public class RealmServiceTests : IClassFixture // TODO verify data has been stored! } + + [Theory] + [InlineData("slug", null)] + [InlineData("foo", "Foo")] + public async Task FindBySlug(string slug, string? name) + { + 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); + } + + using Db actContext = _dbFixture.CreateDbContext(); + RealmService sut = new(actContext, _encryptionService); + // Act + var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); + + Assert.Equal(name, result?.Name); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 00de987..7ed114a 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -3,6 +3,7 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Services; @@ -12,9 +13,9 @@ public class RealmService( Db db, IEncryptionService encryptionService) : IRealmService { - public Task FindBySlug(string slug, CancellationToken ct = default) + public async Task FindBySlug(string slug, CancellationToken ct = default) { - throw new NotImplementedException(); + return await db.Realms.SingleOrDefaultAsync(r => r.Slug == slug, ct); } public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) @@ -26,8 +27,6 @@ public class RealmService( Name = request.Name, Keys = [ CreateKey() ], }; - - db.Add(realm); await db.SaveChangesAsync(ct); From ccb06b260cb6a7a92119db9687c18d9ccf5baa66 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 19:06:09 +0100 Subject: [PATCH 06/14] 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 From 3e5ce9d81d591857ed69c910a8a77cffae483fd9 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 19:18:02 +0100 Subject: [PATCH 07/14] EncryptionService should be using ISecretProvider Remove Async from method that was not Async --- IdentityShroud.Api/Program.cs | 6 +--- .../Services/EncryptionServiceTests.cs | 6 +++- .../Contracts/ISecretProvider.cs | 2 +- .../Security/ConfigurationSecretProvider.cs | 2 +- .../Services/EncryptionService.cs | 36 +++++++++++++++++++ .../Services/MasterEncryptionService.cs | 23 ------------ 6 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 IdentityShroud.Core/Services/EncryptionService.cs delete mode 100644 IdentityShroud.Core/Services/MasterEncryptionService.cs diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 57aaed4..66a7554 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -40,11 +40,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) 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.AddSingleton(); services.AddValidatorsFromAssemblyContaining(); diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs index e97b2df..b855732 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Tests.Services; @@ -10,7 +11,10 @@ public class EncryptionServiceTests { // setup string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - EncryptionService sut = new(key); + var secretProvider = Substitute.For(); + secretProvider.GetSecret("Master").Returns(key); + + EncryptionService sut = new(secretProvider); byte[] input = RandomNumberGenerator.GetBytes(16); // act diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index 73cd3a6..2a8e9e6 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -2,5 +2,5 @@ namespace IdentityShroud.Core.Contracts; public interface ISecretProvider { - string GetSecretAsync(string name); + string GetSecret(string name); } diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index 01be0a9..ab77ef1 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -10,7 +10,7 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret { private readonly IConfigurationSection secrets = configuration.GetSection("secrets"); - public string GetSecretAsync(string name) + public string GetSecret(string name) { return secrets.GetValue(name) ?? ""; } diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs new file mode 100644 index 0000000..845f43e --- /dev/null +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -0,0 +1,36 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +/// +/// +/// +public class EncryptionService : IEncryptionService +{ + private readonly byte[] encryptionKey; + + /// + /// For easier usage in + /// + /// Encryption key as base64, must be 32 bytes + // public EncryptionService(string keyBase64) + // { + // encryptionKey = Convert.FromBase64String(keyBase64); + // } + + public EncryptionService(ISecretProvider secretProvider) + { + encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); + } + + 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/MasterEncryptionService.cs b/IdentityShroud.Core/Services/MasterEncryptionService.cs deleted file mode 100644 index d0b5eda..0000000 --- a/IdentityShroud.Core/Services/MasterEncryptionService.cs +++ /dev/null @@ -1,23 +0,0 @@ -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 From 138f335af0e2336eacbe0b906c78c839174eb290 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 15 Feb 2026 19:34:17 +0100 Subject: [PATCH 08/14] Within the openid spec there is no need to return data at realms/name. Removed placeholders. --- IdentityShroud.Api/Apis/RealmApi.cs | 16 ---------------- .../Services/EncryptionService.cs | 9 --------- 2 files changed, 25 deletions(-) diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index ea62361..d5e439b 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -29,7 +29,6 @@ public static class RealmApi var realmSlugGroup = realmsGroup.MapGroup("{slug}") .AddEndpointFilter(); - realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); var openidConnect = realmSlugGroup.MapGroup("openid-connect"); @@ -90,19 +89,4 @@ public static class RealmApi 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 - } - */ - } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index 845f43e..24cdd18 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -10,15 +10,6 @@ public class EncryptionService : IEncryptionService { private readonly byte[] encryptionKey; - /// - /// For easier usage in - /// - /// Encryption key as base64, must be 32 bytes - // public EncryptionService(string keyBase64) - // { - // encryptionKey = Convert.FromBase64String(keyBase64); - // } - public EncryptionService(ISecretProvider secretProvider) { encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); From 07393f57fc7b0baa78b1ae54dcac70a674ba1db7 Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 27 Feb 2026 17:57:42 +0000 Subject: [PATCH 09/14] 5-improve-encrypted-storage (#6) Added the use of DEK's for encryption of secrets. Both the KEK's and DEK's are stored in a way that you can have multiple key of which one is active. But the others are still available for decrypting. This allows for implementing key rotation. Co-authored-by: eelke Co-authored-by: Eelke76 <31384324+Eelke76@users.noreply.github.com> Reviewed-on: https://code.eelkeklein.nl/eelke/IdentityShroud/pulls/6 --- .../Apis/ClientApiTests.cs | 179 ++++++++++++++++++ .../Apis/RealmApisTests.cs | 38 ++-- .../Fixtures/ApplicationFactory.cs | 10 +- .../Mappers/KeyMapperTests.cs | 41 ---- .../Mappers/KeyServiceTests.cs | 46 +++++ IdentityShroud.Api/Apis/ClientApi.cs | 73 +++++++ .../Apis/Dto/ClientRepresentation.cs | 16 ++ .../Apis/EndpointRouteBuilderExtensions.cs | 15 ++ .../Apis/Filters/ClientIdValidationFilter.cs | 21 ++ .../Apis/Filters/RealmIdValidationFilter.cs | 20 ++ ...Filter.cs => RealmSlugValidationFilter.cs} | 9 +- .../Apis/Mappers/ClientMapper.cs | 11 ++ IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 34 ++-- IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 72 +++++++ IdentityShroud.Api/Apis/RealmApi.cs | 67 ++----- .../ClientCreateRequestValidator.cs | 22 +++ .../Validation/RealmCreateRequestValidator.cs | 2 +- .../{ => Apis}/Validation/ValidateFilter.cs | 2 +- .../AppJsonSerializerContext.cs | 1 - IdentityShroud.Api/IdentityShroud.Api.csproj | 2 +- .../IdentityShroud.Api.csproj.DotSettings | 4 +- IdentityShroud.Api/Program.cs | 19 +- .../EndpointRouteBuilderExtensions.cs | 7 - .../Fixtures/DbFixture.cs | 3 +- .../Helpers/Base64UrlConverterTests.cs | 36 ++++ .../JwtSignatureGeneratorTests.cs | 4 +- IdentityShroud.Core.Tests/Model/KeyTests.cs | 51 ----- .../Security/AesGcmHelperTests.cs | 21 -- .../ConfigurationSecretProviderTests.cs | 63 ++++++ .../Services/ClientServiceTests.cs | 155 +++++++++++++++ .../Services/DataEncryptionServiceTests.cs | 64 +++++++ .../Services/DekEncryptionServiceTests.cs | 123 ++++++++++++ .../Services/EncryptionServiceTests.cs | 26 --- .../Services/EncryptionTests.cs | 30 +++ .../Services/RealmServiceTests.cs | 105 +++++++--- IdentityShroud.Core.Tests/UnitTest1.cs | 15 +- .../Contracts/IClientService.cs | 14 ++ IdentityShroud.Core/Contracts/IClock.cs | 6 + .../Contracts/IDataEncryptionService.cs | 9 + .../Contracts/IDekEncryptionService.cs | 11 ++ .../Contracts/IEncryptionService.cs | 7 - IdentityShroud.Core/Contracts/IKeyService.cs | 12 ++ .../Contracts/IRealmContext.cs | 9 + .../{Services => Contracts}/IRealmService.cs | 5 +- .../Contracts/ISecretProvider.cs | 8 + .../DTO/Client/ClientCreateRequest.cs | 10 + .../DTO/JsonWebKey.cs | 26 ++- .../DTO/JsonWebKeySet.cs | 0 IdentityShroud.Core/Db.cs | 41 +++- .../Helpers/Base64UrlConverter.cs | 28 +++ IdentityShroud.Core/Helpers/SlugHelper.cs | 1 - .../IdentityShroud.Core.csproj | 9 +- IdentityShroud.Core/Model/Client.cs | 26 ++- IdentityShroud.Core/Model/ClientSecret.cs | 17 ++ IdentityShroud.Core/Model/Key.cs | 45 ----- IdentityShroud.Core/Model/Realm.cs | 16 +- IdentityShroud.Core/Model/RealmKey.cs | 27 +++ IdentityShroud.Core/Security/AesGcmHelper.cs | 71 ------- .../Security/ConfigurationSecretProvider.cs | 5 + IdentityShroud.Core/Security/DekId.cs | 6 + IdentityShroud.Core/Security/EncryptedDek.cs | 6 + .../Security/EncryptedValue.cs | 8 + IdentityShroud.Core/Security/Encryption.cs | 70 +++++++ .../Security/JsonWebAlgorithm.cs | 2 - IdentityShroud.Core/Security/KekId.cs | 41 ++++ .../Security/KeyEncryptionKey.cs | 10 + .../Security/Keys/IKeyProvider.cs | 19 ++ .../Security/Keys/IKeyProviderFactory.cs | 7 + .../Security/Keys/KeyProviderFactory.cs | 17 ++ .../Security/Keys/Rsa/RsaProvider.cs | 35 ++++ IdentityShroud.Core/Security/RsaHelper.cs | 16 -- IdentityShroud.Core/Services/ClientService.cs | 65 +++++++ IdentityShroud.Core/Services/ClockService.cs | 11 ++ .../Services/DataEncryptionService.cs | 41 ++++ .../Services/DekEncryptionService.cs | 38 ++++ .../Services/EncryptionService.cs | 27 --- IdentityShroud.Core/Services/KeyService.cs | 46 +++++ IdentityShroud.Core/Services/RealmContext.cs | 26 +++ IdentityShroud.Core/Services/RealmService.cs | 39 ++-- .../Asserts/JsonObjectAssert.cs | 1 - .../Asserts/ResultAssert.cs | 1 - .../IdentityShroud.TestUtils.csproj | 7 +- .../EncryptionServiceSubstitute.cs | 18 -- .../Substitutes/NullDataEncryptionService.cs | 18 ++ .../Substitutes/NullDekEncryptionService.cs | 18 ++ IdentityShroud.sln.DotSettings.user | 29 ++- README.md | 4 + 87 files changed, 1903 insertions(+), 533 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/ClientApiTests.cs delete mode 100644 IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs create mode 100644 IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs create mode 100644 IdentityShroud.Api/Apis/ClientApi.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs create mode 100644 IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs create mode 100644 IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs rename IdentityShroud.Api/Apis/Filters/{SlugValidationFilter.cs => RealmSlugValidationFilter.cs} (58%) create mode 100644 IdentityShroud.Api/Apis/Mappers/ClientMapper.cs create mode 100644 IdentityShroud.Api/Apis/OpenIdEndpoints.cs create mode 100644 IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs rename IdentityShroud.Api/{ => Apis}/Validation/RealmCreateRequestValidator.cs (92%) rename IdentityShroud.Api/{ => Apis}/Validation/ValidateFilter.cs (96%) delete mode 100644 IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs create mode 100644 IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs delete mode 100644 IdentityShroud.Core.Tests/Model/KeyTests.cs delete mode 100644 IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs create mode 100644 IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/ClientServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs delete mode 100644 IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/EncryptionTests.cs create mode 100644 IdentityShroud.Core/Contracts/IClientService.cs create mode 100644 IdentityShroud.Core/Contracts/IClock.cs create mode 100644 IdentityShroud.Core/Contracts/IDataEncryptionService.cs create mode 100644 IdentityShroud.Core/Contracts/IDekEncryptionService.cs delete mode 100644 IdentityShroud.Core/Contracts/IEncryptionService.cs create mode 100644 IdentityShroud.Core/Contracts/IKeyService.cs create mode 100644 IdentityShroud.Core/Contracts/IRealmContext.cs rename IdentityShroud.Core/{Services => Contracts}/IRealmService.cs (65%) create mode 100644 IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKey.cs (58%) rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKeySet.cs (100%) create mode 100644 IdentityShroud.Core/Helpers/Base64UrlConverter.cs create mode 100644 IdentityShroud.Core/Model/ClientSecret.cs delete mode 100644 IdentityShroud.Core/Model/Key.cs create mode 100644 IdentityShroud.Core/Model/RealmKey.cs delete mode 100644 IdentityShroud.Core/Security/AesGcmHelper.cs create mode 100644 IdentityShroud.Core/Security/DekId.cs create mode 100644 IdentityShroud.Core/Security/EncryptedDek.cs create mode 100644 IdentityShroud.Core/Security/EncryptedValue.cs create mode 100644 IdentityShroud.Core/Security/Encryption.cs create mode 100644 IdentityShroud.Core/Security/KekId.cs create mode 100644 IdentityShroud.Core/Security/KeyEncryptionKey.cs create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProvider.cs create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs delete mode 100644 IdentityShroud.Core/Security/RsaHelper.cs create mode 100644 IdentityShroud.Core/Services/ClientService.cs create mode 100644 IdentityShroud.Core/Services/ClockService.cs create mode 100644 IdentityShroud.Core/Services/DataEncryptionService.cs create mode 100644 IdentityShroud.Core/Services/DekEncryptionService.cs delete mode 100644 IdentityShroud.Core/Services/EncryptionService.cs create mode 100644 IdentityShroud.Core/Services/KeyService.cs create mode 100644 IdentityShroud.Core/Services/RealmContext.cs delete mode 100644 IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs create mode 100644 README.md diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs new file mode 100644 index 0000000..db984f1 --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -0,0 +1,179 @@ +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 350149b..ecc46c0 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture var client = _factory.CreateClient(); Guid? inputId = id is null ? (Guid?)null : new Guid(id); - var response = await client.PostAsync("/realms", JsonContent.Create(new + + // act + var response = await client.PostAsync("/api/v1/realms", JsonContent.Create(new { Id = inputId, Slug = slug, @@ -88,16 +90,21 @@ public class RealmApisTests : IClassFixture // act var client = _factory.CreateClient(); - var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", + 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/realms/foo/openid-connect/auth", result, "authorization_endpoint"); - JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer"); - JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint"); - JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/jwks", result, "jwks_uri"); + 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] @@ -107,7 +114,7 @@ public class RealmApisTests : IClassFixture { // act var client = _factory.CreateClient(); - var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration", + var response = await client.GetAsync($"/realms/{slug}/.well-known/openid-configuration", TestContext.Current.CancellationToken); // verify @@ -118,34 +125,35 @@ public class RealmApisTests : IClassFixture public async Task GetJwks() { // setup - IEncryptionService encryptionService = _factory.Services.GetRequiredService(); + IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - Key key = new() + + RealmKey realmKey = new() { Id = Guid.NewGuid(), + KeyType = "RSA", + Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); await ScopedContextAsync(async db => { - db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ key ]}); + 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("/realms/foo/openid-connect/jwks", + 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(key.Id.ToString(), payload, "keys[0].kid"); + 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"); } diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 6f4c461..9846559 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -1,11 +1,6 @@ -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; @@ -33,7 +28,10 @@ public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime new Dictionary { ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", + ["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=", }); }); diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs deleted file mode 100644 index 6c57971..0000000 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -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.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs new file mode 100644 index 0000000..f423f54 --- /dev/null +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..e595e34 --- /dev/null +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..80b5f13 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..3c47b48 --- /dev/null +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..771be81 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..97a1bb9 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs @@ -0,0 +1,20 @@ +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/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs similarity index 58% rename from IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs rename to IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs index 5bc699e..75338e1 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs @@ -1,5 +1,5 @@ +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; namespace IdentityShroud.Api; @@ -9,12 +9,13 @@ namespace IdentityShroud.Api; /// consistently. /// /// -public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter +public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - string slug = context.Arguments.OfType().First(); - Realm? realm = await realmService.FindBySlug(slug); + 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(); diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs new file mode 100644 index 0000000..8e58717 --- /dev/null +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -0,0 +1,11 @@ +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 index 00f5d7b..7155208 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -1,34 +1,22 @@ -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 class KeyMapper(IKeyService keyService) { - public JsonWebKey KeyToJsonWebKey(Key key) + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { - using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService)); - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - return new JsonWebKey() + JsonWebKeySet wks = new(); + foreach (var k in keys) { - 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(), - }; + 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 new file mode 100644 index 0000000..6565413 --- /dev/null +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -0,0 +1,72 @@ +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 d5e439b..88a5179 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,7 +1,4 @@ -using FluentResults; -using IdentityShroud.Api.Mappers; -using IdentityShroud.Api.Validation; -using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; @@ -15,26 +12,28 @@ 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(this IEndpointRouteBuilder app) + public static void MapRealmEndpoints(IEndpointRouteBuilder erp) { - var realmsGroup = app.MapGroup("/realms"); + var realmsGroup = erp.MapGroup("/api/v1/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{slug}") - .AddEndpointFilter(); - realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") + .AddEndpointFilter(); + + ClientApi.MapEndpoints(realmIdGroup); + + - var openidConnect = realmSlugGroup.MapGroup("openid-connect"); - openidConnect.MapPost("auth", OpenIdConnectAuth); - openidConnect.MapPost("token", OpenIdConnectToken); - openidConnect.MapGet("jwks", OpenIdConnectJwks); } private static async Task, InternalServerError>> @@ -47,46 +46,4 @@ public static class RealmApi // TODO make helper to convert failure response to a proper HTTP result. return TypedResults.InternalServerError(); } - - private static async Task, BadRequest>> OpenIdConnectJwks( - string slug, - [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(); - } - - private static async Task> GetOpenIdConfiguration( - string slug, - [FromServices]IRealmService realmService, - HttpContext context) - { - Realm realm = context.GetValidatedRealm(); - - 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); - } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs new file mode 100644 index 0000000..7666b36 --- /dev/null +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -0,0 +1,22 @@ +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/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs similarity index 92% rename from IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs rename to IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs index 8daa0a9..3e3a20a 100644 --- a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs +++ b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs @@ -1,7 +1,7 @@ using FluentValidation; using IdentityShroud.Core.Messages.Realm; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class RealmCreateRequestValidator : AbstractValidator { diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs similarity index 96% rename from IdentityShroud.Api/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Validation/ValidateFilter.cs index fbebd9d..d621441 100644 --- a/IdentityShroud.Api/Validation/ValidateFilter.cs +++ b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class ValidateFilter : IEndpointFilter where T : class { diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index 9b075ce..e7d90da 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -1,7 +1,6 @@ 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))] diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 72b4639..31f88b2 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 index bd2aa2d..c9c4f6a 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,3 +1,5 @@  True - True \ No newline at end of file + True + True + True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 66a7554..29f6736 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,10 +1,10 @@ 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,13 +36,21 @@ 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.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); + services.AddValidatorsFromAssemblyContaining(); + services.AddHttpContextAccessor(); builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() @@ -57,7 +65,8 @@ void ConfigureApplication(WebApplication app) app.MapOpenApi(); } app.UseSerilogRequestLogging(); - app.MapRealmEndpoints(); + app.MapApis(); + // app.UseRouting(); // app.MapControllers(); } diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index e67f787..0000000 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -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.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 85c2fbe..844d4ca 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -1,5 +1,4 @@ -using DotNet.Testcontainers.Containers; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; diff --git a/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs new file mode 100644 index 0000000..923a865 --- /dev/null +++ b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs @@ -0,0 +1,36 @@ +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/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index 0fb0a42..bf4d0a6 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -72,8 +72,8 @@ public class JwtSignatureGeneratorTests var rsa = RSA.Create(); var parameters = new RSAParameters { - Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus), - Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent) + Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), + Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) }; rsa.ImportParameters(parameters); diff --git a/IdentityShroud.Core.Tests/Model/KeyTests.cs b/IdentityShroud.Core.Tests/Model/KeyTests.cs deleted file mode 100644 index e7e9b45..0000000 --- a/IdentityShroud.Core.Tests/Model/KeyTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Tests.Model; - -public class KeyTests -{ - [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); - - Key key = new(); - key.SetPrivateKey(encryptionService, privateKey); - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.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); - - Key key = new(); - key.PrivateKeyEncrypted = encryptedPrivateKey; - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.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 deleted file mode 100644 index 6392676..0000000 --- a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -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 new file mode 100644 index 0000000..01851a4 --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..d0269e6 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -0,0 +1,155 @@ +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 new file mode 100644 index 0000000..4f88e48 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..fc4a45f --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -0,0 +1,123 @@ +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 deleted file mode 100644 index b855732..0000000 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core.Tests.Services; - -public class EncryptionServiceTests -{ - [Fact] - public void RoundtripWorks() - { - // setup - string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns(key); - - EncryptionService sut = new(secretProvider); - 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 new file mode 100644 index 0000000..2dfbb52 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -0,0 +1,30 @@ +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 5b830ea..fda233e 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,7 +1,9 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; @@ -9,7 +11,7 @@ namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IKeyService _keyService = Substitute.For(); public RealmServiceTests(DbFixture dbFixture) { @@ -34,25 +36,43 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - using Db db = _dbFixture.CreateDbContext(); - RealmService sut = new(db, _encryptionService); - // Act - - var response = await sut.Create( - new(realmId, "slug", "New realm"), - TestContext.Current.CancellationToken); - - // Verify - 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! + 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] @@ -60,7 +80,7 @@ public class RealmServiceTests : IClassFixture [InlineData("foo", "Foo")] public async Task FindBySlug(string slug, string? name) { - using (var setupContext = _dbFixture.CreateDbContext()) + await using (var setupContext = _dbFixture.CreateDbContext()) { setupContext.Realms.Add(new() { @@ -76,11 +96,48 @@ public class RealmServiceTests : IClassFixture await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); } - using Db actContext = _dbFixture.CreateDbContext(); - RealmService sut = new(actContext, _encryptionService); + 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); + } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 2d28047..7506fd0 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using IdentityShroud.Core.DTO; -using IdentityShroud.Core.Messages; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; @@ -36,7 +35,6 @@ 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"; @@ -52,6 +50,15 @@ 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 } } } @@ -67,9 +74,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)) }; } diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs new file mode 100644 index 0000000..20e270c --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..4ba7766 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClock.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..2810aaa --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..3032040 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -0,0 +1,11 @@ +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 deleted file mode 100644 index f85487d..0000000 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ /dev/null @@ -1,7 +0,0 @@ -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 new file mode 100644 index 0000000..4f6b5f7 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..c757a02 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IRealmContext.cs @@ -0,0 +1,9 @@ +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/Services/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs similarity index 65% rename from IdentityShroud.Core/Services/IRealmService.cs rename to IdentityShroud.Core/Contracts/IRealmService.cs index 4ce1da4..4598b97 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,12 +1,15 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; -namespace 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 2a8e9e6..4d4182e 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -1,6 +1,14 @@ +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); } diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs new file mode 100644 index 0000000..a162131 --- /dev/null +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -0,0 +1,10 @@ +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.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs similarity index 58% rename from IdentityShroud.Api/Apis/DTO/JsonWebKey.cs rename to IdentityShroud.Core/DTO/JsonWebKey.cs index e46107f..4f16955 100644 --- a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using IdentityShroud.Core.Helpers; namespace IdentityShroud.Core.Messages; @@ -25,17 +26,24 @@ public class JsonWebKey // RSA Public Key Components [JsonPropertyName("n")] - public required string Modulus { get; set; } + public string? Modulus { get; set; } [JsonPropertyName("e")] - public required string Exponent { get; set; } + 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; } // 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.Api/Apis/DTO/JsonWebKeySet.cs b/IdentityShroud.Core/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs rename to IdentityShroud.Core/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index b476787..a37136c 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -1,5 +1,7 @@ using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,9 +18,44 @@ 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 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 new file mode 100644 index 0000000..77f05f2 --- /dev/null +++ b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs @@ -0,0 +1,28 @@ +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 beef894..51aa0c3 100644 --- a/IdentityShroud.Core/Helpers/SlugHelper.cs +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -1,4 +1,3 @@ -using System; using System.Globalization; using System.Security.Cryptography; using System.Text; diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index a87c996..9dd3e34 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -11,7 +11,10 @@ + + + @@ -19,10 +22,4 @@ - - - ..\..\..\.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 d412632..5df6c1a 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,11 +1,29 @@ -using IdentityShroud.Core.Security; +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 { - public Guid Id { get; set; } - public string Name { get; set; } + [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; } - public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + [MaxLength(20)] + public string? SignatureAlgorithm { get; set; } + + public bool AllowClientCredentialsFlow { get; set; } = false; + + public required DateTime CreatedAt { get; set; } + + public List Secrets { get; set; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs new file mode 100644 index 0000000..52d25cc --- /dev/null +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -0,0 +1,17 @@ +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/Key.cs b/IdentityShroud.Core/Model/Key.cs deleted file mode 100644 index ee09d31..0000000 --- a/IdentityShroud.Core/Model/Key.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Core.Model; - - -[Table("key")] -public class Key -{ - private byte[] _privateKeyDecrypted = []; - - public Guid Id { get; set; } - - public DateTime CreatedAt { get; set; } - public DateTime? DeactivatedAt { 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; - - 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/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 35c76e8..bbe9631 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; @@ -20,11 +19,22 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + public List Keys { get; init; } = []; + + public List Deks { get; init; } = []; /// /// 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; } } diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs new file mode 100644 index 0000000..3fcf2d1 --- /dev/null +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -0,0 +1,27 @@ +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 deleted file mode 100644 index 62abf6a..0000000 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class AesGcmHelper -{ - - public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) - { - 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 - 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; - } - - // -------------------------------------------------------------------- - // 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, tagSize); - 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 ab77ef1..9355c0b 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -14,4 +14,9 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret { 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 new file mode 100644 index 0000000..276178e --- /dev/null +++ b/IdentityShroud.Core/Security/DekId.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..377a2f6 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..173c295 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..47344c1 --- /dev/null +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -0,0 +1,70 @@ +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 index cbdcf05..dc9bc28 100644 --- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs +++ b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; - namespace IdentityShroud.Core.Security; public static class JsonWebAlgorithm diff --git a/IdentityShroud.Core/Security/KekId.cs b/IdentityShroud.Core/Security/KekId.cs new file mode 100644 index 0000000..c794078 --- /dev/null +++ b/IdentityShroud.Core/Security/KekId.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..35f7917 --- /dev/null +++ b/IdentityShroud.Core/Security/KeyEncryptionKey.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..8e32309 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..485e6e5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..a1c3472 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..daf2b7f --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -0,0 +1,35 @@ +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 deleted file mode 100644 index ab49ebd..0000000 --- a/IdentityShroud.Core/Security/RsaHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; - -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/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs new file mode 100644 index 0000000..0887ccd --- /dev/null +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..26eb3dd --- /dev/null +++ b/IdentityShroud.Core/Services/ClockService.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..a06cbae --- /dev/null +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..add9267 --- /dev/null +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -0,0 +1,38 @@ +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/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs deleted file mode 100644 index 24cdd18..0000000 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Services; - -/// -/// -/// -public class EncryptionService : IEncryptionService -{ - private readonly byte[] encryptionKey; - - public EncryptionService(ISecretProvider secretProvider) - { - encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); - } - - 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/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs new file mode 100644 index 0000000..a2ce9dc --- /dev/null +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -0,0 +1,46 @@ +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/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs new file mode 100644 index 0000000..7daa399 --- /dev/null +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -0,0 +1,26 @@ +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 57c4cf2..949c9fe 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,8 +1,9 @@ -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; @@ -11,8 +12,14 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IEncryptionService encryptionService) : IRealmService + IKeyService keyService) : 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 @@ -26,8 +33,9 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, - Keys = [ CreateKey() ], }; + + realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); db.Add(realm); await db.SaveChangesAsync(ct); @@ -36,25 +44,26 @@ public class RealmService( 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.DeactivatedAt == null) + .Where(k => k.RevokedAt == null) .LoadAsync(); - } - private Key CreateKey() + public async Task LoadDeks(Realm realm) { - using RSA rsa = RSA.Create(2048); - - Key key = new() - { - Priority = 10, - }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); - - return key; + await db.Entry(realm).Collection(r => r.Deks) + .Query() + .LoadAsync(); } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs index 3352bc6..016f358 100644 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; -using Xunit; namespace IdentityShroud.TestUtils.Asserts; diff --git a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs index 28a0b11..ff00c06 100644 --- a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs @@ -1,5 +1,4 @@ using FluentResults; -using Xunit; namespace IdentityShroud.Core.Tests; diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 0b8cba9..4b68445 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -10,6 +10,7 @@ + @@ -21,10 +22,4 @@ - - - ..\..\..\.nuget\packages\nsubstitute\5.3.0\lib\net6.0\NSubstitute.dll - - - diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs deleted file mode 100644 index bb26ee9..0000000 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.TestUtils.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.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs new file mode 100644 index 0000000..4e97bfc --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..879f932 --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs @@ -0,0 +1,18 @@ +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.DotSettings.user b/IdentityShroud.sln.DotSettings.user index a850ec0..88c8f46 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -2,32 +2,47 @@ 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 #3" 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> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bd5aa3 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# IdentityShroud + +IdentityShroud is a .NET project for identity management and protection. + From 1a8c63808a020aea860c26750be39d3560c6cc93 Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 28 Feb 2026 08:18:30 +0100 Subject: [PATCH 10/14] Switch Owned value types to ComplexProperty which is better fit. This requires fluent configuration. Also made some conversion registration context wide. --- .../EFCore/Converters/DekIdConverter.cs | 12 +++++ .../EFCore/Converters/KekIdConverter.cs | 12 +++++ IdentityShroud.Core/{ => EFCore}/Db.cs | 47 +++++-------------- .../IdentityShroud.Core.csproj.DotSettings | 2 + IdentityShroud.Core/Model/ClientSecret.cs | 14 +++++- IdentityShroud.Core/Model/Realm.cs | 15 ++---- IdentityShroud.Core/Model/RealmDek.cs | 24 ++++++++++ IdentityShroud.Core/Model/RealmKey.cs | 16 +++++-- IdentityShroud.Core/Security/EncryptedDek.cs | 1 - .../Security/EncryptedValue.cs | 1 - IdentityShroud.sln.DotSettings.user | 3 ++ 11 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs rename IdentityShroud.Core/{ => EFCore}/Db.cs (59%) create mode 100644 IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings create mode 100644 IdentityShroud.Core/Model/RealmDek.cs diff --git a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs new file mode 100644 index 0000000..0a957ae --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core; + +public class DekIdConverter : ValueConverter +{ + public DekIdConverter() + : base(id => id.Id, guid => new DekId(guid)) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs new file mode 100644 index 0000000..87c0de9 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core; + +public class KekIdConverter : ValueConverter +{ + public KekIdConverter() + : base(id => id.Id, guid => new KekId(guid)) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/EFCore/Db.cs similarity index 59% rename from IdentityShroud.Core/Db.cs rename to IdentityShroud.Core/EFCore/Db.cs index a37136c..90ebea7 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/EFCore/Db.cs @@ -1,7 +1,7 @@ +using System.Linq.Expressions; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -23,39 +23,6 @@ public class Db( 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(""); @@ -71,6 +38,18 @@ public class Db( { optionsBuilder.UseLoggerFactory(loggerFactory); } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(Db).Assembly); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder b) + { + base.ConfigureConventions(b); + b.Properties().HaveConversion(); + b.Properties().HaveConversion(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings new file mode 100644 index 0000000..f42aea1 --- /dev/null +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index 52d25cc..d17e24d 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; @@ -13,5 +15,15 @@ public class ClientSecret public Guid ClientId { get; set; } public DateTime CreatedAt { get; set; } public DateTime? RevokedAt { get; set; } - public required EncryptedValue Secret { get; set; } + public EncryptedValue? Secret { get; set; } +} + +public class ClientSecretConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("client_secret"); + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.Secret); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index bbe9631..8f6737a 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,13 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; [Table("realm")] public class Realm { - 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 @@ -27,14 +28,4 @@ public class Realm /// 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; } -} +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmDek.cs b/IdentityShroud.Core/Model/RealmDek.cs new file mode 100644 index 0000000..ed78b14 --- /dev/null +++ b/IdentityShroud.Core/Model/RealmDek.cs @@ -0,0 +1,24 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace IdentityShroud.Core.Model; + +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 class RealmDekConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("realm_dek"); + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.KeyData, e => e.IsRequired()); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs index 3fcf2d1..4613be8 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -2,17 +2,14 @@ using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; 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; } @@ -22,6 +19,15 @@ public record RealmKey /// are more comfortable replacing keys by using priority then directly deactivating the old key. /// public int Priority { get; set; } = 10; - +} +public class RealmKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("realm_key"); + b.HasKey(e => e.Id); + + b.ComplexProperty(e => e.Key, e => e.IsRequired()); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs index 377a2f6..002c1c9 100644 --- a/IdentityShroud.Core/Security/EncryptedDek.cs +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -2,5 +2,4 @@ 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 index 173c295..37619fa 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Security; -[Owned] public record EncryptedValue(DekId DekId, byte[] Value); diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 88c8f46..540f7bf 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -26,6 +26,9 @@ /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"> + <Solution /> +</SessionState> <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From 8782ef39c680d19cf166ad31fd218b113ca7e283 Mon Sep 17 00:00:00 2001 From: eelke Date: Mon, 16 Mar 2026 19:15:04 +0100 Subject: [PATCH 11/14] Still working on getting client credential flow complete, most of the request works but still working on generating the JWT. --- .../Apis/ClientApiTests.cs | 88 +++++++++---- .../Apis/OpenIdApiTests.cs | 123 ++++++++++++++++++ .../Apis/RealmApisTests.cs | 66 ++++++---- .../HeaderHelpersTests.cs | 20 +++ .../Mappers/KeyServiceTests.cs | 46 ------- IdentityShroud.Api/Apis/ClientApi.cs | 50 +++++-- .../Apis/Dto/ClientRepresentation.cs | 3 + IdentityShroud.Api/Apis/Dto/ErrorDto.cs | 3 + .../Apis/Dto/RealmRepresentation.cs | 6 + .../Apis/Dto/TokenRequestBody.cs | 21 +++ .../Apis/EndpointRouteBuilderExtensions.cs | 7 +- .../{Validation => Filters}/ValidateFilter.cs | 0 .../Apis/Helpers/HeaderHelpers.cs | 50 +++++++ .../ISResults/ISUnauthorizedHttpResult.cs | 38 ++++++ IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 20 ++- IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 76 ++++++++++- IdentityShroud.Api/Apis/RealmApi.cs | 49 +++++-- .../ClientCreateRequestValidator.cs | 10 +- .../AppJsonSerializerContext.cs | 7 +- IdentityShroud.Api/GlobalExceptionHandler.cs | 24 ++++ IdentityShroud.Api/Program.cs | 8 +- .../Fixtures/DbFixture.cs | 3 +- .../IdentityShroud.Core.Tests.csproj | 1 - .../JwtSignatureGeneratorTests.cs | 3 +- .../Services/ClientServiceTests.cs | 62 +++++++-- .../Services/DataEncryptionServiceTests.cs | 24 ++-- .../Services/EncryptionTests.cs | 1 - .../Services/RealmServiceTests.cs | 39 +++--- IdentityShroud.Core.Tests/UnitTest1.cs | 11 +- .../Contracts/IDataEncryptionService.cs | 17 ++- IdentityShroud.Core/Contracts/IKeyService.cs | 8 +- .../Contracts/IRealmService.cs | 3 +- .../DTO/Client/ClientCreateRequest.cs | 16 +-- IdentityShroud.Core/DTO/JsonWebKey.cs | 3 +- IdentityShroud.Core/DTO/OpenId/GrantTypes.cs | 9 ++ .../DTO/OpenId/TokenResponse.cs | 19 +++ .../DTO/Realm/RealmCreateRequest.cs | 2 +- .../EFCore/Converters/DekIdConverter.cs | 10 +- .../Converters/DictionaryToJsonConverter.cs | 14 ++ .../Converters/JwtSigAlgNameConverter.cs | 5 + .../EFCore/Converters/KekIdConverter.cs | 2 +- .../EFCore/Converters/KeyTypeConverter.cs | 6 + .../Converters/RealmSigningKeyIdConverter.cs | 13 ++ IdentityShroud.Core/EFCore/Db.cs | 10 +- .../IdentityShroud.Core.csproj | 2 +- IdentityShroud.Core/Model/Client.cs | 10 +- IdentityShroud.Core/Model/ClientSecret.cs | 4 +- IdentityShroud.Core/Model/Realm.cs | 14 +- IdentityShroud.Core/Model/RealmDek.cs | 9 +- .../Model/{RealmKey.cs => RealmSigningKey.cs} | 19 +-- .../Model/RealmSigningKeyId.cs | 24 ++++ IdentityShroud.Core/Security/DekId.cs | 4 +- IdentityShroud.Core/Security/EncryptedDek.cs | 2 - .../Security/EncryptedValue.cs | 2 - IdentityShroud.Core/Security/Encryption.cs | 2 +- .../Security/JsonWebAlgorithm.cs | 6 - .../Security/Jwt/IJwtSignatureProvider.cs | 26 ++++ .../Security/Jwt/JwtSigAlgName.cs | 23 ++++ .../Security/Jwt/JwtSignatureGenerator.cs | 100 ++++++++++++++ .../Security/Jwt/RsaJwtSignatureProvider.cs | 88 +++++++++++++ .../Security/JwtSignatureGenerator.cs | 38 ------ .../Security/Keys/Aes/AesKeyPolicy.cs | 10 ++ .../Security/Keys/Aes/AesProvider.cs | 19 +++ .../Security/Keys/IKeyProvider.cs | 23 +++- .../Security/Keys/IKeyProviderFactory.cs | 2 +- .../Security/Keys/KeyProviderFactory.cs | 7 +- IdentityShroud.Core/Security/Keys/KeyType.cs | 21 +++ .../Security/Keys/Rsa/RsaKeyPolicy.cs | 10 ++ .../Security/Keys/Rsa/RsaProvider.cs | 29 ++--- IdentityShroud.Core/Services/ClientService.cs | 27 +++- .../Services/DataEncryptionService.cs | 38 ++---- IdentityShroud.Core/Services/KeyService.cs | 38 +----- .../Services/OpenId/TokenService.cs | 30 +++++ IdentityShroud.Core/Services/RealmContext.cs | 4 +- IdentityShroud.Core/Services/RealmService.cs | 53 ++++++-- .../DesignTimeDbFactory.cs | 2 +- .../Asserts/JsonObjectAssert.cs | 2 + .../Substitutes/NullDataEncryptionService.cs | 5 +- IdentityShroud.sln | 6 +- IdentityShroud.sln.DotSettings.user | 50 ++++++- 80 files changed, 1331 insertions(+), 414 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs create mode 100644 IdentityShroud.Api.Tests/HeaderHelpersTests.cs delete mode 100644 IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ErrorDto.cs create mode 100644 IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs rename IdentityShroud.Api/Apis/{Validation => Filters}/ValidateFilter.cs (100%) create mode 100644 IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs create mode 100644 IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs create mode 100644 IdentityShroud.Api/GlobalExceptionHandler.cs create mode 100644 IdentityShroud.Core/DTO/OpenId/GrantTypes.cs create mode 100644 IdentityShroud.Core/DTO/OpenId/TokenResponse.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs rename IdentityShroud.Core/Model/{RealmKey.cs => RealmSigningKey.cs} (66%) create mode 100644 IdentityShroud.Core/Model/RealmSigningKeyId.cs delete mode 100644 IdentityShroud.Core/Security/JsonWebAlgorithm.cs create mode 100644 IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs create mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs delete mode 100644 IdentityShroud.Core/Security/JwtSignatureGenerator.cs create mode 100644 IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs create mode 100644 IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs create mode 100644 IdentityShroud.Core/Security/Keys/KeyType.cs create mode 100644 IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs create mode 100644 IdentityShroud.Core/Services/OpenId/TokenService.cs diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs index db984f1..7133bd7 100644 --- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -1,16 +1,26 @@ using System.Net; using System.Net.Http.Json; -using IdentityShroud.Core; +using System.Text; +using System.Text.Json; +using FluentResults; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Tests; using IdentityShroud.Core.Tests.Fixtures; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Shouldly; namespace IdentityShroud.Api.Tests.Apis; public class ClientApiTests : IClassFixture { + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = AppJsonSerializerContext.Default, + }; private readonly ApplicationFactory _factory; public ClientApiTests(ApplicationFactory factory) @@ -32,7 +42,7 @@ public class ClientApiTests : IClassFixture public async Task Create_Validation(string? clientId, bool succeeds, string fieldName) { // setup - Realm realm = await CreateRealmAsync("test-realm", "Test Realm"); + var realm = await CreateRealmAsync("test-realm", "Test Realm"); var client = _factory.CreateClient(); @@ -64,32 +74,56 @@ public class ClientApiTests : IClassFixture [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 + var body = await DoCreateRequest(""" + { + "clientId": "new-client", + "name": "New Client" + } + """); // 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_Success_CreatesSecret() + { + // act + var body = await DoCreateRequest(""" + { + "clientId": "new-client", + "name": "New Client", + "confidential": true, + "generateSecret": true + } + """); + + // verify + body.ShouldNotBeNull(); + body.Secret.ShouldNotBeNullOrWhiteSpace(); + } + + private async Task DoCreateRequest( + string request) + { + var realm = await CreateRealmAsync("create-realm", "Create Realm"); + + var client = _factory.CreateClient(); + var response = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + //JsonContent.Create(request), + new StringContent(request, Encoding.UTF8, "application/json"), + TestContext.Current.CancellationToken); + + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(HttpStatusCode.Created == response.StatusCode, contents); + + return JsonSerializer.Deserialize(contents, _jsonOptions); + } + [Fact] public async Task Create_UnknownRealm_ReturnsNotFound() { @@ -107,7 +141,7 @@ public class ClientApiTests : IClassFixture public async Task Get_Success() { // setup - Realm realm = await CreateRealmAsync("get-realm", "Get Realm"); + var realm = await CreateRealmAsync("get-realm", "Get Realm"); Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client"); var httpClient = _factory.CreateClient(); @@ -138,7 +172,7 @@ public class ClientApiTests : IClassFixture public async Task Get_UnknownClient_ReturnsNotFound() { // setup - Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); + var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); var httpClient = _factory.CreateClient(); @@ -154,11 +188,11 @@ public class ClientApiTests : IClassFixture 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; + var realmService = scope.ServiceProvider.GetRequiredService(); + Result result = await realmService.Create( + new(null, slug, name), + TestContext.Current.CancellationToken); + return ResultAssert.Success(result); } private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) diff --git a/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs new file mode 100644 index 0000000..93d241a --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using IdentityShroud.Api.Apis; +using IdentityShroud.Core.EFCore; +using IdentityShroud.Core.Tests.Fixtures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace IdentityShroud.Api.Tests.Apis; + +public class OpenIdApiTests : IClassFixture +{ + private readonly ApplicationFactory _factory; + + public OpenIdApiTests(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(true)] + [InlineData(false)] + public async Task ClientCredentialsFlow(bool useAuthenticationHeader) + { + var client = _factory.CreateClient(); + + var createRealmResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new + { + Slug = "foo", + Name = "Test'", + }), + TestContext.Current.CancellationToken); + + createRealmResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var realm = await createRealmResponse.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + realm.ShouldNotBeNull(); + realm.Id.ShouldNotBe(Guid.Empty); + + var createClientResponse = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + JsonContent.Create(new + { + ClientId = "myclient", + Name = "New Client", + Confidential = true, + AllowClientCredentialsFlow = true, + GenerateSecret = true, + }), + TestContext.Current.CancellationToken); + + createClientResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + // Act + const string clientId = "myclient"; + + var data = new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", "secret"), + new KeyValuePair("response_type", "token"), + new KeyValuePair("grant_type", "client_credentials"), + }; + + if (useAuthenticationHeader) + { + // client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("basic", + // Convert.ToBase64String($"{clientId}:{clientSecret}")) + } + + var content = new FormUrlEncodedContent(data); + var response = await client.PostAsync( + "/auth/realms/foo/openid-connect/token", + content, + TestContext.Current.CancellationToken); + + // Verify + // var responseJson = await response.Content.ReadAsStringAsync( + // TestContext.Current.CancellationToken); + // Console.WriteLine($"Response: {responseJson}"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Cache-Control: no-store + response.Headers.CacheControl.ShouldNotBeNull() + .NoStore.ShouldBe(true); + // Pragma: no-cache + response.Headers.Pragma.ShouldNotBeNull() + .ShouldContain(new NameValueHeaderValue("no-cache")); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.ShouldNotBeNull(); + Assert.Multiple( + () => payload.AccessToken.ShouldNotBeNull(), + () => payload.TokenType.ShouldBe("bearer"), + () => payload.ExpiresIn.ShouldBe(3600)); + + // - refresh_token OPTIONAL + // - scope OPTIONAL when identical to request otherwise REQUIRED + } + + internal class TokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index ecc46c0..7d3d49e 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,14 +1,13 @@ +using System.Buffers.Text; 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.EFCore; using IdentityShroud.Core.Model; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -124,28 +123,16 @@ public class RealmApisTests : IClassFixture [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 createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new + { + Slug = "foo", + Name = "Test'", + }), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + // act var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", TestContext.Current.CancellationToken); @@ -153,9 +140,16 @@ public class RealmApisTests : IClassFixture 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"); + string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString(); + Assert.NotNull(kid); + Assert.True(kid.Length >= 16); + + //if (JsonObjectAssert.NavigateToPath(payload, "keys[0].kty")?.AsValue().ToString() == "RSA") + + JsonObjectAssert.Equal("RSA", payload, "keys[0].kty"); + string? n = payload["keys"]?[0]?["n"]?.AsValue().ToString(); + string? e = payload["keys"]?[0]?["e"]?.AsValue().ToString(); + AssertRsaParams(n, e); } private async Task ScopedContextAsync( @@ -166,4 +160,22 @@ public class RealmApisTests : IClassFixture var db = scope.ServiceProvider.GetRequiredService(); await action(db); } + + private static void AssertRsaParams(string? n, string? e) + { + Assert.NotNull(n); + Assert.NotNull(e); + + var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = Base64Url.DecodeFromChars(n), + Exponent = Base64Url.DecodeFromChars(e) + }); + + // If n and e are complete nonsense, this will throw + var encrypted = rsa.Encrypt(new byte[] { 1, 2, 3 }, RSAEncryptionPadding.OaepSHA256); + Assert.NotNull(encrypted); + Assert.NotEmpty(encrypted); + } } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/HeaderHelpersTests.cs b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs new file mode 100644 index 0000000..c08303a --- /dev/null +++ b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Api.Helpers; + +namespace IdentityShroud.Api.Tests; + +public class HeaderHelpersTests +{ + [Theory] + [InlineData("Basic dXNlcjpzZWNyZXQ=", true, "user", "secret")] + [InlineData("baSIC dXNlcjpzZWNyZXQ=", true, "user", "secret")] + [InlineData("Basic dXNlcnNlY3JldA==", false, null, null)] // no colon to seperate user and password + [InlineData("Bearer dXNlcjpzZWNyZXQ=", false, null, null)] + public void TryDecodeBasicAuth(string input, bool expectedResult, string? expectedUser, string? expectedPassword) + { + var result = HeaderHelpers.TryDecodeBasicAuth(input, out string? user, out string? password); + + Assert.Equal(expectedResult, result); + Assert.Equal(expectedUser, user); + Assert.Equal(expectedPassword, password); + } +} \ No newline at end of file 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 index e595e34..05b4aa7 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -5,11 +5,7 @@ using IdentityShroud.Core.Model; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -namespace IdentityShroud.Api; - - - -public record ClientCreateReponse(int Id, string ClientId); +namespace IdentityShroud.Api.Apis; /// /// The part of the api below realms/{slug}/clients @@ -20,12 +16,12 @@ public static class ClientApi public static void MapEndpoints(this IEndpointRouteBuilder erp) { - RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); - + RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); + clientsGroup.MapPost("", ClientCreate) - .Validate() - .WithName("ClientCreate") - .Produces(StatusCodes.Status201Created); + .Produces(StatusCodes.Status201Created) + .Validate() + .WithName("ClientCreate"); var clientIdGroup = clientsGroup.MapGroup("{clientId}") .AddEndpointFilter(); @@ -43,11 +39,12 @@ public static class ClientApi return TypedResults.Ok(new ClientMapper().ToDto(client)); } - private static async Task, InternalServerError>> + private static async Task, InternalServerError>> ClientCreate( Guid realmId, ClientCreateRequest request, [FromServices] IClientService service, + [FromServices] IDataEncryptionService cryptor, HttpContext context, CancellationToken cancellationToken) { @@ -60,9 +57,12 @@ public static class ClientApi } Client client = result.Value; - + ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client); + var secret = SelectBestSecret(client.Secrets); + if (secret is {} s) + clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret); return TypedResults.CreatedAtRoute( - new ClientCreateReponse(client.Id, client.ClientId), + clientRepresentation, ClientGetRouteName, new RouteValueDictionary() { @@ -70,4 +70,28 @@ public static class ClientApi ["clientId"] = client.Id, }); } + + private static ClientSecret? SelectBestSecret(List clientSecrets) + { + ClientSecret? result = null; + + foreach (var cs in clientSecrets) + { + if (cs.RevokedAt is null && (!cs.Expires.HasValue || cs.Expires.Value > DateTime.UtcNow)) + { + if (result is null) + { + result = cs; + } + else + { + int d = (cs.Expires ?? DateTime.MaxValue).CompareTo(result.Expires ?? DateTime.MaxValue); + if (d > 0 || (d == 0 && cs.CreatedAt > result.CreatedAt)) + result = cs; + } + } + } + + return result; + } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs index 80b5f13..d5e2853 100644 --- a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -10,7 +10,10 @@ public record ClientRepresentation public string? SignatureAlgorithm { get; set; } + public bool Confidential { get; set; } public bool AllowClientCredentialsFlow { get; set; } = false; public required DateTime CreatedAt { get; set; } + + public string? Secret { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ErrorDto.cs b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs new file mode 100644 index 0000000..655d4c4 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs @@ -0,0 +1,3 @@ +namespace IdentityShroud.Api.Apis; + +public record ErrorDto(string Error); diff --git a/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs new file mode 100644 index 0000000..29f6ca5 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Api.Apis; + +public record RealmRepresentation( + Guid Id, + string Slug, + string Name); \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs new file mode 100644 index 0000000..88672d6 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.DTO.OpenId; + +public class TokenRequestBody +{ + [JsonPropertyName("grant_type")] + public GrantTypes GrantType { get; init; } + + /// + /// In most cases required but not when basic auth header is used + /// + [JsonPropertyName("client_id")] + public string? ClientId { get; init; } = ""; + + [JsonPropertyName("client_secret")] + public string? ClientSecret { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs index 3c47b48..5e2590f 100644 --- a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -2,9 +2,10 @@ namespace IdentityShroud.Api; public static class EndpointRouteBuilderExtensions { - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); - + public static IEndpointConventionBuilder Validate(this IEndpointConventionBuilder builder) + where TDto : class + => builder.AddEndpointFilter>(); + public static void MapApis(this IEndpointRouteBuilder erp) { RealmApi.MapRealmEndpoints(erp); diff --git a/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Filters/ValidateFilter.cs similarity index 100% rename from IdentityShroud.Api/Apis/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Filters/ValidateFilter.cs diff --git a/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs new file mode 100644 index 0000000..35f7f30 --- /dev/null +++ b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.Extensions.Primitives; + +namespace IdentityShroud.Api.Helpers; + +public static class HeaderHelpers +{ + public static bool TryGetBasicAuth( + HttpContext context, + [NotNullWhen(true)] out string? user, + [NotNullWhen(true)] out string? password) + { + var headers = context?.Request.Headers; + if (headers is not null) + { + if (headers.TryGetValue("Authorization", out StringValues s)) + return TryDecodeBasicAuth(s.ToString(), out user, out password); + } + + user = password = null; + return false; + } + + public static bool TryDecodeBasicAuth( + string authorizationHeader, + [NotNullWhen(true)] out string? user, + [NotNullWhen(true)] out string? password) + { + if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan val = authorizationHeader.AsSpan(6); // basic + space + Span b = new byte[(val.Length * 6 / 8) + 1]; + if (Convert.TryFromBase64Chars(val, b, out int written)) + { + int sepIdx = b.IndexOf((byte)':'); + if (sepIdx > 0 && sepIdx < written - 1) + { + user = Encoding.UTF8.GetString(b.Slice(0, sepIdx)); + password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1))); + return true; + } + } + } + + user = password = null; + return false; + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs new file mode 100644 index 0000000..f7722cb --- /dev/null +++ b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs @@ -0,0 +1,38 @@ +namespace IdentityShroud.Api.Apis.ISResults; + +public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult +{ + private readonly List _wwwAuthenticateValues; + /// + /// Initializes a new instance of the class. + /// + internal ISUnauthorizedHttpResult(List wwwAuthenticateValues) + { + _wwwAuthenticateValues = wwwAuthenticateValues; + } + + /// + /// Gets the HTTP status code: + /// + public int StatusCode => StatusCodes.Status401Unauthorized; + + int? IStatusCodeHttpResult.StatusCode => StatusCode; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + // Creating the logger with a string to preserve the category after the refactoring. + // var loggerFactory = httpContext.RequestServices.GetRequiredService(); + // var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult"); + // HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode); + + + httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray()); + + httpContext.Response.StatusCode = StatusCode; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 7155208..e37798b 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -1,20 +1,28 @@ -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Api.Mappers; -public class KeyMapper(IKeyService keyService) +public class KeyMapper(IKeyProviderFactory keyProviderFactory) { - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { JsonWebKeySet wks = new(); foreach (var k in keys) { - var wk = keyService.CreateJsonWebKey(k); - if (wk is {}) + IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType); + if (provider.IsPublic) { - wks.Keys.Add(wk); + JsonWebKey jwk = new() + { + KeyId = k.Id.ToString(), + KeyType = k.KeyType, + Use = "sig", + }; + + provider.SetJwkParameters(k.PublicKeyParameters!, jwk); + wks.Keys.Add(jwk); } } return wks; diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs index 6565413..053be93 100644 --- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -1,7 +1,11 @@ +using IdentityShroud.Api.Apis; +using IdentityShroud.Api.Apis.ISResults; +using IdentityShroud.Api.Helpers; using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services.OpenId; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -11,8 +15,6 @@ 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"); @@ -56,17 +58,79 @@ public static class OpenIdEndpoints { Realm realm = context.GetValidatedRealm(); await realmService.LoadActiveKeys(realm); - return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); + return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys)); } - private static Task OpenIdConnectToken(HttpContext context) + private static async Task, + BadRequest, + ISUnauthorizedHttpResult + >> OpenIdConnectToken( + string realmSlug, + [FromServices] IClientService clientService, + HttpContext context, + CancellationToken ct) { - throw new NotImplementedException(); + IFormCollection form = await context.Request.ReadFormAsync(); + + string grantType = form["grant_type"].ToString(); + string clientId = form["client_id"].ToString(); + string scope = form["scope"].ToString(); + + if (grantType == "client_credentials") + { + string? clientSecret = null; + bool withAuthHeader = false; + if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password)) + { + withAuthHeader = true; + clientId = user; + clientSecret = password; + } + clientSecret ??= form["client_secret"].ToString(); + + if (string.IsNullOrEmpty(clientId) || + string.IsNullOrEmpty(clientSecret)) + { + return CreateBadRequest("invalid_request"); + } + + Realm realm = context.GetValidatedRealm(); + Client? client = await clientService.GetByClientId(realm.Id, clientId, ct); + if (client is null) + { + if (withAuthHeader) + { + return new ISUnauthorizedHttpResult([$"Basic realm=\"{realm.Slug}\""]); + } + return CreateBadRequest("invalid_client"); + } + + if (!client.AllowClientCredentialsFlow) + return CreateBadRequest("unauthorized_client"); + + } + else + return CreateBadRequest("unsupported_grant_type"); + + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Pragma = "no-cache"; + + return TypedResults.Ok(new TokenResponse() + { + AccessToken = "token", + TokenType = "bearer", + ExpiresIn = 3600, + }); } + private static BadRequest CreateBadRequest(string error) => + TypedResults.BadRequest(new ErrorDto(error)); + + + 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..ed78cef 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,7 +1,7 @@ +using IdentityShroud.Api.Apis; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -19,31 +19,56 @@ public static class HttpContextExtensions public static class RealmApi { + public const string GetRealmRoute = "Get Realm"; + public const string CreateRealmRoute = "Create Realm"; + public static void MapRealmEndpoints(IEndpointRouteBuilder erp) { var realmsGroup = erp.MapGroup("/api/v1/realms"); + realmsGroup.MapPost("", RealmCreate) - .Validate() - .WithName("Create Realm") - .Produces(StatusCodes.Status201Created); + .Produces(StatusCodes.Status201Created) + .Validate() + .WithName(CreateRealmRoute); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") .AddEndpointFilter(); - ClientApi.MapEndpoints(realmIdGroup); - - + realmIdGroup.MapGet("", RealmGet) + .WithName(GetRealmRoute); + ClientApi.MapEndpoints(realmIdGroup); } - - private static async Task, InternalServerError>> + + private static Ok RealmGet( + Guid realmId, + HttpContext context) + { + Realm realm = context.GetValidatedRealm(); + return TypedResults.Ok(MapToRepresentation(realm)); + } + + private static async Task, InternalServerError>> RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service) { var response = await service.Create(request); if (response.IsSuccess) - return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value); - + { + var realm = response.Value; + return TypedResults.CreatedAtRoute( + MapToRepresentation(realm), + GetRealmRoute, + new { realmId = realm.Id }); + } + // TODO make helper to convert failure response to a proper HTTP result. return TypedResults.InternalServerError(); } -} \ No newline at end of file + + private static RealmRepresentation MapToRepresentation(Realm realm) + => new(realm.Id, realm.Slug, realm.Name); +} + + + diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs index 7666b36..aef7c47 100644 --- a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -6,9 +6,9 @@ namespace IdentityShroud.Api; public class ClientCreateRequestValidator : AbstractValidator { // most of standard ascii minus the control characters and space - private const string ClientIdPattern = "^[\x21-\x7E]+"; + private const string ClientIdPattern = "^[a-zA-Z0-9_-]+"; - private string[] AllowedAlgorithms = [ "RS256", "ES256" ]; + private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ]; public ClientCreateRequestValidator() { @@ -16,7 +16,9 @@ public class ClientCreateRequestValidator : AbstractValidator 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"); + .Must(v => v is null || _allowedAlgorithms.Contains(v)) + .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null"); + RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true); + RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true); } } \ No newline at end of file diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index e7d90da..5733ac3 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -1,9 +1,14 @@ using System.Text.Json.Serialization; +using IdentityShroud.Api.Apis; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; +namespace IdentityShroud.Api; + +[JsonSerializable(typeof(ClientRepresentation))] +[JsonSerializable(typeof(ErrorDto))] [JsonSerializable(typeof(OpenIdConfiguration))] [JsonSerializable(typeof(RealmCreateRequest))] -internal partial class AppJsonSerializerContext : JsonSerializerContext +public partial class AppJsonSerializerContext : JsonSerializerContext { } \ No newline at end of file diff --git a/IdentityShroud.Api/GlobalExceptionHandler.cs b/IdentityShroud.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000..7729674 --- /dev/null +++ b/IdentityShroud.Api/GlobalExceptionHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace IdentityShroud.Api; + +public class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + => _logger = logger; + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Exception type: {Type}, Message: {Message}", + exception.GetType().Name, exception.Message); + + // Return false to let other handlers or the default handle it + // Return true to mark it as handled + return false; + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 29f6736..b2a31e8 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,8 +1,8 @@ using FluentValidation; using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Core; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; @@ -30,7 +30,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) //services.AddControllers(); services.ConfigureHttpJsonOptions(options => { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); }); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -52,6 +52,9 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddValidatorsFromAssemblyContaining(); services.AddHttpContextAccessor(); + services.AddExceptionHandler(); + services.AddProblemDetails(); + builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() //.Enrich.With() @@ -60,6 +63,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) void ConfigureApplication(WebApplication app) { + app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 844d4ca..1df6559 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 IdentityShroud.Core.EFCore; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 8af08c1..1d98db0 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -9,7 +9,6 @@ - diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index bf4d0a6..4563ea4 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -48,8 +48,7 @@ public class JwtSignatureGeneratorTests } ] } - """; - + """; JsonWebKeySet keySet = JsonSerializer.Deserialize(keycloakKeySet)!; using RSA publicKey = LoadFromJwk(keySet.Keys[0]); diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index d0269e6..a0690c9 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -1,5 +1,9 @@ +using IdentityShroud.Api; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -7,6 +11,29 @@ using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; +public static class RealmDekBuilder +{ + public static RealmDek DefaultActive() => + new() + { + Id = DekId.NewId(), + Active = true, + Algorithm = KeyType.AES, + KeyData = new EncryptedDek(KekId.NewId(), + [ + 0 + ]) + }; +} + +public static class ClientCreateRequestBuilder +{ + public static ClientCreateRequest Default() => new( + "test-client", + "Test Client", + "A test client"); +} + public class ClientServiceTests : IClassFixture { private readonly DbFixture _dbFixture; @@ -34,15 +61,28 @@ public class ClientServiceTests : IClassFixture { if (!db.Realms.Any(r => r.Id == _realmId)) { - db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" }); + db.Realms.Add(new() + { + Id = _realmId, + Slug = "test-realm", + Name = "Test Realm", + DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ], + }); + db.SaveChanges(); } } + private ClientService CreateSut(Db db) => new(db, + _dataEncryptionService, + new ClientCreateRequestValidator(), + _clock); + + [Theory] [InlineData(false)] [InlineData(true)] - public async Task Create(bool allowClientCredentialsFlow) + public async Task Create(bool withSecret) { // Setup DateTime now = DateTime.UtcNow; @@ -52,15 +92,13 @@ public class ClientServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { // Act - ClientService sut = new(db, _dataEncryptionService, _clock); + ClientService sut = CreateSut(db); var response = await sut.Create( _realmId, - new ClientCreateRequest + ClientCreateRequestBuilder.Default() with { - ClientId = "test-client", - Name = "Test Client", - Description = "A test client", - AllowClientCredentialsFlow = allowClientCredentialsFlow, + Confidential = withSecret, + GenerateSecret = withSecret, }, TestContext.Current.CancellationToken); @@ -70,7 +108,7 @@ public class ClientServiceTests : IClassFixture 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(withSecret, val.Confidential); Assert.Equal(now, val.CreatedAt); } @@ -80,7 +118,7 @@ public class ClientServiceTests : IClassFixture .Include(e => e.Secrets) .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - if (allowClientCredentialsFlow) + if (withSecret) Assert.Single(dbRecord.Secrets); else Assert.Empty(dbRecord.Secrets); @@ -108,7 +146,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); + ClientService sut = CreateSut(actContext); Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify @@ -143,7 +181,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); + ClientService sut = CreateSut(actContext); Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs index 4f88e48..a61e7e0 100644 --- a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs @@ -2,6 +2,7 @@ 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; @@ -9,23 +10,20 @@ namespace IdentityShroud.Core.Tests.Services; public class DataEncryptionServiceTests { - private readonly IRealmContext _realmContext = Substitute.For(); +// 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); + => new(_dekCryptor); [Fact] public void Encrypt_UsesActiveKey() { - _realmContext.GetDeks(Arg.Any()).Returns([ - CreateRealmDek(_secondDekId, false), - CreateRealmDek(_activeDekId, true), - ]); - - var cipher = CreateSut().Encrypt("Hello"u8); + var dek = CreateRealmDek(_activeDekId, true); + + var cipher = CreateSut().Encrypt(dek, "Hello"u8); Assert.Equal(_activeDekId, cipher.DekId); } @@ -34,20 +32,18 @@ public class DataEncryptionServiceTests public void Decrypt_UsesCorrectKey() { var first = CreateRealmDek(_activeDekId, true); - _realmContext.GetDeks(Arg.Any()).Returns([ first ]); var sut = CreateSut(); - var cipher = sut.Encrypt("Hello"u8); + var cipher = sut.Encrypt(first, "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 ]); + RealmDek[] list = [ first, second ]; - - var decoded = sut.Decrypt(cipher); + var decoded = sut.Decrypt(list, cipher); Assert.Equal("Hello"u8, decoded); } @@ -57,7 +53,7 @@ public class DataEncryptionServiceTests { Id = id, Active = active, - Algorithm = "AES", + Algorithm = KeyType.AES, KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), RealmId = default, }; diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs index 2dfbb52..f040b84 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -1,5 +1,4 @@ using IdentityShroud.Core.Security; -using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Tests.Services; diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index fda233e..70d6d11 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,10 +1,13 @@ +using FluentResults; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; +using Shouldly; namespace IdentityShroud.Core.Tests.Services; @@ -12,6 +15,7 @@ public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; private readonly IKeyService _keyService = Substitute.For(); + private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService(); public RealmServiceTests(DbFixture dbFixture) { @@ -25,6 +29,9 @@ public class RealmServiceTests : IClassFixture { db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); } + + private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService()); + [Theory] [InlineData(null)] @@ -36,20 +43,14 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - RealmCreateResponse? val; + Realm? 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 - }); + .Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21]))); // Act - RealmService sut = new(db, _keyService); - var response = await sut.Create( + RealmService sut = CreateSut(db); + Result response = await sut.Create( new(realmId, "slug", "New realm"), TestContext.Current.CancellationToken); @@ -60,8 +61,12 @@ public class RealmServiceTests : IClassFixture else Assert.NotEqual(Guid.Empty, val.Id); - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); + Assert.Multiple( + () => val.Slug.ShouldBe("slug"), + () => val.Name.ShouldBe("New realm"), + () => val.DataEncryptionKeys.ShouldContain(d => d.Active), + () => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue) + ); _keyService.Received().CreateKey(Arg.Any()); } @@ -69,9 +74,9 @@ public class RealmServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { var dbRecord = await db.Realms - .Include(e => e.Keys) + .Include(e => e.TokenSigningKeys) .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - Assert.Equal("TST", dbRecord.Keys[0].KeyType); + Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType); } } @@ -98,7 +103,7 @@ public class RealmServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - RealmService sut = new(actContext, _keyService); + RealmService sut = CreateSut(actContext); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); // Verify @@ -131,7 +136,7 @@ public class RealmServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - RealmService sut = new(actContext, _keyService); + RealmService sut = CreateSut(actContext); Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 7506fd0..7cfc961 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -1,8 +1,7 @@ -using System.Security.Cryptography; -using System.Text; +using System.Buffers.Text; +using System.Security.Cryptography; using System.Text.Json; using IdentityShroud.Core.DTO; -using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; @@ -74,10 +73,10 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, + Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!, Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, - Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) + Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!, + Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))), }; } } diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs index 2810aaa..1a89862 100644 --- a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -1,9 +1,22 @@ +using System.Text; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; namespace IdentityShroud.Core.Contracts; public interface IDataEncryptionService { - EncryptedValue Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedValue input); + EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain); + byte[] Decrypt(IReadOnlyList deks, EncryptedValue input); +} + +public static class DataEncryptionServiceExtensions +{ + public static string DecryptUtf8ToString( + this IDataEncryptionService des, + IReadOnlyList deks, + EncryptedValue input) + { + return Encoding.UTF8.GetString(des.Decrypt(deks, input)); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs index 4f6b5f7..08a5bf6 100644 --- a/IdentityShroud.Core/Contracts/IKeyService.cs +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -1,12 +1,10 @@ -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Contracts; +public record CreateKeyResponse(KeyType KeyType, KeyData Key); + public interface IKeyService { - RealmKey CreateKey(KeyPolicy policy); - - JsonWebKey? CreateJsonWebKey(RealmKey realmKey); + CreateKeyResponse CreateKey(KeyPolicy policy); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index 4598b97..1724e6c 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,6 +1,5 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Contracts; @@ -9,7 +8,7 @@ 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> 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/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs index a162131..f1c3b40 100644 --- a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -1,10 +1,10 @@ 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 +public record ClientCreateRequest( + string ClientId, + string? Name = null, + string? Description = null, + string? SignatureAlgorithm = null, + bool Confidential = false, + bool AllowClientCredentialsFlow = false, + bool GenerateSecret = false); \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs index 4f16955..afc9367 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using IdentityShroud.Core.Helpers; +using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Messages; @@ -9,7 +10,7 @@ namespace IdentityShroud.Core.Messages; public class JsonWebKey { [JsonPropertyName("kty")] - public string KeyType { get; set; } = "RSA"; + public required KeyType KeyType { get; set; } // Common values sig(nature) enc(ryption) [JsonPropertyName("use")] diff --git a/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs new file mode 100644 index 0000000..e764e24 --- /dev/null +++ b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.DTO.OpenId; + +public enum GrantTypes +{ + [JsonStringEnumMemberName("client_credentials")] + ClientCredentials +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs new file mode 100644 index 0000000..23d9718 --- /dev/null +++ b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Services.OpenId; + +public class TokenResponse +{ + [JsonPropertyName("access_token")] + public required string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public required string TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs index fab91aa..143c75b 100644 --- a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs +++ b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs @@ -1,3 +1,3 @@ namespace IdentityShroud.Core.Messages.Realm; -public record RealmCreateRequest(Guid? Id, string? Slug, string Name); \ No newline at end of file +public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs index 0a957ae..df12fc2 100644 --- a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs +++ b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs @@ -1,12 +1,6 @@ using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; -public class DekIdConverter : ValueConverter -{ - public DekIdConverter() - : base(id => id.Id, guid => new DekId(guid)) - { - } -} \ No newline at end of file +public class DekIdConverter() : ValueConverter(id => id.Id, guid => new DekId(guid)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs new file mode 100644 index 0000000..1236b67 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class DictionaryToJsonConverter : ValueConverter, string> + where TKey : notnull +{ + public DictionaryToJsonConverter() : base( + v => JsonSerializer.Serialize(v), + v => JsonSerializer.Deserialize>(v) ?? new()) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs new file mode 100644 index 0000000..d570d61 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs @@ -0,0 +1,5 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class JwtSigAlgNameConverter() : ValueConverter(j => j.ToString(), s => new(s)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs index 87c0de9..23f55fe 100644 --- a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs +++ b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs @@ -1,7 +1,7 @@ using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; public class KekIdConverter : ValueConverter { diff --git a/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs new file mode 100644 index 0000000..18c8574 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs @@ -0,0 +1,6 @@ +using IdentityShroud.Core.Security.Keys; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class KeyTypeConverter() : ValueConverter(id => id.ToString(), s => new(s)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs new file mode 100644 index 0000000..f36ff9a --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs @@ -0,0 +1,13 @@ +using IdentityShroud.Core.Model; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class RealmSigningKeyIdConverter : ValueConverter +{ + public RealmSigningKeyIdConverter() + : base(id => id.Id, guid => new RealmSigningKeyId(guid)) + { + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Db.cs b/IdentityShroud.Core/EFCore/Db.cs index 90ebea7..b2bc12e 100644 --- a/IdentityShroud.Core/EFCore/Db.cs +++ b/IdentityShroud.Core/EFCore/Db.cs @@ -1,11 +1,11 @@ -using System.Linq.Expressions; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; public class DbConfiguration { @@ -20,7 +20,7 @@ public class Db( { public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } + public virtual DbSet Keys { get; set; } public virtual DbSet Deks { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -50,6 +50,10 @@ public class Db( base.ConfigureConventions(b); b.Properties().HaveConversion(); + b.Properties>().HaveConversion>(); + b.Properties().HaveConversion(); b.Properties().HaveConversion(); + b.Properties().HaveConversion(); + b.Properties().HaveConversion(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 9dd3e34..fb54802 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -10,12 +10,12 @@ - + diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 5df6c1a..b7d9c60 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -19,8 +19,16 @@ public class Client public string? Description { get; set; } [MaxLength(20)] - public string? SignatureAlgorithm { get; set; } + public JwtSigAlgName? SignatureAlgorithm { get; set; } + /// + /// Enables confidential flows + /// + public bool Confidential { get; set; } + + /// + /// Enables the client credentials flow which required Confidential to be true too. + /// public bool AllowClientCredentialsFlow { get; set; } = false; public required DateTime CreatedAt { get; set; } diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index d17e24d..189039f 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -14,8 +13,9 @@ public class ClientSecret public int Id { get; set; } public Guid ClientId { get; set; } public DateTime CreatedAt { get; set; } + public DateTime? Expires { get; set; } public DateTime? RevokedAt { get; set; } - public EncryptedValue? Secret { get; set; } + public required EncryptedValue Secret { get; set; } } public class ClientSecretConfiguration : IEntityTypeConfiguration diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 8f6737a..97f08c7 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,8 +1,5 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; @@ -20,12 +17,17 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + + /// + /// Note multiple keys can be in use at the same time because different clients may be configured to use + /// a different keytype depending on their clients requirements/capabilities. + /// + public List TokenSigningKeys { get; init; } = []; - public List Deks { get; init; } = []; + public List DataEncryptionKeys { get; init; } = []; /// /// Can be overriden per client /// - public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmDek.cs b/IdentityShroud.Core/Model/RealmDek.cs index ed78b14..92bc57b 100644 --- a/IdentityShroud.Core/Model/RealmDek.cs +++ b/IdentityShroud.Core/Model/RealmDek.cs @@ -1,16 +1,18 @@ using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; + public record RealmDek { public required DekId Id { get; init; } public required bool Active { get; set; } - public required string Algorithm { get; init; } + public required KeyType Algorithm { get; init; } public required EncryptedDek KeyData { get; init; } - public required Guid RealmId { get; init; } + public Guid RealmId { get; init; } } public class RealmDekConfiguration : IEntityTypeConfiguration @@ -21,4 +23,5 @@ public class RealmDekConfiguration : IEntityTypeConfiguration b.HasKey(e => e.Id); b.ComplexProperty(e => e.KeyData, e => e.IsRequired()); } -} \ No newline at end of file +} + diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmSigningKey.cs similarity index 66% rename from IdentityShroud.Core/Model/RealmKey.cs rename to IdentityShroud.Core/Model/RealmSigningKey.cs index 4613be8..25b37a2 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmSigningKey.cs @@ -1,33 +1,34 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; -public record RealmKey +public record RealmSigningKey { - public required Guid Id { get; init; } - public required string KeyType { get; init; } + public required RealmSigningKeyId Id { get; init; } + public required KeyType 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; + + public Dictionary? PublicKeyParameters { get; set; } } -public class RealmKeyConfiguration : IEntityTypeConfiguration +public class RealmKeyConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder b) + public void Configure(EntityTypeBuilder b) { b.ToTable("realm_key"); b.HasKey(e => e.Id); b.ComplexProperty(e => e.Key, e => e.IsRequired()); + b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb"); } -} \ No newline at end of file +} diff --git a/IdentityShroud.Core/Model/RealmSigningKeyId.cs b/IdentityShroud.Core/Model/RealmSigningKeyId.cs new file mode 100644 index 0000000..085b9ff --- /dev/null +++ b/IdentityShroud.Core/Model/RealmSigningKeyId.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Model; + +[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))] +public readonly record struct RealmSigningKeyId(Guid Id) +{ + public override string ToString() => Id.ToString("N"); + + public static RealmSigningKeyId NewId() + { + return new(Guid.NewGuid()); + } +} + +public class RealmSigningKeyIdJsonConverter : JsonConverter +{ + public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new (reader.GetGuid()); + + public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs index 276178e..d68a985 100644 --- a/IdentityShroud.Core/Security/DekId.cs +++ b/IdentityShroud.Core/Security/DekId.cs @@ -1,6 +1,8 @@ namespace IdentityShroud.Core.Security; -public record struct DekId(Guid Id) +public readonly record struct DekId(Guid Id) { public static DekId NewId() => new(Guid.NewGuid()); + + public override string ToString() => Id.ToString("N"); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs index 002c1c9..2e44afe 100644 --- a/IdentityShroud.Core/Security/EncryptedDek.cs +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace IdentityShroud.Core.Security; 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 index 37619fa..03dad86 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace IdentityShroud.Core.Security; public record EncryptedValue(DekId DekId, byte[] Value); diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs index 47344c1..5fe274e 100644 --- a/IdentityShroud.Core/Security/Encryption.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security; public static class Encryption { - private record struct AlgVersion(int Version, int NonceSize, int TagSize); + private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize); private static AlgVersion[] _versions = [ 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/Jwt/IJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs new file mode 100644 index 0000000..5100436 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace IdentityShroud.Core; + +public interface IJwtSignatureProvider : IDisposable +{ + /* + Of the signature and MAC algorithms specified in JSON Web Algorithms + [JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by + conforming JWT implementations. It is RECOMMENDED that + implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash + algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256 + hash algorithm ("ES256"). Support for other algorithms and key sizes + is OPTIONAL. + */ + + void WriteJwtHeaderFields(Utf8JsonWriter writer); + /// + /// Length of the binary signature in bytes. + /// + /// + int GetSignatureLength(); + + void CalculateSignature(ReadOnlySpan jwt, Span signatureOut); + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs new file mode 100644 index 0000000..7e59dbb --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace IdentityShroud.Core; + +[SuppressMessage("ReSharper", "InconsistentNaming")] +public readonly record struct JwtSigAlgName(string Name) : IEquatable +{ + // HMAC using SHA-??? + public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED + public static JwtSigAlgName HS384 => new("HS384"); + public static JwtSigAlgName HS512 => new("HS512"); + + // RSASSA-PKCS1-v1_5 using SHA-??? + public static JwtSigAlgName RS256 => new("RS256"); + public static JwtSigAlgName RS384 => new("RS384"); + public static JwtSigAlgName RS512 => new("RS512"); + + public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256 + public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384 + public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512 + + public override string ToString() => Name; +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs new file mode 100644 index 0000000..6143f35 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs @@ -0,0 +1,100 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentityShroud.Core; + +public static class JwtSignatureGenerator +{ + /// + /// Generates a JWT signature using RS256 algorithm + /// + /// Base64Url encoded header + /// Base64Url encoded payload + /// RSA private key (PEM format or RSA parameters) + /// Base64Url encoded signature + public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey) + { + // Combine header and payload with a period + string dataToSign = $"{headerBase64Url}.{payloadBase64Url}"; + + // Convert to bytes + byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign); + + // Sign the data using RSA-SHA256 + byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Convert signature to Base64Url encoding + string signature = WebEncoders.Base64UrlEncode(signatureBytes); + + return signature; + } + + public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey) + { + string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey); + return $"{headerBase64Url}.{payloadBase64Url}.{signature}"; + } + +} + +public static class JwtCreator +{ + + public static byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, IJwtSignatureProvider signatureProvider) + { + MemoryStream memStream = new(); + Utf8JsonWriter writer = new(memStream); + WriteJwtHeader(writer, signatureProvider); + writer.Flush(); + memStream.Seek(0, SeekOrigin.Begin); + + int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length); + int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length); + int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength()); + int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length; + + var completeJwt = new byte[totalLength]; + + // + var byteArray = new byte[memStream.Length]; + memStream.ReadExactly(byteArray, 0, (int)memStream.Length); + int written = Base64Url.EncodeToUtf8(byteArray, completeJwt); + + if (written != headerBase64Length) + throw new Exception("expected header length did not match bytes written"); + + completeJwt[headerBase64Length] = (byte)'.'; + + written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); + + if (written != payloadBase64Length) + throw new Exception("expected payload length did not match bytes written"); + + completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.'; + + + Span signature = stackalloc byte[signatureProvider.GetSignatureLength()]; + signatureProvider.CalculateSignature( + completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length), + signature); + + written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan() + .Slice(headerBase64Length + 1 + payloadBase64Length + 1)); + + if (written != signatureBase64Length) + throw new Exception("expected signature length did not match bytes written"); + + return completeJwt; + } + + private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider) + { + writer.WriteStartObject(); + writer.WriteString("typ"u8, "JWT"u8); + signatureProvider.WriteJwtHeaderFields(writer); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs new file mode 100644 index 0000000..ab33a72 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs @@ -0,0 +1,88 @@ +using System.Security.Cryptography; +using System.Text.Json; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core; + +public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services) +{ + + public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key) + { + throw new NotImplementedException(); + } + + public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData) + { + + + //realm.DefaultSignatureAlgorithm + //realm.TokenSigningKeys + + //IJwtSignatureProvider? sigProvider = services.GetKeyedService(); + throw new NotImplementedException(); + } + +} + +public class RsaJwtSignatureProvider : IJwtSignatureProvider +{ + private JwtSigAlgName _sigAlgName; + private RealmSigningKeyId _keyId; + private readonly RSA _rsa; + + public RsaJwtSignatureProvider(DekEncryptionService dekCryptor, + RealmSigningKey privateKey, + JwtSigAlgName sigAlgName) + { + _sigAlgName = sigAlgName; + _keyId = privateKey.Id; + + byte[] key = dekCryptor.Decrypt(privateKey.Key); + _rsa = RSA.Create(); + _rsa.ImportPkcs8PrivateKey(key, out int _); + } + + /* + +-------------------+---------------------------------+ + | "alg" Param Value | Digital Signature Algorithm | + +-------------------+---------------------------------+ + | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | + | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | + | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | + +-------------------+---------------------------------+ + */ + + + public void WriteJwtHeaderFields(Utf8JsonWriter writer) + { + writer.WriteString("alg"u8, _sigAlgName.ToString()); + writer.WriteString("kid"u8, _keyId.ToString()); + } + + public int GetSignatureLength() + { + return _rsa.KeySize / 8; + } + + public void CalculateSignature(ReadOnlySpan jwt, Span sig) + { + _rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1); + } + + public void Dispose() + { + _rsa.Dispose(); + } + + private HashAlgorithmName GetHashAlgorithmName() + => _sigAlgName.Name switch + { + "RS256" => HashAlgorithmName.SHA256, + "RS384" => HashAlgorithmName.SHA384, + "RS512" => HashAlgorithmName.SHA512, + _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") + }; + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs deleted file mode 100644 index e22cfca..0000000 --- a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.WebUtilities; - -namespace IdentityShroud.Core; - -public static class JwtSignatureGenerator -{ - /// - /// Generates a JWT signature using RS256 algorithm - /// - /// Base64Url encoded header - /// Base64Url encoded payload - /// RSA private key (PEM format or RSA parameters) - /// Base64Url encoded signature - public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey) - { - // Combine header and payload with a period - string dataToSign = $"{headerBase64Url}.{payloadBase64Url}"; - - // Convert to bytes - byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign); - - // Sign the data using RSA-SHA256 - byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - // Convert signature to Base64Url encoding - string signature = WebEncoders.Base64UrlEncode(signatureBytes); - - return signature; - } - - public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey) - { - string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey); - return $"{headerBase64Url}.{payloadBase64Url}.{signature}"; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs new file mode 100644 index 0000000..5e44402 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security.Keys.Aes; + +public class AesKeyPolicy : KeyPolicy +{ + public AesKeyPolicy() + { + KeyType = KeyType.AES; + KeySize = 256; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs new file mode 100644 index 0000000..b30428f --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Messages; + +namespace IdentityShroud.Core.Security.Keys.Aes; + +public class AesProvider : IKeyProvider +{ + public bool IsPublic => false; + public KeyData CreateKey(KeyPolicy policy) + { + return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8)); + } + + public void SetJwkParameters(Dictionary parameters, JsonWebKey jwk) + { + // Can we use this for Jwe? + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs index 8e32309..6a5ce45 100644 --- a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -2,17 +2,32 @@ using IdentityShroud.Core.Messages; namespace IdentityShroud.Core.Security.Keys; -public abstract class KeyPolicy +public class KeyPolicy { - public abstract string KeyType { get; } + public KeyType KeyType { get; protected init; } + public int KeySize { get; protected init; } +} + +public record KeyData(byte[] PrivateKey, Dictionary? PublicKeyParameters = null) +{ + /// + /// The data to be kept private, also used for symmetric keys + /// + public byte[] PrivateKey { get; set; } = PrivateKey; + + public Dictionary? PublicKeyParameters { get; set; } = PublicKeyParameters; } public interface IKeyProvider { - byte[] CreateKey(KeyPolicy policy); + /// + /// Returns true when this key uses public key cryptography + /// + bool IsPublic { get; } + KeyData CreateKey(KeyPolicy policy); - void SetJwkParameters(byte[] key, JsonWebKey jwk); + void SetJwkParameters(Dictionary parameters, JsonWebKey jwk); } diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs index 485e6e5..c39a836 100644 --- a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys; public interface IKeyProviderFactory { - public IKeyProvider CreateProvider(string keyType); + public IKeyProvider CreateProvider(KeyType keyType); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs index a1c3472..33d5092 100644 --- a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -1,15 +1,18 @@ +using IdentityShroud.Core.Security.Keys.Aes; using IdentityShroud.Core.Security.Keys.Rsa; namespace IdentityShroud.Core.Security.Keys; public class KeyProviderFactory : IKeyProviderFactory { - public IKeyProvider CreateProvider(string keyType) + public IKeyProvider CreateProvider(KeyType keyType) { - switch (keyType) + switch (keyType.Name) { case "RSA": return new RsaProvider(); + case "AES": + return new AesProvider(); default: throw new NotImplementedException(); } diff --git a/IdentityShroud.Core/Security/Keys/KeyType.cs b/IdentityShroud.Core/Security/Keys/KeyType.cs new file mode 100644 index 0000000..224e989 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyType.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Security.Keys; + +[JsonConverter(typeof(KeyTypeJsonConverter))] +public readonly record struct KeyType(string Name) +{ + public static KeyType AES => new("AES"); + public static KeyType RSA => new("RSA"); + public override string ToString() => Name; +} + +public class KeyTypeJsonConverter : JsonConverter +{ + public override KeyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new KeyType(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, KeyType value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs new file mode 100644 index 0000000..0e2919c --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security.Keys.Rsa; + +public class RsaKeyPolicy : KeyPolicy +{ + public RsaKeyPolicy() + { + KeyType = KeyType.RSA; + KeySize = 2048; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs index daf2b7f..717f9de 100644 --- a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -4,32 +4,31 @@ 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) + public bool IsPublic => true; + + public KeyData CreateKey(KeyPolicy policy) { if (policy is RsaKeyPolicy p) { using var rsa = RSA.Create(p.KeySize); - return rsa.ExportPkcs8PrivateKey(); + var publicParamaters = rsa.ExportParameters(includePrivateParameters: false); + return new KeyData( + rsa.ExportPkcs8PrivateKey(), + new() + { + ["e"] = Base64Url.EncodeToString(publicParamaters.Exponent), + ["n"] = Base64Url.EncodeToString(publicParamaters.Modulus), + }); } throw new ArgumentException("Incorrect policy type", nameof(policy)); } - public void SetJwkParameters(byte[] key, JsonWebKey jwk) + public void SetJwkParameters(Dictionary parameters, 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); + jwk.Exponent = parameters["e"]; + jwk.Modulus = parameters["n"]; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 0887ccd..61be016 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; +using FluentValidation; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; using Microsoft.EntityFrameworkCore; @@ -8,24 +10,35 @@ namespace IdentityShroud.Core.Services; public class ClientService( Db db, IDataEncryptionService cryptor, + IValidator clientCreateValidator, IClock clock) : IClientService { public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) { + clientCreateValidator.ValidateAndThrow(request); + + Realm realm = await db.Realms.FirstOrDefaultAsync(e => e.Id == realmId, ct) + ?? throw new InvalidOperationException("Require the id of an existing realm"); + Client client = new() { RealmId = realmId, ClientId = request.ClientId, Name = request.Name, Description = request.Description, - SignatureAlgorithm = request.SignatureAlgorithm, - AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false, + SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm), + Confidential = request.Confidential, + AllowClientCredentialsFlow = request.AllowClientCredentialsFlow, CreatedAt = clock.UtcNow(), }; - if (client.AllowClientCredentialsFlow) + if (request.GenerateSecret is true) { - client.Secrets.Add(CreateSecret()); + await db.Entry(realm).Collection(r => r.DataEncryptionKeys) + .Query() + .LoadAsync(ct); + + client.Secrets.Add(CreateSecret(realm)); } await db.AddAsync(client, ct); @@ -50,15 +63,17 @@ public class ClientService( return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); } - private ClientSecret CreateSecret() + private ClientSecret CreateSecret(Realm realm) { Span secret = stackalloc byte[24]; RandomNumberGenerator.Fill(secret); + + var dek = realm.DataEncryptionKeys.Single(k => k.Active); return new ClientSecret() { CreatedAt = clock.UtcNow(), - Secret = cryptor.Encrypt(secret.ToArray()), + Secret = cryptor.Encrypt(dek, secret), }; } diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs index a06cbae..9d8f092 100644 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -5,37 +5,23 @@ 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() + public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain) { - 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); } + + public byte[] Decrypt(IReadOnlyList deks, EncryptedValue input) + { + // Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like + // - Old key removed before migration completed (should not be possible) + // - Wrong keyset because of programming error. + var dek = deks.SingleOrDefault(d => d.Id == input.DekId) + ?? throw new InvalidOperationException("Required key not found"); + var key = dekCryptor.Decrypt(dek.KeyData); + return Encryption.Decrypt(input.Value, key); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index a2ce9dc..10900dd 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -1,46 +1,16 @@ 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 + IKeyProviderFactory keyProviderFactory) : IKeyService { - public RealmKey CreateKey(KeyPolicy policy) + public CreateKeyResponse CreateKey(KeyPolicy policy) { IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType); - var plainKey = provider.CreateKey(policy); + KeyData plainKey = provider.CreateKey(policy); - return CreateKey(policy.KeyType, plainKey); + return new CreateKeyResponse(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/OpenId/TokenService.cs b/IdentityShroud.Core/Services/OpenId/TokenService.cs new file mode 100644 index 0000000..964b1d9 --- /dev/null +++ b/IdentityShroud.Core/Services/OpenId/TokenService.cs @@ -0,0 +1,30 @@ +namespace IdentityShroud.Core.Services.OpenId; + +public interface ITokenService +{ + Task> Handle( + Dictionary form, + string? basicAuthUser, + string? basicAuthPassword, + CancellationToken ct = default); +} + +public class TokenService : ITokenService +{ + public async Task> Handle( + Dictionary form, + string? basicAuthUser, + string? basicAuthPassword, + CancellationToken ct = default) + { + return new(); + } + + public async Task> ClientCredentialsFlow( + string clientId, + string clientSecret, + CancellationToken ct = default) + { + return new(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs index 7daa399..8c5de16 100644 --- a/IdentityShroud.Core/Services/RealmContext.cs +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -16,11 +16,11 @@ public class RealmContext( public async Task> GetDeks(CancellationToken ct = default) { Realm realm = GetRealm(); - if (realm.Deks.Count == 0) + if (realm.DataEncryptionKeys.Count == 0) { await realmService.LoadDeks(realm); } - return realm.Deks; + return realm.DataEncryptionKeys; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 949c9fe..9dd3ba8 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,18 +1,21 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Security.Keys.Aes; using IdentityShroud.Core.Security.Keys.Rsa; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Services; -public record RealmCreateResponse(Guid Id, string Slug, string Name); - public class RealmService( Db db, - IKeyService keyService) : IRealmService + IKeyService keyService, + IDekEncryptionService dekCryptor, + IClock clock) : IRealmService { public async Task FindById(Guid id, CancellationToken ct = default) { @@ -26,7 +29,7 @@ public class RealmService( .SingleOrDefaultAsync(r => r.Slug == slug, ct); } - public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) + public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() { @@ -35,26 +38,52 @@ public class RealmService( Name = request.Name, }; - realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); + realm.TokenSigningKeys.Add(CreateSigningKey(realm)); + realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm)); db.Add(realm); await db.SaveChangesAsync(ct); - - return new RealmCreateResponse( - realm.Id, realm.Slug, realm.Name); + + return realm; } + + private RealmSigningKey CreateSigningKey(Realm realm) + { + var k = keyService.CreateKey(GetSigningKeyPolicy(realm)); + return new RealmSigningKey + { + Id = RealmSigningKeyId.NewId(), + KeyType = k.KeyType, + Key = dekCryptor.Encrypt(k.Key.PrivateKey), + PublicKeyParameters = k.Key.PublicKeyParameters, + CreatedAt = clock.UtcNow(), + }; + } + + private RealmDek CreateDataEncryptionKey(Realm realm) + { + var k = keyService.CreateKey(GetDataKeyPolicy(realm)); + return new RealmDek() + { + Id = DekId.NewId(), + Active = true, + Algorithm = k.KeyType, + KeyData = dekCryptor.Encrypt(k.Key.PrivateKey), + }; + } + /// /// 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(); - + private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy(); + private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy(); public async Task LoadActiveKeys(Realm realm) { - await db.Entry(realm).Collection(r => r.Keys) + await db.Entry(realm).Collection(r => r.TokenSigningKeys) .Query() .Where(k => k.RevokedAt == null) .LoadAsync(); @@ -62,7 +91,7 @@ public class RealmService( public async Task LoadDeks(Realm realm) { - await db.Entry(realm).Collection(r => r.Deks) + await db.Entry(realm).Collection(r => r.DataEncryptionKeys) .Query() .LoadAsync(); } diff --git a/IdentityShroud.Migrations/DesignTimeDbFactory.cs b/IdentityShroud.Migrations/DesignTimeDbFactory.cs index 9459610..e03d3ef 100644 --- a/IdentityShroud.Migrations/DesignTimeDbFactory.cs +++ b/IdentityShroud.Migrations/DesignTimeDbFactory.cs @@ -1,4 +1,4 @@ -using IdentityShroud.Core; +using IdentityShroud.Core.EFCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs index 016f358..9dbf957 100644 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -35,6 +35,8 @@ public static class JsonObjectAssert return segments.ToArray(); } + public static JsonNode? NavigateToPath(JsonObject jsonObject, string path) + => NavigateToPath(jsonObject, ParsePath(path)); /// /// Navigates to a JsonNode at the specified path and returns it. /// Throws XunitException if the path doesn't exist or is invalid. diff --git a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs index 4e97bfc..eaf1180 100644 --- a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs +++ b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs @@ -1,4 +1,5 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; namespace IdentityShroud.TestUtils.Substitutes; @@ -6,12 +7,12 @@ namespace IdentityShroud.TestUtils.Substitutes; public class NullDataEncryptionService : IDataEncryptionService { public DekId KeyId { get; } = DekId.NewId(); - public EncryptedValue Encrypt(ReadOnlySpan plain) + public EncryptedValue Encrypt(RealmDek key, ReadOnlySpan plain) { return new(KeyId, plain.ToArray()); } - public byte[] Decrypt(EncryptedValue input) + public byte[] Decrypt(IReadOnlyList keys, EncryptedValue input) { return input.Value; } diff --git a/IdentityShroud.sln b/IdentityShroud.sln index ef65bf2..4fd0005 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -16,7 +16,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils", 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}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "08_Tests", "08_Tests", "{980900AA-E052-498B-A41A-4F33A8678828}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01", "01", "{07B08872-1141-4BE6-87E6-B85E52FE4341}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -58,5 +60,7 @@ Global {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} + {D2B446A0-AB62-4555-9D79-33FF43D7CEF4} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {8490BF59-B68A-4BE0-9F96-6CB262AF4850} = {07B08872-1141-4BE6-87E6-B85E52FE4341} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 540f7bf..158c9b3 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -1,35 +1,69 @@  ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + 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"> - <Solution /> -</SessionState> - <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> @@ -44,6 +78,14 @@ + + + + + + + + From 054754f553889d10b03dd0a8a55198b11181911b Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 07:40:24 +0200 Subject: [PATCH 12/14] Reworked encryption to use less heap allocated buffers for secrets. Also some work on plugin system. --- .editorconfig | 2 + .../Apis/ClientApiTests.cs | 6 +- .../Fixtures/ApplicationFactory.cs | 1 + .../IdentityShroud.Api.Tests.csproj | 2 +- .../Apis/Mappers/ClientMapper.cs | 3 + IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 2 +- .../AppJsonSerializerContext.cs | 14 - IdentityShroud.Api/IdentityShroud.Api.csproj | 3 +- IdentityShroud.Api/Program.cs | 124 ++++--- .../Properties/launchSettings.json | 2 +- .../Security/Jwt/RsaJwtSignerTests.cs | 48 +++ .../Services/DekEncryptionServiceTests.cs | 14 +- .../Services/EncryptionTests.cs | 3 +- .../Contracts/IDekEncryptionService.cs | 4 +- .../CoreServiceCollectionExtensions.cs | 38 +++ .../IdentityShroud.Core.csproj | 5 + .../Model/DecryptedSigningKey.cs | 66 ++++ IdentityShroud.Core/Plugins/PluginLoader.cs | 59 ++++ IdentityShroud.Core/Plugins/PluginRegistry.cs | 18 + IdentityShroud.Core/Security/Encryption.cs | 39 ++- ...IJwtSignatureProvider.cs => IJwtSigner.cs} | 14 +- .../Security/Jwt/IJwtSignerFactory.cs | 6 + .../Security/Jwt/JwtSignatureGenerator.cs | 55 +-- .../Security/Jwt/JwtSignerFactory.cs | 17 + .../Security/Jwt/RsaJwtSignatureProvider.cs | 88 ----- .../Security/Jwt/RsaJwtSigner.cs | 36 ++ .../Services/DataEncryptionService.cs | 32 +- .../Services/DekEncryptionService.cs | 10 +- .../IdentityShroud.GraphQL.csproj | 17 + IdentityShroud.GraphQL/Query.cs | 26 ++ .../RegistrationExtensions.cs | 31 ++ .../20260412083710_Initial.Designer.cs | 318 ++++++++++++++++++ .../Migrations/20260412083710_Initial.cs | 171 ++++++++++ .../Migrations/DbModelSnapshot.cs | 315 +++++++++++++++++ IdentityShroud.PluginSupport/IPlugin.cs | 9 + .../IdentityShroud.PluginSupport.csproj | 9 + .../ISecretProvider.cs | 15 + .../IdentityShroud.SecretProviders.csproj | 13 + IdentityShroud.SecretProviders/PlainSecret.cs | 18 + .../Substitutes/NullDekEncryptionService.cs | 10 + IdentityShroud.sln | 23 ++ IdentityShroud.sln.DotSettings.user | 8 +- 42 files changed, 1452 insertions(+), 242 deletions(-) create mode 100644 .editorconfig delete mode 100644 IdentityShroud.Api/AppJsonSerializerContext.cs create mode 100644 IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs create mode 100644 IdentityShroud.Core/CoreServiceCollectionExtensions.cs create mode 100644 IdentityShroud.Core/Model/DecryptedSigningKey.cs create mode 100644 IdentityShroud.Core/Plugins/PluginLoader.cs create mode 100644 IdentityShroud.Core/Plugins/PluginRegistry.cs rename IdentityShroud.Core/Security/Jwt/{IJwtSignatureProvider.cs => IJwtSigner.cs} (61%) create mode 100644 IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs delete mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs create mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs create mode 100644 IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj create mode 100644 IdentityShroud.GraphQL/Query.cs create mode 100644 IdentityShroud.GraphQL/RegistrationExtensions.cs create mode 100644 IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs create mode 100644 IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs create mode 100644 IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs create mode 100644 IdentityShroud.PluginSupport/IPlugin.cs create mode 100644 IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj create mode 100644 IdentityShroud.SecretProviders/ISecretProvider.cs create mode 100644 IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj create mode 100644 IdentityShroud.SecretProviders/PlainSecret.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..33a3ce8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +resharper_naming_rules.abbreviations = QL, DB diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs index 7133bd7..cf1eb9f 100644 --- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -17,10 +17,8 @@ namespace IdentityShroud.Api.Tests.Apis; public class ClientApiTests : IClassFixture { - private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = AppJsonSerializerContext.Default, - }; + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly ApplicationFactory _factory; public ClientApiTests(ApplicationFactory factory) diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 9846559..0c5337d 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -1,3 +1,4 @@ +using IdentityShroud.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj index a3aa6a8..4bb8f47 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -23,7 +23,7 @@ - + diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs index 8e58717..0c6563f 100644 --- a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -6,6 +6,9 @@ namespace IdentityShroud.Api.Mappers; [Mapper] public partial class ClientMapper { + // skipping secret as we do not have the DEK [MapperIgnoreSource(nameof(Client.Secrets))] + [MapperIgnoreTarget(nameof(ClientRepresentation.Secret))] public partial ClientRepresentation ToDto(Client client); + } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs index 053be93..54b972a 100644 --- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -47,7 +47,7 @@ public static class OpenIdEndpoints TokenEndpoint = baseUri + "/openid-connect/token", Issuer = baseUri, JwksUri = baseUri + "/openid-connect/jwks", - }, AppJsonSerializerContext.Default.OpenIdConfiguration); + }); } private static async Task, BadRequest>> OpenIdConnectJwks( diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs deleted file mode 100644 index 5733ac3..0000000 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; -using IdentityShroud.Api.Apis; -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Messages.Realm; - -namespace IdentityShroud.Api; - -[JsonSerializable(typeof(ClientRepresentation))] -[JsonSerializable(typeof(ErrorDto))] -[JsonSerializable(typeof(OpenIdConfiguration))] -[JsonSerializable(typeof(RealmCreateRequest))] -public 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..5d779e7 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -5,7 +5,7 @@ enable enable true - true + false Linux 6b8ef434-0577-4a3c-8749-6b547d7787c5 @@ -25,6 +25,7 @@ + diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index b2a31e8..2ff5fe6 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,78 +1,74 @@ using FluentValidation; -using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Core.Contracts; +using IdentityShroud.Core; using IdentityShroud.Core.EFCore; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Security.Keys; -using IdentityShroud.Core.Services; +using IdentityShroud.GraphQL; using Serilog; using Serilog.Formatting.Json; - // Initial logging until we can set it up from Configuration -Log.Logger = new LoggerConfiguration() - .Enrich.FromLogContext() - .WriteTo.Console(new JsonFormatter()) - .CreateLogger(); -var applicationBuilder = WebApplication.CreateSlimBuilder(args); -ConfigureBuilder(applicationBuilder); -var application = applicationBuilder.Build(); -ConfigureApplication(application); -application.Run(); +namespace IdentityShroud.Api; -void ConfigureBuilder(WebApplicationBuilder builder) +public class Program { - var services = builder.Services; - var configuration = builder.Configuration; - - //services.AddControllers(); - services.ConfigureHttpJsonOptions(options => + public static void Main(string[] args) { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); - }); + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console(new JsonFormatter()) + .CreateLogger(); - // 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.AddExceptionHandler(); - services.AddProblemDetails(); - - builder.Host.UseSerilog((context, services, configuration) => configuration - .Enrich.FromLogContext() - //.Enrich.With() - .ReadFrom.Configuration(context.Configuration)); -} - -void ConfigureApplication(WebApplication app) -{ - app.UseExceptionHandler(); - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); + var applicationBuilder = WebApplication.CreateSlimBuilder(args); + ConfigureBuilder(applicationBuilder); + var application = applicationBuilder.Build(); + ConfigureApplication(application); + application.Run(); } - app.UseSerilogRequestLogging(); - app.MapApis(); - - // app.UseRouting(); - // app.MapControllers(); -} -public partial class Program { } + private static void ConfigureBuilder(WebApplicationBuilder builder) + { + var services = builder.Services; + var configuration = builder.Configuration; + + services.AddOptions().Bind(configuration.GetSection("db")); + + // services.ConfigureHttpJsonOptions(options => + // { + // options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); + // }); + + services.AddScoped(); + + services.AddValidatorsFromAssemblyContaining(); + + services.AddHttpContextAccessor(); + services.AddOpenApi(); + services.AddExceptionHandler(); + services.AddProblemDetails(); + + services + .AddCore() + .AddIdentityShroudGraphQL(); + + builder.Host.UseSerilog((context, services, configuration) => configuration + .Enrich.FromLogContext() + //.Enrich.With() + .ReadFrom.Configuration(context.Configuration)); + } + + private static void ConfigureApplication(WebApplication app) + { + app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + app.UseSerilogRequestLogging(); + app.MapApis(); + app.MapIdentityShroudGraphQL(); + + // app.UseRouting(); + // app.MapControllers(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Properties/launchSettings.json b/IdentityShroud.Api/Properties/launchSettings.json index 9472c5a..8556497 100644 --- a/IdentityShroud.Api/Properties/launchSettings.json +++ b/IdentityShroud.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "todos", + "launchUrl": "graphql", "applicationUrl": "http://localhost:5249", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs new file mode 100644 index 0000000..13b76ac --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs @@ -0,0 +1,48 @@ +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; + +namespace IdentityShroud.Core.Tests.Security.Jwt; + +public class RsaJwtSignerTests +{ + [Fact] + public void Test() + { + // ISecretProvider secretProvider = Substitute.For(); + // RealmSigningKey privateKey = new() + // { + // Id = default, + // KeyType = KeyType.RSA, + // Key = new EncryptedDek(KekId.NewId(), [1]), + // CreatedAt = default, + // RevokedAt = null, + // Priority = 0, + // PublicKeyParameters = null + // }; + DecryptedSigningKey key = new(); + byte[] jwt = []; + + RsaJwtSigner provider = new(); + provider.CalculateSignature(JwtSigAlgName.RS256, key, jwt); + // + // new DekEncryptionService(secretProvider), privateKey, + // JwtSigAlgName.RS256); + } + + [Theory] + [InlineData(1024)] + [InlineData(2048)] + [InlineData(4096)] + public void EstimateKeySizeTests(int keySizeBits) + { + using var rsa = RSA.Create(); + rsa.KeySize = keySizeBits; + byte[] b = rsa.ExportPkcs8PrivateKey(); + int estimate = DecryptedSigningKey.EstimatePkcs8ExportSize(keySizeBits); + Assert.True(b.Length < estimate - 100); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs index fc4a45f..c0b9f38 100644 --- a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -25,8 +25,13 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); - byte[] result = sut.Decrypt(cipher); + int decryptedSize = sut.GetDecryptedSize(cipher); + Assert.Equal(input.Length, decryptedSize); + + var result = new byte[decryptedSize]; + sut.Decrypt(cipher, result); // verify Assert.Equal(input, result); @@ -56,8 +61,10 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); + int decryptedSize = sut.GetDecryptedSize(secret); + var result = new byte[decryptedSize]; Assert.Throws( - () => sut.Decrypt(secret), + () => sut.Decrypt(secret, result), ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); } @@ -89,7 +96,8 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(secret); + byte[] result = new byte[sut.GetDecryptedSize(secret)]; + sut.Decrypt(secret, result); // verify Assert.Equal("Hello, World!"u8, result); diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs index f040b84..32e4538 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -19,7 +19,8 @@ public class EncryptionTests byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); // act - byte[] result = Encryption.Decrypt(cipher, keyValue); + byte[] result = new byte[Encryption.GetDecryptedLength(cipher)]; + Encryption.Decrypt(cipher, keyValue, result); // verify Assert.Equal("Hello, World!"u8, result); diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs index 3032040..bbb234c 100644 --- a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -7,5 +7,7 @@ namespace IdentityShroud.Core.Contracts; public interface IDekEncryptionService { EncryptedDek Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedDek input); + + void Decrypt(EncryptedDek input, Span output); + int GetDecryptedSize(EncryptedDek input); } \ No newline at end of file diff --git a/IdentityShroud.Core/CoreServiceCollectionExtensions.cs b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs new file mode 100644 index 0000000..86d7339 --- /dev/null +++ b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityShroud.Core; + +public static class CoreServiceCollectionExtensions +{ + public static IServiceCollection AddCore(this IServiceCollection services) + { + services.AddScoped(); + + services.Scan(scan => scan + .FromAssemblyOf() + .AddClasses(classes => classes.AssignableTo()) + .AsImplementedInterfaces() + .WithSingletonLifetime()); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + + services.AddScoped(); + services.AddScoped(); + + + return services; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index fb54802..4562d8d 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -15,11 +15,16 @@ + + + + + diff --git a/IdentityShroud.Core/Model/DecryptedSigningKey.cs b/IdentityShroud.Core/Model/DecryptedSigningKey.cs new file mode 100644 index 0000000..4a94dc7 --- /dev/null +++ b/IdentityShroud.Core/Model/DecryptedSigningKey.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Model; + +public sealed class DecryptedSigningKey : IDisposable +{ + private readonly byte[] _keyData; + private readonly int _keyLength; + private bool _disposed; + + public RealmSigningKeyId Id { get; } + public KeyType KeyType { get; } + public ReadOnlySpan KeyData => _disposed + ? throw new ObjectDisposedException(nameof(DecryptedSigningKey)) + : _keyData.AsSpan(0, _keyLength); + + public DecryptedSigningKey(RealmSigningKey realmSigningKey, IDekEncryptionService encryptionService) + { + Id = realmSigningKey.Id; + KeyType = realmSigningKey.KeyType; + int keySize = encryptionService.GetDecryptedSize(realmSigningKey.Key); + _keyData = GC.AllocateArray(keySize, pinned: true); + _keyLength = keySize; + encryptionService.Decrypt(realmSigningKey.Key, _keyData); + } + + public DecryptedSigningKey() + { + Id = RealmSigningKeyId.NewId(); + KeyType = KeyType.RSA; + const int keySize = 2048; + + using var rsa = RSA.Create(); + rsa.KeySize = keySize; + int estimatedSize = EstimatePkcs8ExportSize(keySize); + + Span temp = stackalloc byte[estimatedSize * 2]; + try + { + if (!rsa.TryExportPkcs8PrivateKey(temp, out int bytesWritten)) + throw new CryptographicException("Unable to export RSA private key."); + + _keyData = GC.AllocateArray(bytesWritten, pinned: true); + _keyLength = bytesWritten; + temp[..bytesWritten].CopyTo(_keyData); + } + finally + { + CryptographicOperations.ZeroMemory(temp); + } + } + + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + CryptographicOperations.ZeroMemory(_keyData); + } + + // Note actual accurate coefficients would be *0.566 and +57.4 + public static int EstimatePkcs8ExportSize(int keySizeBits) + => ((keySizeBits * 6) / 10) + 150; +} \ No newline at end of file diff --git a/IdentityShroud.Core/Plugins/PluginLoader.cs b/IdentityShroud.Core/Plugins/PluginLoader.cs new file mode 100644 index 0000000..e216a57 --- /dev/null +++ b/IdentityShroud.Core/Plugins/PluginLoader.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using System.Runtime.Loader; +using IdentityShroud.PluginSupport; + +namespace IdentityShroud.Core.Plugins; + +public static class PluginLoader +{ + public static IEnumerable LoadPlugins(string pluginsFolder) + { + if (!Directory.Exists(pluginsFolder)) + yield break; + + foreach (var dll in Directory.EnumerateFiles(pluginsFolder, "*.dll")) + { + foreach (var plugin in LoadPluginDll(dll)) yield return plugin; + } + } + + private static IEnumerable LoadPluginDll(string dll) + { + Assembly asm; + try + { + asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(dll)); + } + catch + { + yield break; + } + + IEnumerable pluginTypes; + try + { + pluginTypes = asm.GetTypes() + .Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }); + } + catch + { + yield break; + } + + foreach (var t in pluginTypes) + { + IPlugin? instance = null; + try + { + instance = (IPlugin?)Activator.CreateInstance(t); + } + catch + { + // ignore bad plugin types + } + + if (instance != null) + yield return instance; + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Plugins/PluginRegistry.cs b/IdentityShroud.Core/Plugins/PluginRegistry.cs new file mode 100644 index 0000000..d58863c --- /dev/null +++ b/IdentityShroud.Core/Plugins/PluginRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.ObjectModel; +using IdentityShroud.PluginSupport; + +namespace IdentityShroud.Core.Plugins; + +/// +/// Note +/// +/// +public class PluginRegistry where TPlugin : IPlugin +{ + private ReadOnlyDictionary _plugins; + + public PluginRegistry(ReadOnlyDictionary plugins) + { + _plugins = plugins; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs index 5fe274e..01c8843 100644 --- a/IdentityShroud.Core/Security/Encryption.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -35,36 +35,45 @@ public static class Encryption return result; } - public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) + public static void Decrypt(ReadOnlyMemory input, ReadOnlySpan key, Span output) { - 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)); + AlgVersion versionParams = GetVersionParams(input); + if (input.Length < 1 + versionParams.NonceSize + versionParams.TagSize) + throw new ArgumentException("Cypher data is too short to be valid.", nameof(input)); + var payload = input.Span; 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); + aes.Decrypt(nonce, cipher, tag, output); } catch (CryptographicException ex) { // Tag verification failed → tampering or wrong key/nonce. throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); } + } - return plaintext; + public static int GetDecryptedLength(ReadOnlyMemory input) + { + AlgVersion versionParams = GetVersionParams(input); + int length = input.Length - (1 + versionParams.NonceSize + versionParams.TagSize); + if (length < 0) + throw new ArgumentException("Cypher data is too short to be valid.", nameof(input)); + + return length; + } + + private static AlgVersion GetVersionParams(ReadOnlyMemory input) + { + var versionNumber = (int)input.Span[0]; + if (versionNumber != 1) + throw new ArgumentException("Invalid payload"); + + return _versions[versionNumber]; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs similarity index 61% rename from IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs rename to IdentityShroud.Core/Security/Jwt/IJwtSigner.cs index 5100436..80fc37e 100644 --- a/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs +++ b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs @@ -1,8 +1,9 @@ using System.Text.Json; +using IdentityShroud.Core.Model; namespace IdentityShroud.Core; -public interface IJwtSignatureProvider : IDisposable +public interface IJwtSigner { /* Of the signature and MAC algorithms specified in JSON Web Algorithms @@ -13,14 +14,7 @@ public interface IJwtSignatureProvider : IDisposable hash algorithm ("ES256"). Support for other algorithms and key sizes is OPTIONAL. */ + IReadOnlyList Algorithms { get; } - void WriteJwtHeaderFields(Utf8JsonWriter writer); - /// - /// Length of the binary signature in bytes. - /// - /// - int GetSignatureLength(); - - void CalculateSignature(ReadOnlySpan jwt, Span signatureOut); - + byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs new file mode 100644 index 0000000..fbab369 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core; + +public interface IJwtSignerFactory +{ + IJwtSigner Create(JwtSigAlgName algorithm); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs index 6143f35..99b9097 100644 --- a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs +++ b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs @@ -2,6 +2,7 @@ using System.Buffers.Text; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using IdentityShroud.Core.Model; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core; @@ -40,49 +41,48 @@ public static class JwtSignatureGenerator } -public static class JwtCreator +public class JwtService(IJwtSignerFactory signerFactory) { - public static byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, IJwtSignatureProvider signatureProvider) + public byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, JwtSigAlgName algName, DecryptedSigningKey key) { - MemoryStream memStream = new(); - Utf8JsonWriter writer = new(memStream); - WriteJwtHeader(writer, signatureProvider); - writer.Flush(); - memStream.Seek(0, SeekOrigin.Begin); + // LATER might be able to improve performance using ArrayPool + + IJwtSigner signer = signerFactory.Create(algName); + MemoryStream headerMemStream = new(); + Utf8JsonWriter headerWriter = new(headerMemStream); + WriteJwtHeader(headerWriter, algName, key.Id.ToString()); + headerWriter.Flush(); + headerMemStream.Seek(0, SeekOrigin.Begin); - int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length); + int headerBase64Length = Base64Url.GetEncodedLength((int)headerMemStream.Length); int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length); - int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength()); - int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length; - - var completeJwt = new byte[totalLength]; + var jwtData = new byte[headerBase64Length + payloadBase64Length + 1]; // - var byteArray = new byte[memStream.Length]; - memStream.ReadExactly(byteArray, 0, (int)memStream.Length); - int written = Base64Url.EncodeToUtf8(byteArray, completeJwt); + var byteArray = new byte[headerMemStream.Length]; + headerMemStream.ReadExactly(byteArray, 0, (int)headerMemStream.Length); + int written = Base64Url.EncodeToUtf8(byteArray, jwtData); if (written != headerBase64Length) throw new Exception("expected header length did not match bytes written"); - completeJwt[headerBase64Length] = (byte)'.'; + jwtData[headerBase64Length] = (byte)'.'; - written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); + written = Base64Url.EncodeToUtf8(payloadUtf8, jwtData.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); if (written != payloadBase64Length) throw new Exception("expected payload length did not match bytes written"); - completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.'; + byte[] signature = signer.CalculateSignature(algName, key, jwtData.AsSpan()); + int signatureBase64Length = Base64Url.GetEncodedLength(signature.Length); - Span signature = stackalloc byte[signatureProvider.GetSignatureLength()]; - signatureProvider.CalculateSignature( - completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length), - signature); - - written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan() - .Slice(headerBase64Length + 1 + payloadBase64Length + 1)); + var completeJwt = new byte[jwtData.Length + 1 + signatureBase64Length]; + Array.Copy(jwtData, completeJwt, jwtData.Length); + completeJwt[jwtData.Length] = (byte)'.'; + + written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan().Slice(jwtData.Length + 1, signatureBase64Length)); if (written != signatureBase64Length) throw new Exception("expected signature length did not match bytes written"); @@ -90,11 +90,12 @@ public static class JwtCreator return completeJwt; } - private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider) + private static void WriteJwtHeader(Utf8JsonWriter writer, JwtSigAlgName algName, string keyId) { writer.WriteStartObject(); writer.WriteString("typ"u8, "JWT"u8); - signatureProvider.WriteJwtHeaderFields(writer); + writer.WriteString("alg"u8, algName.ToString()); + writer.WriteString("kid"u8, keyId); writer.WriteEndObject(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs new file mode 100644 index 0000000..85ffe21 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs @@ -0,0 +1,17 @@ +namespace IdentityShroud.Core; + +public class JwtSignerFactory(IEnumerable signers) : IJwtSignerFactory +{ + private readonly IReadOnlyDictionary _signers = signers + .SelectMany(s => s.Algorithms.Select(alg => (alg, signer: s))) + .ToDictionary(x => x.alg, x => x.signer); + + public IJwtSigner Create(JwtSigAlgName algorithm) + { + if (_signers.TryGetValue(algorithm, out var signer)) + return signer; + + throw new NotSupportedException($"JWT signing algorithm '{algorithm}' is not registered."); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs deleted file mode 100644 index ab33a72..0000000 --- a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Security.Cryptography; -using System.Text.Json; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core; - -public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services) -{ - - public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key) - { - throw new NotImplementedException(); - } - - public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData) - { - - - //realm.DefaultSignatureAlgorithm - //realm.TokenSigningKeys - - //IJwtSignatureProvider? sigProvider = services.GetKeyedService(); - throw new NotImplementedException(); - } - -} - -public class RsaJwtSignatureProvider : IJwtSignatureProvider -{ - private JwtSigAlgName _sigAlgName; - private RealmSigningKeyId _keyId; - private readonly RSA _rsa; - - public RsaJwtSignatureProvider(DekEncryptionService dekCryptor, - RealmSigningKey privateKey, - JwtSigAlgName sigAlgName) - { - _sigAlgName = sigAlgName; - _keyId = privateKey.Id; - - byte[] key = dekCryptor.Decrypt(privateKey.Key); - _rsa = RSA.Create(); - _rsa.ImportPkcs8PrivateKey(key, out int _); - } - - /* - +-------------------+---------------------------------+ - | "alg" Param Value | Digital Signature Algorithm | - +-------------------+---------------------------------+ - | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | - | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | - | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | - +-------------------+---------------------------------+ - */ - - - public void WriteJwtHeaderFields(Utf8JsonWriter writer) - { - writer.WriteString("alg"u8, _sigAlgName.ToString()); - writer.WriteString("kid"u8, _keyId.ToString()); - } - - public int GetSignatureLength() - { - return _rsa.KeySize / 8; - } - - public void CalculateSignature(ReadOnlySpan jwt, Span sig) - { - _rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1); - } - - public void Dispose() - { - _rsa.Dispose(); - } - - private HashAlgorithmName GetHashAlgorithmName() - => _sigAlgName.Name switch - { - "RS256" => HashAlgorithmName.SHA256, - "RS384" => HashAlgorithmName.SHA384, - "RS512" => HashAlgorithmName.SHA512, - _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") - }; - -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs new file mode 100644 index 0000000..80af03c --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core; + +public class RsaJwtSigner : IJwtSigner +{ + public IReadOnlyList Algorithms => [JwtSigAlgName.RS256, JwtSigAlgName.RS384, JwtSigAlgName.RS512]; + + // +-------------------+---------------------------------+ + // | "alg" Param Value | Digital Signature Algorithm | + // +-------------------+---------------------------------+ + // | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | + // | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | + // | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | + // +-------------------+---------------------------------+ + + public byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt) + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(key.KeyData, out int _); + var sig = new byte[rsa.KeySize / 8]; + rsa.SignData(jwt, sig, GetHashAlgorithmName(algName), RSASignaturePadding.Pkcs1); + return sig; + } + + private static HashAlgorithmName GetHashAlgorithmName(JwtSigAlgName algName) + => algName.Name switch + { + "RS256" => HashAlgorithmName.SHA256, + "RS384" => HashAlgorithmName.SHA384, + "RS512" => HashAlgorithmName.SHA512, + _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") + }; + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs index 9d8f092..be0cf51 100644 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; @@ -9,9 +10,17 @@ public class DataEncryptionService( { public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain) { - var key = dekCryptor.Decrypt(dek.KeyData); - byte[] cipher = Encryption.Encrypt(plain, key); - return new (dek.Id, cipher); + Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)]; + try + { + dekCryptor.Decrypt(dek.KeyData, key); + byte[] cipher = Encryption.Encrypt(plain, key); + return new (dek.Id, cipher); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } } public byte[] Decrypt(IReadOnlyList deks, EncryptedValue input) @@ -20,8 +29,19 @@ public class DataEncryptionService( // - Old key removed before migration completed (should not be possible) // - Wrong keyset because of programming error. var dek = deks.SingleOrDefault(d => d.Id == input.DekId) - ?? throw new InvalidOperationException("Required key not found"); - var key = dekCryptor.Decrypt(dek.KeyData); - return Encryption.Decrypt(input.Value, key); + ?? throw new InvalidOperationException("Required key not found"); + + Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)]; + try + { + dekCryptor.Decrypt(dek.KeyData, key); + byte[] output = new byte[Encryption.GetDecryptedLength(input.Value)]; + Encryption.Decrypt(input.Value, key, output); + return output; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs index add9267..b80ea4d 100644 --- a/IdentityShroud.Core/Services/DekEncryptionService.cs +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -18,8 +18,6 @@ public class DekEncryptionService : IDekEncryptionService 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) @@ -29,10 +27,14 @@ public class DekEncryptionService : IDekEncryptionService return new (encryptionKey.Id, cipher); } - public byte[] Decrypt(EncryptedDek input) + public void Decrypt(EncryptedDek input, Span output) { var encryptionKey = GetKey(input.KekId); + Encryption.Decrypt(input.Value, encryptionKey.Key, output); + } - return Encryption.Decrypt(input.Value, encryptionKey.Key); + public int GetDecryptedSize(EncryptedDek input) + { + return Encryption.GetDecryptedLength(input.Value); } } \ No newline at end of file diff --git a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj new file mode 100644 index 0000000..b0de15c --- /dev/null +++ b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/IdentityShroud.GraphQL/Query.cs b/IdentityShroud.GraphQL/Query.cs new file mode 100644 index 0000000..5ccbeb1 --- /dev/null +++ b/IdentityShroud.GraphQL/Query.cs @@ -0,0 +1,26 @@ +susing IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.GraphQL; + +public class Query +{ + public string GetHello() => "Hello, world!"; + + public async Task GetRealms( + Guid id, + [Service] IRealmService realmService) + { + return await realmService.FindById(id); + } +} + +public class Mutation +{ + public async Task RealmCreate(string name) + { + Realm r = new(); + + return r; + } +} \ No newline at end of file diff --git a/IdentityShroud.GraphQL/RegistrationExtensions.cs b/IdentityShroud.GraphQL/RegistrationExtensions.cs new file mode 100644 index 0000000..dad3056 --- /dev/null +++ b/IdentityShroud.GraphQL/RegistrationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityShroud.GraphQL; + +public static class RegistrationExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddIdentityShroudGraphQL() + { + services + .AddGraphQLServer() + .AddMutationConventions(applyToAllMutations: true) + .AddMutationType() + .AddQueryType(); + + return services; + } + } + + extension(IEndpointRouteBuilder app) + { + public IEndpointRouteBuilder MapIdentityShroudGraphQL() + { + app.MapGraphQL(); + return app; + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs new file mode 100644 index 0000000..6c3df6d --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs @@ -0,0 +1,318 @@ +// +using System; +using System.Collections.Generic; +using IdentityShroud.Core.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + [DbContext(typeof(Db))] + [Migration("20260412083710_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowClientCredentialsFlow") + .HasColumnType("boolean") + .HasColumnName("allow_client_credentials_flow"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("client_id"); + + b.Property("Confidential") + .HasColumnType("boolean") + .HasColumnName("confidential"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("SignatureAlgorithm") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("signature_algorithm"); + + b.HasKey("Id") + .HasName("pk_client"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_client_client_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_client_realm_id"); + + b.ToTable("client", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("ClientId1") + .HasColumnType("integer") + .HasColumnName("client_id1"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 => + { + b1.IsRequired(); + + b1.Property("DekId") + .HasColumnType("uuid") + .HasColumnName("secret_dek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("secret_value"); + }); + + b.HasKey("Id") + .HasName("pk_client_secret"); + + b.HasIndex("ClientId1") + .HasDatabaseName("ix_client_secret_client_id1"); + + b.ToTable("client_secret", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultSignatureAlgorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_signature_algorithm"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_realm"); + + b.ToTable("realm", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Algorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("algorithm"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.ComplexProperty(typeof(Dictionary), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_data_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_data_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_dek"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_dek_realm_id"); + + b.ToTable("realm_dek", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_type"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PublicKeyParameters") + .HasColumnType("jsonb") + .HasColumnName("public_key_parameters"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_key"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_key_realm_id"); + + b.ToTable("realm_key", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("Clients") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.HasOne("IdentityShroud.Core.Model.Client", null) + .WithMany("Secrets") + .HasForeignKey("ClientId1") + .HasConstraintName("fk_client_secret_client_client_id1"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("DataEncryptionKeys") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_realm_dek_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("TokenSigningKeys") + .HasForeignKey("RealmId") + .HasConstraintName("fk_realm_key_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Navigation("Clients"); + + b.Navigation("DataEncryptionKeys"); + + b.Navigation("TokenSigningKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs new file mode 100644 index 0000000..78401bb --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs @@ -0,0 +1,171 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "realm", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + default_signature_algorithm = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "client", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + realm_id = table.Column(type: "uuid", nullable: false), + client_id = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + name = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + signature_algorithm = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + confidential = table.Column(type: "boolean", nullable: false), + allow_client_credentials_flow = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_client", x => x.id); + table.ForeignKey( + name: "fk_client_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "realm_dek", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + active = table.Column(type: "boolean", nullable: false), + algorithm = table.Column(type: "text", nullable: false), + realm_id = table.Column(type: "uuid", nullable: false), + key_data_kek_id = table.Column(type: "uuid", nullable: false), + key_data_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm_dek", x => x.id); + table.ForeignKey( + name: "fk_realm_dek_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "realm_key", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key_type = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + priority = table.Column(type: "integer", nullable: false), + public_key_parameters = table.Column(type: "jsonb", nullable: true), + realm_id = table.Column(type: "uuid", nullable: true), + key_kek_id = table.Column(type: "uuid", nullable: false), + key_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm_key", x => x.id); + table.ForeignKey( + name: "fk_realm_key_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "client_secret", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + client_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expires = table.Column(type: "timestamp with time zone", nullable: true), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + client_id1 = table.Column(type: "integer", nullable: true), + secret_dek_id = table.Column(type: "uuid", nullable: false), + secret_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_client_secret", x => x.id); + table.ForeignKey( + name: "fk_client_secret_client_client_id1", + column: x => x.client_id1, + principalTable: "client", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_client_client_id", + table: "client", + column: "client_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_client_realm_id", + table: "client", + column: "realm_id"); + + migrationBuilder.CreateIndex( + name: "ix_client_secret_client_id1", + table: "client_secret", + column: "client_id1"); + + migrationBuilder.CreateIndex( + name: "ix_realm_dek_realm_id", + table: "realm_dek", + column: "realm_id"); + + migrationBuilder.CreateIndex( + name: "ix_realm_key_realm_id", + table: "realm_key", + column: "realm_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "client_secret"); + + migrationBuilder.DropTable( + name: "realm_dek"); + + migrationBuilder.DropTable( + name: "realm_key"); + + migrationBuilder.DropTable( + name: "client"); + + migrationBuilder.DropTable( + name: "realm"); + } + } +} diff --git a/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs b/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs new file mode 100644 index 0000000..f16ec76 --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs @@ -0,0 +1,315 @@ +// +using System; +using System.Collections.Generic; +using IdentityShroud.Core.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + [DbContext(typeof(Db))] + partial class DbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowClientCredentialsFlow") + .HasColumnType("boolean") + .HasColumnName("allow_client_credentials_flow"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("client_id"); + + b.Property("Confidential") + .HasColumnType("boolean") + .HasColumnName("confidential"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("SignatureAlgorithm") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("signature_algorithm"); + + b.HasKey("Id") + .HasName("pk_client"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_client_client_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_client_realm_id"); + + b.ToTable("client", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("ClientId1") + .HasColumnType("integer") + .HasColumnName("client_id1"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 => + { + b1.IsRequired(); + + b1.Property("DekId") + .HasColumnType("uuid") + .HasColumnName("secret_dek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("secret_value"); + }); + + b.HasKey("Id") + .HasName("pk_client_secret"); + + b.HasIndex("ClientId1") + .HasDatabaseName("ix_client_secret_client_id1"); + + b.ToTable("client_secret", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultSignatureAlgorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_signature_algorithm"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_realm"); + + b.ToTable("realm", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Algorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("algorithm"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.ComplexProperty(typeof(Dictionary), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_data_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_data_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_dek"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_dek_realm_id"); + + b.ToTable("realm_dek", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_type"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PublicKeyParameters") + .HasColumnType("jsonb") + .HasColumnName("public_key_parameters"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_key"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_key_realm_id"); + + b.ToTable("realm_key", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("Clients") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.HasOne("IdentityShroud.Core.Model.Client", null) + .WithMany("Secrets") + .HasForeignKey("ClientId1") + .HasConstraintName("fk_client_secret_client_client_id1"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("DataEncryptionKeys") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_realm_dek_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("TokenSigningKeys") + .HasForeignKey("RealmId") + .HasConstraintName("fk_realm_key_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Navigation("Clients"); + + b.Navigation("DataEncryptionKeys"); + + b.Navigation("TokenSigningKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IdentityShroud.PluginSupport/IPlugin.cs b/IdentityShroud.PluginSupport/IPlugin.cs new file mode 100644 index 0000000..fcae4fc --- /dev/null +++ b/IdentityShroud.PluginSupport/IPlugin.cs @@ -0,0 +1,9 @@ +namespace IdentityShroud.PluginSupport; + +/// +/// Any class that should be discovered when loading a dll should implement IPlugin +/// +public interface IPlugin +{ + +} \ No newline at end of file diff --git a/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj b/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/IdentityShroud.SecretProviders/ISecretProvider.cs b/IdentityShroud.SecretProviders/ISecretProvider.cs new file mode 100644 index 0000000..666dd1e --- /dev/null +++ b/IdentityShroud.SecretProviders/ISecretProvider.cs @@ -0,0 +1,15 @@ +namespace IdentityShroud.SecretProviders; + +/// +/// Required interface of a SecretProvider. +/// +public interface ISecretProvider +{ + /// + /// Used as a key in the registry. This same value should be used for the type field + /// when configuring a secret. + /// + string Key { get; } + + Task GetSecret(string configurationValue, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj b/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj new file mode 100644 index 0000000..3de771e --- /dev/null +++ b/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/IdentityShroud.SecretProviders/PlainSecret.cs b/IdentityShroud.SecretProviders/PlainSecret.cs new file mode 100644 index 0000000..9aca02a --- /dev/null +++ b/IdentityShroud.SecretProviders/PlainSecret.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography; + +namespace IdentityShroud.SecretProviders; + +public sealed class PlainSecret(byte[] secret) : IDisposable +{ + private bool _disposed; + + public ReadOnlySpan Secret => secret; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + CryptographicOperations.ZeroMemory(secret); + } +} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs index 879f932..84f9cd7 100644 --- a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs +++ b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs @@ -11,6 +11,16 @@ public class NullDekEncryptionService : IDekEncryptionService return new(KeyId, plain.ToArray()); } + public void Decrypt(EncryptedDek input, Span output) + { + input.Value.CopyTo(output); + } + + public int GetDecryptedSize(EncryptedDek input) + { + return input.Value.Length; + } + public byte[] Decrypt(EncryptedDek input) { return input.Value; diff --git a/IdentityShroud.sln b/IdentityShroud.sln index 4fd0005..ce316d1 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -20,6 +20,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "08_Tests", "08_Tests", "{98 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01", "01", "{07B08872-1141-4BE6-87E6-B85E52FE4341}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.GraphQL", "IdentityShroud.GraphQL\IdentityShroud.GraphQL.csproj", "{8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "04 Plugin Support", "04 Plugin Support", "{ABF0B435-2D50-41C8-847B-0905136537AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.SecretProviders", "IdentityShroud.SecretProviders\IdentityShroud.SecretProviders.csproj", "{A27A70F7-415B-4F06-8A2C-1399675D41AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.PluginSupport", "IdentityShroud.PluginSupport\IdentityShroud.PluginSupport.csproj", "{D4984187-8283-4494-88CF-35EDAD13E4DE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +62,18 @@ Global {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 + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Release|Any CPU.Build.0 = Release|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Release|Any CPU.Build.0 = Release|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4758FE2E-A437-44F0-B58E-09E52D67D288} = {980900AA-E052-498B-A41A-4F33A8678828} @@ -62,5 +82,8 @@ Global {A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828} {D2B446A0-AB62-4555-9D79-33FF43D7CEF4} = {07B08872-1141-4BE6-87E6-B85E52FE4341} {8490BF59-B68A-4BE0-9F96-6CB262AF4850} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {A27A70F7-415B-4F06-8A2C-1399675D41AE} = {ABF0B435-2D50-41C8-847B-0905136537AB} + {D4984187-8283-4494-88CF-35EDAD13E4DE} = {ABF0B435-2D50-41C8-847B-0905136537AB} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 158c9b3..0e215b5 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -25,6 +25,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -54,8 +55,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded - /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr + + + + + From 32ba04043e1b989564257ea6572e18f1a13a2152 Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 08:05:19 +0200 Subject: [PATCH 13/14] Switch to central package version management and upgrade packages to get rid of known vulnerabilities. --- Directory.Packages.props | 36 +++++++++++++++++++ .../IdentityShroud.Api.Tests.csproj | 24 ++++++------- IdentityShroud.Api/IdentityShroud.Api.csproj | 12 +++---- .../IdentityShroud.Core.Tests.csproj | 22 ++++++------ .../IdentityShroud.Core.csproj | 22 ++++++------ .../IdentityShroud.GraphQL.csproj | 4 +-- .../IdentityShroud.Migrations.csproj | 4 +-- .../IdentityShroud.TestUtils.Tests.csproj | 14 ++++---- .../IdentityShroud.TestUtils.csproj | 10 +++--- 9 files changed, 92 insertions(+), 56 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..653fdef --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,36 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 4bb8f47..67cca0e 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,20 +8,20 @@ - - - - - - - - - + + + + + + + + + - - + + diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 5d779e7..f6f4148 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -15,12 +15,12 @@ - - - - - - + + + + + + diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 1d98db0..918119c 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,19 +8,19 @@ - - - - - - - - + + + + + + + + - - + + diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 4562d8d..fe5ed22 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,16 +7,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj index b0de15c..1ba525f 100644 --- a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj +++ b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + diff --git a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj index f4583e2..8cc28ca 100644 --- a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj +++ b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj index 9ce8074..8e5e7f7 100644 --- a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj +++ b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj @@ -7,16 +7,16 @@ - - - - - + + + + + - - + + diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 4b68445..db517cc 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -8,9 +8,9 @@ - - - + + + @@ -18,8 +18,8 @@ - - + + From ba69eafc946b2ba0040dc4a1766bddf4f7fdf8e2 Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 10:41:24 +0200 Subject: [PATCH 14/14] Update readme with new project structure we are going to work towards --- IdentityShroud.sln | 3 +++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/IdentityShroud.sln b/IdentityShroud.sln index ce316d1..b22cf9c 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -7,6 +7,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core.Tests", "IdentityShroud.Core.Tests\IdentityShroud.Core.Tests.csproj", "{DC887623-8680-4D3B-B23A-D54F7DA91891}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{576359FF-C672-4CC3-A683-3BB9D647E75D}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", "IdentityShroud.Migrations\IdentityShroud.Migrations.csproj", "{DEECABE3-8934-4696-B0E1-48738DD0CEC4}" EndProject diff --git a/README.md b/README.md index 8bd5aa3..f4d3cc9 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,28 @@ IdentityShroud is a .NET project for identity management and protection. +# Architecture + +## App + +Projects combining all into a working app + +## Api + +Supplies Api that can be wired up in a project to get all the required endpoints + +## Core + +Should provide the OIDC functionality with little external dependencies. + +## Infra.* + +Multiple sub projectes. +Provides implementations of the services that core requires. But these can be swapped for custom implementations. + +## Infra.EfCore + +Implementation of services that use an EF core context. + + +