diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 350149b..31c1e9d 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -122,17 +122,16 @@ public class RealmApisTests : IClassFixture using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - Key key = new() - { - Id = Guid.NewGuid(), - CreatedAt = DateTime.UtcNow, - }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + RealmKey realmKey = new( + Guid.NewGuid(), + "RSA", + encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), + DateTime.UtcNow); await ScopedContextAsync(async db => { - db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ key ]}); + db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]}); await db.SaveChangesAsync(TestContext.Current.CancellationToken); }); @@ -145,7 +144,7 @@ public class RealmApisTests : IClassFixture JsonObject? payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(payload); - JsonObjectAssert.Equal(key.Id.ToString(), payload, "keys[0].kid"); + JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid"); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n"); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e"); } diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs index 6c57971..9cd88e0 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs @@ -20,20 +20,20 @@ public class KeyMapperTests RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - Key key = new() + RealmKey realmKey = new() { Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), CreatedAt = DateTime.UtcNow, Priority = 10, }; - key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); + realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); // Act KeyMapper mapper = new(_encryptionService); - JsonWebKey jwk = mapper.KeyToJsonWebKey(key); + JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey); Assert.Equal("RSA", jwk.KeyType); - Assert.Equal(key.Id.ToString(), jwk.KeyId); + 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)); diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs new file mode 100644 index 0000000..86d965f --- /dev/null +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -0,0 +1,55 @@ +using FluentResults; +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; + +namespace IdentityShroud.Api; + + +public record ClientCreateReponse(int Id, string ClientId); + +/// +/// The part of the api below realms/{slug}/clients +/// +public static class ClientApi +{ + public const string ClientGetRouteName = "ClientGet"; + + public static void MapEndpoints(this IEndpointRouteBuilder erp) + { + erp.MapPost("", ClientCreate) + .Validate() + .WithName("ClientCreate") + .Produces(StatusCodes.Status201Created); + erp.MapGet("{clientId}", ClientGet) + .WithName(ClientGetRouteName); + } + + private static Task ClientGet(HttpContext context) + { + throw new NotImplementedException(); + } + + private static async Task, InternalServerError>> + ClientCreate( + ClientCreateRequest request, + [FromServices] IClientService service, + HttpContext context, + CancellationToken cancellationToken) + { + 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!?]) + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs index e46107f..ea4d7d5 100644 --- a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs +++ b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs @@ -1,3 +1,6 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; using System.Text.Json.Serialization; namespace IdentityShroud.Core.Messages; @@ -25,17 +28,46 @@ public class JsonWebKey // RSA Public Key Components [JsonPropertyName("n")] - public required string Modulus { get; set; } + public string? Modulus { get; set; } [JsonPropertyName("e")] - public required string Exponent { get; set; } + public string? Exponent { get; set; } + + // ECdsa + public string? Curve { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? X { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? Y { get; set; } // Optional fields - [JsonPropertyName("x5c")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? X509CertificateChain { get; set; } - - [JsonPropertyName("x5t")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? X509CertificateThumbprint { get; set; } + // [JsonPropertyName("x5c")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public List? X509CertificateChain { get; set; } + // + // [JsonPropertyName("x5t")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public string? X509CertificateThumbprint { get; set; } +} + +public class Base64UrlConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value + if (reader.HasValueSequence) + { + var valueSequence = reader.ValueSequence.ToArray(); + return Base64Url.DecodeFromUtf8(valueSequence); + } + return Base64Url.DecodeFromUtf8(reader.ValueSpan); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + int encodedLength = Base64Url.GetEncodedLength(value.Length); + Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; + Base64Url.EncodeToUtf8(value, buffer); + writer.WriteStringValue(buffer); + } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs index 5bc699e..b7efc2b 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs @@ -1,3 +1,4 @@ +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 00f5d7b..94d37e7 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -9,26 +9,44 @@ namespace IdentityShroud.Api.Mappers; public class KeyMapper(IEncryptionService encryptionService) { - public JsonWebKey KeyToJsonWebKey(Key key) + public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey) { - using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService)); - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - return new JsonWebKey() + + JsonWebKey result = new() { - KeyType = rsa.SignatureAlgorithm, - KeyId = key.Id.ToString(), + KeyId = realmKey.Id.ToString(), Use = "sig", - Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!), - Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!), }; + 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) + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { - return new JsonWebKeySet() + JsonWebKeySet wks = new(); + foreach (var k in keys) { - Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(), - }; + var wk = KeyToJsonWebKey(k); + if (wk is {}) + { + wks.Keys.Add(wk); + } + } + return wks; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index d5e439b..47b0549 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,6 +1,6 @@ using FluentResults; using IdentityShroud.Api.Mappers; -using IdentityShroud.Api.Validation; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; @@ -19,18 +19,21 @@ public static class HttpContextExtensions public static class RealmApi { - public static void MapRealmEndpoints(this IEndpointRouteBuilder app) + public static void MapRealmEndpoints(this IEndpointRouteBuilder erp) { - var realmsGroup = app.MapGroup("/realms"); + var realmsGroup = erp.MapGroup("/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{slug}") + var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") .AddEndpointFilter(); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients"); + + var openidConnect = realmSlugGroup.MapGroup("openid-connect"); openidConnect.MapPost("auth", OpenIdConnectAuth); openidConnect.MapPost("token", OpenIdConnectToken); diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings index bd2aa2d..c053b70 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,3 +1,4 @@  True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 66a7554..bb35f98 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,7 +1,6 @@ using FluentValidation; using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Api.Validation; using IdentityShroud.Core; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs index e67f787..e6952be 100644 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public static class EndpointRouteBuilderExtensions { diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs index 8daa0a9..3e3a20a 100644 --- a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs +++ b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs @@ -1,7 +1,7 @@ using FluentValidation; using IdentityShroud.Core.Messages.Realm; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class RealmCreateRequestValidator : AbstractValidator { diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Validation/ValidateFilter.cs index fbebd9d..d621441 100644 --- a/IdentityShroud.Api/Validation/ValidateFilter.cs +++ b/IdentityShroud.Api/Validation/ValidateFilter.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class ValidateFilter : IEndpointFilter where T : class { diff --git a/IdentityShroud.Core.Tests/Model/KeyTests.cs b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs similarity index 69% rename from IdentityShroud.Core.Tests/Model/KeyTests.cs rename to IdentityShroud.Core.Tests/Model/RealmKeyTests.cs index e7e9b45..77969d8 100644 --- a/IdentityShroud.Core.Tests/Model/KeyTests.cs +++ b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs @@ -3,7 +3,7 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Tests.Model; -public class KeyTests +public class RealmKeyTests { [Fact] public void SetNewKey() @@ -16,12 +16,12 @@ public class KeyTests .Encrypt(Arg.Any()) .Returns(x => encryptedPrivateKey); - Key key = new(); - key.SetPrivateKey(encryptionService, privateKey); + RealmKey realmKey = new(); + realmKey.SetPrivateKey(encryptionService, privateKey); // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); + Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); encryptionService.Received(1).Encrypt(privateKey); encryptionService.DidNotReceive().Decrypt(Arg.Any()); @@ -38,12 +38,12 @@ public class KeyTests .Decrypt(encryptedPrivateKey) .Returns(x => privateKey); - Key key = new(); - key.PrivateKeyEncrypted = encryptedPrivateKey; + RealmKey realmKey = new(); + realmKey.PrivateKeyEncrypted = encryptedPrivateKey; // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); + Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); encryptionService.Received(1).Decrypt(encryptedPrivateKey); } diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs new file mode 100644 index 0000000..5c2295b --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -0,0 +1,25 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +//public record CreateClientRequest(Guid RealmId, string ClientId, string? Description); + +public class ClientCreateRequest +{ + public string ClientId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? SignatureAlgorithm { get; set; } + public bool? AllowClientCredentialsFlow { get; set; } +} + + +public interface IClientService +{ + Task> Create( + Guid realmId, + ClientCreateRequest request, + CancellationToken ct = default); + + Task GetByClientId(string clientId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IClock.cs b/IdentityShroud.Core/Contracts/IClock.cs new file mode 100644 index 0000000..4ba7766 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClock.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Contracts; + +public interface IClock +{ + DateTime UtcNow(); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs new file mode 100644 index 0000000..396d765 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs @@ -0,0 +1,8 @@ +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/Services/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs similarity index 81% rename from IdentityShroud.Core/Services/IRealmService.cs rename to IdentityShroud.Core/Contracts/IRealmService.cs index 4ce1da4..9940466 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,7 +1,8 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; -namespace IdentityShroud.Core.Services; +namespace IdentityShroud.Core.Contracts; public interface IRealmService { diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index b476787..cd7a493 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -16,8 +16,9 @@ public class Db( ILoggerFactory? loggerFactory) : DbContext { + public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } + public virtual DbSet Keys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index d412632..43f2f1a 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,11 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; +[Table("client")] +[Index(nameof(ClientId), IsUnique = true)] public class Client { + [Key] public Guid Id { get; set; } - public string Name { get; set; } + public Guid RealmId { get; set; } + [MaxLength(40)] + public required string ClientId { get; set; } + [MaxLength(80)] + public string? Name { get; set; } + [MaxLength(2048)] + public string? Description { get; set; } - public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + [MaxLength(20)] + public string? SignatureAlgorithm { get; set; } + + public bool AllowClientCredentialsFlow { get; set; } = false; + + public required DateTime CreatedAt { get; set; } + + public List Secrets { get; set; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs new file mode 100644 index 0000000..bd57d37 --- /dev/null +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace IdentityShroud.Core.Model; + +[Table("client_secret")] +public class ClientSecret +{ + [Key] + public int Id { get; set; } + public Guid ClientId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? RevokedAt { get; set; } + public required byte[] SecretEncrypted { get; set; } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Key.cs b/IdentityShroud.Core/Model/Key.cs deleted file mode 100644 index ee09d31..0000000 --- a/IdentityShroud.Core/Model/Key.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Core.Model; - - -[Table("key")] -public class Key -{ - private byte[] _privateKeyDecrypted = []; - - public Guid Id { get; set; } - - public DateTime CreatedAt { get; set; } - public DateTime? DeactivatedAt { get; set; } - - /// - /// Key with highest priority will be used. While there is not really a use case for this I know some users - /// are more comfortable replacing keys by using priority then directly deactivating the old key. - /// - public int Priority { get; set; } = 10; - - public byte[] PrivateKeyEncrypted - { - get; - set - { - field = value; - _privateKeyDecrypted = []; - } - } = []; - - public byte[] GetPrivateKey(IEncryptionService encryptionService) - { - if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - return _privateKeyDecrypted; - } - - public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) - { - PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); - _privateKeyDecrypted = privateKey; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 35c76e8..c02fc38 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -20,7 +20,7 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + public List Keys { get; init; } = []; /// /// Can be overriden per client diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs new file mode 100644 index 0000000..14c7c9c --- /dev/null +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace IdentityShroud.Core.Model; + + +[Table("realm_key")] +public record RealmKey(Guid Id, string KeyType, byte[] KeyDataEncrypted, DateTime CreatedAt) +{ + public Guid Id { get; private set; } = Id; + public string KeyType { get; private set; } = KeyType; + public byte[] KeyDataEncrypted { get; private set; } = KeyDataEncrypted; + public DateTime CreatedAt { get; private set; } = CreatedAt; + public DateTime? RevokedAt { get; set; } + + /// + /// Key with highest priority will be used. While there is not really a use case for this I know some users + /// are more comfortable replacing keys by using priority then directly deactivating the old key. + /// + public int Priority { get; set; } = 10; + + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs new file mode 100644 index 0000000..37a8391 --- /dev/null +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Services; + +public class ClientService( + Db db, + IEncryptionService cryptor, + IClock clock) : IClientService +{ + public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) + { + Client client = new() + { + Id = Guid.NewGuid(), + RealmId = realmId, + ClientId = request.ClientId, + Name = request.Name, + Description = request.Description, + SignatureAlgorithm = request.SignatureAlgorithm, + AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false, + CreatedAt = clock.UtcNow(), + }; + + if (client.AllowClientCredentialsFlow) + { + client.Secrets.Add(CreateSecret()); + } + + await db.AddAsync(client, ct); + await db.SaveChangesAsync(ct); + + return client; + } + + public async Task GetByClientId(string clientId, CancellationToken ct = default) + { + return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + } + + private ClientSecret CreateSecret() + { + byte[] secret = RandomNumberGenerator.GetBytes(24); + + return new ClientSecret() + { + CreatedAt = clock.UtcNow(), + SecretEncrypted = cryptor.Encrypt(secret), + }; + + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClockService.cs b/IdentityShroud.Core/Services/ClockService.cs new file mode 100644 index 0000000..26eb3dd --- /dev/null +++ b/IdentityShroud.Core/Services/ClockService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Services; + +public class ClockService : IClock +{ + public DateTime UtcNow() + { + return DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyProvisioningService.cs b/IdentityShroud.Core/Services/KeyProvisioningService.cs new file mode 100644 index 0000000..313e894 --- /dev/null +++ b/IdentityShroud.Core/Services/KeyProvisioningService.cs @@ -0,0 +1,30 @@ +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/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 57c4cf2..206d38e 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -11,7 +11,7 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IEncryptionService encryptionService) : IRealmService + IKeyProvisioningService keyProvisioningService) : IRealmService { public async Task FindBySlug(string slug, CancellationToken ct = default) { @@ -26,7 +26,7 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, - Keys = [ CreateKey() ], + Keys = [ keyProvisioningService.CreateRsaKey() ], }; db.Add(realm); @@ -40,21 +40,8 @@ public class RealmService( { await db.Entry(realm).Collection(r => r.Keys) .Query() - .Where(k => k.DeactivatedAt == null) + .Where(k => k.RevokedAt == null) .LoadAsync(); } - - private Key CreateKey() - { - using RSA rsa = RSA.Create(2048); - - Key key = new() - { - Priority = 10, - }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); - - return key; - } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index a850ec0..26df40e 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -13,11 +13,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -25,7 +27,7 @@ <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState>