From 8782ef39c680d19cf166ad31fd218b113ca7e283 Mon Sep 17 00:00:00 2001 From: eelke Date: Mon, 16 Mar 2026 19:15:04 +0100 Subject: [PATCH] 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 @@ + + + + + + + +