diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 31c1e9d..8d08a27 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture var client = _factory.CreateClient(); Guid? inputId = id is null ? (Guid?)null : new Guid(id); - var response = await client.PostAsync("/realms", JsonContent.Create(new + + // act + var response = await client.PostAsync("/api/v1/realms", JsonContent.Create(new { Id = inputId, Slug = slug, @@ -88,16 +90,21 @@ public class RealmApisTests : IClassFixture // act var client = _factory.CreateClient(); - var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", + var response = await client.GetAsync("auth/realms/foo/.well-known/openid-configuration", TestContext.Current.CancellationToken); // verify +#if DEBUG + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); +#endif + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(result); - JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/auth", result, "authorization_endpoint"); - JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer"); - JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint"); - JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/jwks", result, "jwks_uri"); + JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/auth", result, "authorization_endpoint"); + JsonObjectAssert.Equal("http://localhost/auth/realms/foo", result, "issuer"); + JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/token", result, "token_endpoint"); + JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/jwks", result, "jwks_uri"); } [Theory] @@ -137,7 +144,7 @@ public class RealmApisTests : IClassFixture // act var client = _factory.CreateClient(); - var response = await client.GetAsync("/realms/foo/openid-connect/jwks", + var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs index 9cd88e0..767337e 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs @@ -1,41 +1,17 @@ -using System.Security.Cryptography; using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; using IdentityShroud.TestUtils.Substitutes; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Api.Tests.Mappers; -public class KeyMapperTests -{ - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); - - [Fact] - public void Test() - { - // Setup - using RSA rsa = RSA.Create(2048); - - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - RealmKey realmKey = new() - { - Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), - CreatedAt = DateTime.UtcNow, - Priority = 10, - }; - realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); - - // Act - KeyMapper mapper = new(_encryptionService); - JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey); - - Assert.Equal("RSA", jwk.KeyType); - Assert.Equal(realmKey.Id.ToString(), jwk.KeyId); - Assert.Equal("sig", jwk.Use); - Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent)); - Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus)); - } -} \ No newline at end of file +// public class KeyMapperTests +// { +// private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); +// +// [Fact] +// public void Test() +// { +// } +// } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs new file mode 100644 index 0000000..196b15d --- /dev/null +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -0,0 +1,43 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Services; +using IdentityShroud.TestUtils.Substitutes; + +namespace IdentityShroud.Api.Tests.Mappers; + +public class KeyServiceTests +{ + private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + //private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For(); + + [Fact] + public void Test() + { + // Setup + using RSA rsa = RSA.Create(2048); + + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + RealmKey realmKey = new( + new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), + "RSA", + rsa.ExportPkcs8PrivateKey(), + DateTime.UtcNow) + { + Priority = 10, + }; + + // Act + KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService()); + var jwk = sut.CreateJsonWebKey(realmKey); + + 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)); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs index 86d965f..fd3e804 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -20,11 +20,17 @@ public static class ClientApi public static void MapEndpoints(this IEndpointRouteBuilder erp) { - erp.MapPost("", ClientCreate) + RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); + + clientsGroup.MapPost("", ClientCreate) .Validate() .WithName("ClientCreate") .Produces(StatusCodes.Status201Created); - erp.MapGet("{clientId}", ClientGet) + + var clientIdGroup = clientsGroup.MapGroup("{clientId}") + .AddEndpointFilter(); + + clientIdGroup.MapGet("", ClientGet) .WithName(ClientGetRouteName); } @@ -33,7 +39,7 @@ public static class ClientApi throw new NotImplementedException(); } - private static async Task, InternalServerError>> + private static async Task, InternalServerError>> ClientCreate( ClientCreateRequest request, [FromServices] IClientService service, @@ -42,14 +48,22 @@ public static class ClientApi { Realm realm = context.GetValidatedRealm(); Result result = await service.Create(realm.Id, request, cancellationToken); - - // Should i have two set of paths? one for actual REST and one for openid - // openid: auth/realms/{realmSlug}/.well-known/openid-configuration - // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) - // api: api/v1/realms/{realmId}/.... - // api: api/v1/realms/{realmId}/clients/{clientId} - - //return Results.CreatedAtRoute(ClientGetRouteName, [ "realmSlug" = realmId!?]) + + if (result.IsFailed) + { + throw new NotImplementedException(); + } + + Client client = result.Value; + + return TypedResults.CreatedAtRoute( + new ClientCreateReponse(client.Id, client.ClientId), + ClientGetRouteName, + new RouteValueDictionary() + { + ["realmId"] = realm.Id, + ["clientId"] = client.Id, + }); throw new NotImplementedException(); } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..3c47b48 --- /dev/null +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,15 @@ +namespace IdentityShroud.Api; + +public static class EndpointRouteBuilderExtensions +{ + public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class + => builder.AddEndpointFilter>(); + + public static void MapApis(this IEndpointRouteBuilder erp) + { + RealmApi.MapRealmEndpoints(erp); + + OpenIdEndpoints.MapEndpoints(erp); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs new file mode 100644 index 0000000..8030153 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + int id = context.Arguments.OfType().First(); + Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted); + if (client is null) + { + return Results.NotFound(); + } + context.HttpContext.Items["ClientEntity"] = client; + + return await next(context); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs new file mode 100644 index 0000000..97a1bb9 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + Guid id = context.Arguments.OfType().First(); + Realm? realm = await realmService.FindById(id, context.HttpContext.RequestAborted); + if (realm is null) + { + return Results.NotFound(); + } + context.HttpContext.Items["RealmEntity"] = realm; + + return await next(context); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs similarity index 63% rename from IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs rename to IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs index b7efc2b..862b599 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs @@ -10,12 +10,13 @@ namespace IdentityShroud.Api; /// consistently. /// /// -public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter +public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - string slug = context.Arguments.OfType().First(); - Realm? realm = await realmService.FindBySlug(slug); + string realmSlug = context.Arguments.OfType().FirstOrDefault() + ?? throw new InvalidOperationException("Expected argument missing, ensure you include path parameters in your handlers signature even when you don't use them"); + Realm? realm = await realmService.FindBySlug(realmSlug, context.HttpContext.RequestAborted); if (realm is null) { return Results.NotFound(); diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 94d37e7..36bd200 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -7,41 +7,14 @@ using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Api.Mappers; -public class KeyMapper(IEncryptionService encryptionService) +public class KeyMapper(IKeyService keyService) { - public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey) - { - - JsonWebKey result = new() - { - KeyId = realmKey.Id.ToString(), - Use = "sig", - }; - switch (realmKey.KeyType) - { - case "RSA": - using (var rsa = RsaHelper.LoadFromPkcs8(realmKey.GetPrivateKey(encryptionService))) - { - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - result.KeyType = rsa.SignatureAlgorithm; - result.Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!); - result.Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!); - } - break; - - default: - return null; - } - - return result; - } - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { JsonWebKeySet wks = new(); foreach (var k in keys) { - var wk = KeyToJsonWebKey(k); + var wk = keyService.CreateJsonWebKey(k); if (wk is {}) { wks.Keys.Add(wk); diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs new file mode 100644 index 0000000..6565413 --- /dev/null +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -0,0 +1,72 @@ +using IdentityShroud.Api.Mappers; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace IdentityShroud.Api; + +public static class OpenIdEndpoints +{ + // openid: auth/realms/{realmSlug}/.well-known/openid-configuration + // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) + + + public static void MapEndpoints(this IEndpointRouteBuilder erp) + { + var realmsGroup = erp.MapGroup("/auth/realms"); + + var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") + .AddEndpointFilter(); + realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + + var openidConnect = realmSlugGroup.MapGroup("openid-connect"); + openidConnect.MapPost("auth", OpenIdConnectAuth); + openidConnect.MapPost("token", OpenIdConnectToken); + openidConnect.MapGet("jwks", OpenIdConnectJwks); + } + + private static async Task> GetOpenIdConfiguration( + string realmSlug, + [FromServices]IRealmService realmService, + HttpContext context) + { + Realm realm = context.GetValidatedRealm(); + + var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var searchString = $"realms/{realmSlug}"; + int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); + string baseUri = s.Substring(0, index + searchString.Length); + + return TypedResults.Json(new OpenIdConfiguration() + { + AuthorizationEndpoint = baseUri + "/openid-connect/auth", + TokenEndpoint = baseUri + "/openid-connect/token", + Issuer = baseUri, + JwksUri = baseUri + "/openid-connect/jwks", + }, AppJsonSerializerContext.Default.OpenIdConfiguration); + } + + private static async Task, BadRequest>> OpenIdConnectJwks( + string realmSlug, + [FromServices]IRealmService realmService, + [FromServices]KeyMapper keyMapper, + HttpContext context) + { + Realm realm = context.GetValidatedRealm(); + await realmService.LoadActiveKeys(realm); + return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); + } + + private static Task OpenIdConnectToken(HttpContext context) + { + throw new NotImplementedException(); + } + + private static Task OpenIdConnectAuth(HttpContext context) + { + throw new NotImplementedException(); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 47b0549..88a5179 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,7 +1,4 @@ -using FluentResults; -using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; @@ -15,29 +12,28 @@ public static class HttpContextExtensions public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!; } +// api: api/v1/realms/{realmId}/.... +// api: api/v1/realms/{realmId}/clients/{clientId} + public static class RealmApi { - public static void MapRealmEndpoints(this IEndpointRouteBuilder erp) + public static void MapRealmEndpoints(IEndpointRouteBuilder erp) { - var realmsGroup = erp.MapGroup("/realms"); + var realmsGroup = erp.MapGroup("/api/v1/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") - .AddEndpointFilter(); - realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") + .AddEndpointFilter(); - RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients"); + ClientApi.MapEndpoints(realmIdGroup); + - var openidConnect = realmSlugGroup.MapGroup("openid-connect"); - openidConnect.MapPost("auth", OpenIdConnectAuth); - openidConnect.MapPost("token", OpenIdConnectToken); - openidConnect.MapGet("jwks", OpenIdConnectJwks); } private static async Task, InternalServerError>> @@ -50,46 +46,4 @@ public static class RealmApi // TODO make helper to convert failure response to a proper HTTP result. return TypedResults.InternalServerError(); } - - private static async Task, BadRequest>> OpenIdConnectJwks( - string slug, - [FromServices]IRealmService realmService, - [FromServices]KeyMapper keyMapper, - HttpContext context) - { - Realm realm = context.GetValidatedRealm(); - await realmService.LoadActiveKeys(realm); - return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); - } - - private static Task OpenIdConnectToken(HttpContext context) - { - throw new NotImplementedException(); - } - - private static Task OpenIdConnectAuth(HttpContext context) - { - throw new NotImplementedException(); - } - - private static async Task> GetOpenIdConfiguration( - string slug, - [FromServices]IRealmService realmService, - HttpContext context) - { - Realm realm = context.GetValidatedRealm(); - - var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; - var searchString = $"realms/{slug}"; - int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); - string baseUri = s.Substring(0, index + searchString.Length); - - return TypedResults.Json(new OpenIdConfiguration() - { - AuthorizationEndpoint = baseUri + "/openid-connect/auth", - TokenEndpoint = baseUri + "/openid-connect/token", - Issuer = baseUri, - JwksUri = baseUri + "/openid-connect/jwks", - }, AppJsonSerializerContext.Default.OpenIdConfiguration); - } } \ No newline at end of file diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs similarity index 100% rename from IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs rename to IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs similarity index 100% rename from IdentityShroud.Api/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Validation/ValidateFilter.cs diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings index c053b70..c9c4f6a 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,4 +1,5 @@  True True + True True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index bb35f98..0a145c2 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -4,6 +4,7 @@ using IdentityShroud.Api.Mappers; using IdentityShroud.Core; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using Serilog; using Serilog.Formatting.Json; @@ -35,11 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder) // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi services.AddOpenApi(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); services.AddValidatorsFromAssemblyContaining(); @@ -56,7 +61,8 @@ void ConfigureApplication(WebApplication app) app.MapOpenApi(); } app.UseSerilogRequestLogging(); - app.MapRealmEndpoints(); + app.MapApis(); + // app.UseRouting(); // app.MapControllers(); } diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index e6952be..0000000 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace IdentityShroud.Api; - -public static class EndpointRouteBuilderExtensions -{ - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 8af08c1..40c87d5 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -30,4 +30,8 @@ + + + + \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs deleted file mode 100644 index 77969d8..0000000 --- a/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Tests.Model; - -public class RealmKeyTests -{ - [Fact] - public void SetNewKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Encrypt(Arg.Any()) - .Returns(x => encryptedPrivateKey); - - RealmKey realmKey = new(); - realmKey.SetPrivateKey(encryptionService, privateKey); - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); - - encryptionService.Received(1).Encrypt(privateKey); - encryptionService.DidNotReceive().Decrypt(Arg.Any()); - } - - [Fact] - public void GetDecryptedKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Decrypt(encryptedPrivateKey) - .Returns(x => privateKey); - - RealmKey realmKey = new(); - realmKey.PrivateKeyEncrypted = encryptedPrivateKey; - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); - - encryptionService.Received(1).Decrypt(encryptedPrivateKey); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 5b830ea..d6aec76 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,4 +1,6 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -9,7 +11,7 @@ namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IKeyService _keyService = Substitute.For(); public RealmServiceTests(DbFixture dbFixture) { @@ -34,25 +36,37 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - using Db db = _dbFixture.CreateDbContext(); - RealmService sut = new(db, _encryptionService); - // Act - - var response = await sut.Create( - new(realmId, "slug", "New realm"), - TestContext.Current.CancellationToken); - - // Verify - RealmCreateResponse val = ResultAssert.Success(response); - if (realmId.HasValue) - Assert.Equal(realmId, val.Id); - else - Assert.NotEqual(Guid.Empty, val.Id); - - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); - - // TODO verify data has been stored! + RealmCreateResponse? val; + await using (var db = _dbFixture.CreateDbContext()) + { + _keyService.CreateKey(Arg.Any()) + .Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow)); + // Act + RealmService sut = new(db, _keyService); + var response = await sut.Create( + new(realmId, "slug", "New realm"), + TestContext.Current.CancellationToken); + + // Verify + val = ResultAssert.Success(response); + if (realmId.HasValue) + Assert.Equal(realmId, val.Id); + else + Assert.NotEqual(Guid.Empty, val.Id); + + Assert.Equal("slug", val.Slug); + Assert.Equal("New realm", val.Name); + + _keyService.Received().CreateKey(Arg.Any()); + } + + await using (var db = _dbFixture.CreateDbContext()) + { + var dbRecord = await db.Realms + .Include(e => e.Keys) + .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); + Assert.Equal("TST", dbRecord.Keys[0].KeyType); + } } [Theory] @@ -60,7 +74,7 @@ public class RealmServiceTests : IClassFixture [InlineData("foo", "Foo")] public async Task FindBySlug(string slug, string? name) { - using (var setupContext = _dbFixture.CreateDbContext()) + await using (var setupContext = _dbFixture.CreateDbContext()) { setupContext.Realms.Add(new() { @@ -76,8 +90,8 @@ public class RealmServiceTests : IClassFixture await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); } - using Db actContext = _dbFixture.CreateDbContext(); - RealmService sut = new(actContext, _encryptionService); + await using var actContext = _dbFixture.CreateDbContext(); + RealmService sut = new(actContext, _keyService); // Act var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs index 5c2295b..15c0eba 100644 --- a/IdentityShroud.Core/Contracts/IClientService.cs +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -6,7 +6,7 @@ namespace IdentityShroud.Core.Contracts; public class ClientCreateRequest { - public string ClientId { get; set; } + public required string ClientId { get; set; } public string? Name { get; set; } public string? Description { get; set; } public string? SignatureAlgorithm { get; set; } @@ -22,4 +22,5 @@ public interface IClientService CancellationToken ct = default); Task GetByClientId(string clientId, CancellationToken ct = default); + Task FindById(int id, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs index f85487d..a737732 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Contracts; public interface IEncryptionService { byte[] Encrypt(byte[] plain); - byte[] Decrypt(byte[] cipher); + byte[] Decrypt(ReadOnlyMemory cipher); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs deleted file mode 100644 index 396d765..0000000 --- a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Contracts; - -public interface IKeyProvisioningService -{ - RealmKey CreateRsaKey(int keySize = 2048); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs new file mode 100644 index 0000000..4f6b5f7 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Contracts; + +public interface IKeyService +{ + RealmKey CreateKey(KeyPolicy policy); + + JsonWebKey? CreateJsonWebKey(RealmKey realmKey); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index 9940466..b740aa5 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -6,6 +6,7 @@ namespace IdentityShroud.Core.Contracts; public interface IRealmService { + Task FindById(Guid id, CancellationToken ct = default); Task FindBySlug(string slug, CancellationToken ct = default); Task> Create(RealmCreateRequest request, CancellationToken ct = default); diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKey.cs rename to IdentityShroud.Core/DTO/JsonWebKey.cs diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs b/IdentityShroud.Core/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs rename to IdentityShroud.Core/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index a87c996..d9d6809 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 43f2f1a..a8c9e29 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -10,7 +10,7 @@ namespace IdentityShroud.Core.Model; public class Client { [Key] - public Guid Id { get; set; } + public int Id { get; set; } public Guid RealmId { get; set; } [MaxLength(40)] public required string ClientId { get; set; } diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs index 62abf6a..bfa5809 100644 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ b/IdentityShroud.Core/Security/AesGcmHelper.cs @@ -31,9 +31,8 @@ public static class AesGcmHelper // • payload – byte[] containing nonce‖ciphertext‖tag // • returns – the original plaintext bytes // -------------------------------------------------------------------- - public static byte[] DecryptAesGcm(byte[] payload, byte[] key) + public static byte[] DecryptAesGcm(ReadOnlyMemory payload, byte[] key) { - if (payload == null) throw new ArgumentNullException(nameof(payload)); if (key == null) throw new ArgumentNullException(nameof(key)); if (key.Length != 32) // 256‑bit key throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); @@ -49,9 +48,9 @@ public static class AesGcmHelper if (payload.Length < nonceSize + tagSize) throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - ReadOnlySpan nonce = new(payload, 0, nonceSize); - ReadOnlySpan ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize); - ReadOnlySpan tag = new(payload, payload.Length - tagSize, tagSize); + ReadOnlySpan nonce = payload.Span[..nonceSize]; + ReadOnlySpan ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize); + ReadOnlySpan tag = payload.Span.Slice(payload.Length - tagSize, tagSize); byte[] plaintext = new byte[ciphertext.Length]; diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs new file mode 100644 index 0000000..ec095b5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Security.Keys; + +public abstract class KeyPolicy +{ + public abstract string KeyType { get; } +} + + +public interface IKeyProvider +{ + byte[] CreateKey(KeyPolicy policy); + + void SetJwkParameters(byte[] key, JsonWebKey jwk); +} + + + diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs new file mode 100644 index 0000000..485e6e5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -0,0 +1,7 @@ +namespace IdentityShroud.Core.Security.Keys; + + +public interface IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs new file mode 100644 index 0000000..a1c3472 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -0,0 +1,17 @@ +using IdentityShroud.Core.Security.Keys.Rsa; + +namespace IdentityShroud.Core.Security.Keys; + +public class KeyProviderFactory : IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType) + { + switch (keyType) + { + case "RSA": + return new RsaProvider(); + default: + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs new file mode 100644 index 0000000..a5bcee8 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -0,0 +1,37 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Security.Keys.Rsa; + +public class RsaKeyPolicy : KeyPolicy +{ + public override string KeyType => "RSA"; + public int KeySize { get; } = 2048; +} + +public class RsaProvider : IKeyProvider +{ + public byte[] CreateKey(KeyPolicy policy) + { + if (policy is RsaKeyPolicy p) + { + using var rsa = RSA.Create(p.KeySize); + return rsa.ExportPkcs8PrivateKey(); + } + + throw new ArgumentException("Incorrect policy type", nameof(policy)); + } + + public void SetJwkParameters(byte[] key, JsonWebKey jwk) + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(key, out _); + var parameters = rsa.ExportParameters(includePrivateParameters: false); + + jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent); + jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 37a8391..2e556d4 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -14,7 +14,6 @@ public class ClientService( { Client client = new() { - Id = Guid.NewGuid(), RealmId = realmId, ClientId = request.ClientId, Name = request.Name, @@ -40,6 +39,11 @@ public class ClientService( return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); } + public async Task FindById(int id, CancellationToken ct = default) + { + return await db.Clients.FirstOrDefaultAsync(c => c.Id == id, ct); + } + private ClientSecret CreateSecret() { byte[] secret = RandomNumberGenerator.GetBytes(24); diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index 24cdd18..a4455e0 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -20,7 +20,7 @@ public class EncryptionService : IEncryptionService return AesGcmHelper.EncryptAesGcm(plain, encryptionKey); } - public byte[] Decrypt(byte[] cipher) + public byte[] Decrypt(ReadOnlyMemory cipher) { return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey); } diff --git a/IdentityShroud.Core/Services/KeyProvisioningService.cs b/IdentityShroud.Core/Services/KeyProvisioningService.cs deleted file mode 100644 index 313e894..0000000 --- a/IdentityShroud.Core/Services/KeyProvisioningService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Services; - -public class KeyProvisioningService( - IEncryptionService encryptionService, - IClock clock) : IKeyProvisioningService -{ - public RealmKey CreateRsaKey(int keySize = 2048) - { - using var rsa = RSA.Create(keySize); - return CreateKey("RSA", rsa.ExportPkcs8PrivateKey()); - } - - private RealmKey CreateKey(string keyType, byte[] keyData) => - new RealmKey( - Guid.NewGuid(), - keyType, - encryptionService.Encrypt(keyData), - clock.UtcNow()); - - // public byte[] GetPrivateKey(IEncryptionService encryptionService) - // { - // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - // return _privateKeyDecrypted; - // } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs new file mode 100644 index 0000000..440dff9 --- /dev/null +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Services; + +public class KeyService( + IEncryptionService cryptor, + IKeyProviderFactory keyProviderFactory, + IClock clock) : IKeyService +{ + public RealmKey CreateKey(KeyPolicy policy) + { + IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType); + var plainKey = provider.CreateKey(policy); + + return CreateKey(policy.KeyType, plainKey); + } + + public JsonWebKey? CreateJsonWebKey(RealmKey realmKey) + { + JsonWebKey jwk = new() + { + KeyId = realmKey.Id.ToString(), + KeyType = realmKey.KeyType, + Use = "sig", + }; + + IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType); + provider.SetJwkParameters( + cryptor.Decrypt(realmKey.KeyDataEncrypted), + jwk); + + return jwk; + } + + private RealmKey CreateKey(string keyType, byte[] plainKey) => + new RealmKey( + Guid.NewGuid(), + keyType, + cryptor.Encrypt(plainKey), + clock.UtcNow()); + + // public byte[] GetPrivateKey(IEncryptionService encryptionService) + // { + // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + // return _privateKeyDecrypted; + // } +} diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 206d38e..5385658 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -3,6 +3,8 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Security.Keys.Rsa; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Services; @@ -11,8 +13,14 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IKeyProvisioningService keyProvisioningService) : IRealmService + IKeyService keyService) : IRealmService { + public async Task FindById(Guid id, CancellationToken ct = default) + { + return await db.Realms + .SingleOrDefaultAsync(r => r.Id == id, ct); + } + public async Task FindBySlug(string slug, CancellationToken ct = default) { return await db.Realms @@ -26,8 +34,9 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, - Keys = [ keyProvisioningService.CreateRsaKey() ], }; + + realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); db.Add(realm); await db.SaveChangesAsync(ct); @@ -36,6 +45,14 @@ public class RealmService( realm.Id, realm.Slug, realm.Name); } + /// + /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. + /// + /// + /// + private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy(); + + public async Task LoadActiveKeys(Realm realm) { await db.Entry(realm).Collection(r => r.Keys) diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index bb26ee9..5a81240 100644 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -11,8 +11,8 @@ public static class EncryptionServiceSubstitute .Encrypt(Arg.Any()) .Returns(x => x.ArgAt(0)); encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0)); + .Decrypt(Arg.Any>()) + .Returns(x => x.ArgAt>(0).ToArray()); return encryptionService; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 26df40e..9992676 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -5,31 +5,32 @@ 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" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> + + + + \ No newline at end of file