5-improve-encrypted-storage #6

Merged
eelke merged 17 commits from 5-improve-encrypted-storage into main 2026-02-27 17:57:44 +00:00
28 changed files with 365 additions and 121 deletions
Showing only changes of commit eb872a4f44 - Show all commits

View file

@ -123,16 +123,15 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
using var rsa = RSA.Create(2048); using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new() RealmKey realmKey = new(
{ Guid.NewGuid(),
Id = Guid.NewGuid(), "RSA",
CreatedAt = DateTime.UtcNow, encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
}; DateTime.UtcNow);
key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
await ScopedContextAsync(async db => 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); await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}); });
@ -145,7 +144,7 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken); JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload); 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.Modulus!), payload, "keys[0].n");
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e"); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
} }

View file

@ -20,20 +20,20 @@ public class KeyMapperTests
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new() RealmKey realmKey = new()
{ {
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Priority = 10, Priority = 10,
}; };
key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey());
// Act // Act
KeyMapper mapper = new(_encryptionService); KeyMapper mapper = new(_encryptionService);
JsonWebKey jwk = mapper.KeyToJsonWebKey(key); JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey);
Assert.Equal("RSA", jwk.KeyType); 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("sig", jwk.Use);
Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent)); Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent));
Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus)); Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus));

View file

@ -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);
/// <summary>
/// The part of the api below realms/{slug}/clients
/// </summary>
public static class ClientApi
{
public const string ClientGetRouteName = "ClientGet";
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
erp.MapPost("", ClientCreate)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
erp.MapGet("{clientId}", ClientGet)
.WithName(ClientGetRouteName);
}
private static Task ClientGet(HttpContext context)
{
throw new NotImplementedException();
}
private static async Task<Results<Created<ClientCreateReponse>, InternalServerError>>
ClientCreate(
ClientCreateRequest request,
[FromServices] IClientService service,
HttpContext context,
CancellationToken cancellationToken)
{
Realm realm = context.GetValidatedRealm();
Result<Client> 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();
}
}

View file

@ -1,3 +1,6 @@
using System.Buffers;
using System.Buffers.Text;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages; namespace IdentityShroud.Core.Messages;
@ -25,17 +28,46 @@ public class JsonWebKey
// RSA Public Key Components // RSA Public Key Components
[JsonPropertyName("n")] [JsonPropertyName("n")]
public required string Modulus { get; set; } public string? Modulus { get; set; }
[JsonPropertyName("e")] [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 // Optional fields
[JsonPropertyName("x5c")] // [JsonPropertyName("x5c")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? X509CertificateChain { get; set; } // public List<string>? X509CertificateChain { get; set; }
//
[JsonPropertyName("x5t")] // [JsonPropertyName("x5t")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? X509CertificateThumbprint { get; set; } // public string? X509CertificateThumbprint { get; set; }
}
public class Base64UrlConverter : JsonConverter<byte[]>
{
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<byte> buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength];
Base64Url.EncodeToUtf8(value, buffer);
writer.WriteStringValue(buffer);
}
} }

View file

@ -1,3 +1,4 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;

View file

@ -9,26 +9,44 @@ namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IEncryptionService encryptionService) 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 = realmKey.Id.ToString(),
KeyId = key.Id.ToString(),
Use = "sig", 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;
} }
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<Key> keys) return result;
}
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> 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;
} }
} }

View file

@ -1,6 +1,6 @@
using FluentResults; using FluentResults;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
@ -19,18 +19,21 @@ public static class HttpContextExtensions
public static class RealmApi 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) realmsGroup.MapPost("", RealmCreate)
.Validate<RealmCreateRequest>() .Validate<RealmCreateRequest>()
.WithName("Create Realm") .WithName("Create Realm")
.Produces(StatusCodes.Status201Created); .Produces(StatusCodes.Status201Created);
var realmSlugGroup = realmsGroup.MapGroup("{slug}") var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}")
.AddEndpointFilter<SlugValidationFilter>(); .AddEndpointFilter<SlugValidationFilter>();
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients");
var openidConnect = realmSlugGroup.MapGroup("openid-connect"); var openidConnect = realmSlugGroup.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth); openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken); openidConnect.MapPost("token", OpenIdConnectToken);

View file

@ -1,3 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cdto/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cdto/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validation/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -1,7 +1,6 @@
using FluentValidation; using FluentValidation;
using IdentityShroud.Api; using IdentityShroud.Api;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core; using IdentityShroud.Core;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;

View file

@ -1,4 +1,4 @@
namespace IdentityShroud.Api.Validation; namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions public static class EndpointRouteBuilderExtensions
{ {

View file

@ -1,7 +1,7 @@
using FluentValidation; using FluentValidation;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
namespace IdentityShroud.Api.Validation; namespace IdentityShroud.Api;
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest> public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
{ {

View file

@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace IdentityShroud.Api.Validation; namespace IdentityShroud.Api;
public class ValidateFilter<T> : IEndpointFilter where T : class public class ValidateFilter<T> : IEndpointFilter where T : class
{ {

View file

@ -3,7 +3,7 @@ using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Tests.Model; namespace IdentityShroud.Core.Tests.Model;
public class KeyTests public class RealmKeyTests
{ {
[Fact] [Fact]
public void SetNewKey() public void SetNewKey()
@ -16,12 +16,12 @@ public class KeyTests
.Encrypt(Arg.Any<byte[]>()) .Encrypt(Arg.Any<byte[]>())
.Returns(x => encryptedPrivateKey); .Returns(x => encryptedPrivateKey);
Key key = new(); RealmKey realmKey = new();
key.SetPrivateKey(encryptionService, privateKey); realmKey.SetPrivateKey(encryptionService, privateKey);
// should be able to return original without calling decrypt // should be able to return original without calling decrypt
Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted);
encryptionService.Received(1).Encrypt(privateKey); encryptionService.Received(1).Encrypt(privateKey);
encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>()); encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>());
@ -38,12 +38,12 @@ public class KeyTests
.Decrypt(encryptedPrivateKey) .Decrypt(encryptedPrivateKey)
.Returns(x => privateKey); .Returns(x => privateKey);
Key key = new(); RealmKey realmKey = new();
key.PrivateKeyEncrypted = encryptedPrivateKey; realmKey.PrivateKeyEncrypted = encryptedPrivateKey;
// should be able to return original without calling decrypt // should be able to return original without calling decrypt
Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted);
encryptionService.Received(1).Decrypt(encryptedPrivateKey); encryptionService.Received(1).Decrypt(encryptedPrivateKey);
} }

View file

@ -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<Result<Client>> Create(
Guid realmId,
ClientCreateRequest request,
CancellationToken ct = default);
Task<Client?> GetByClientId(string clientId, CancellationToken ct = default);
}

View file

@ -0,0 +1,6 @@
namespace IdentityShroud.Core.Contracts;
public interface IClock
{
DateTime UtcNow();
}

View file

@ -0,0 +1,8 @@
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Contracts;
public interface IKeyProvisioningService
{
RealmKey CreateRsaKey(int keySize = 2048);
}

View file

@ -1,7 +1,8 @@
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Contracts;
public interface IRealmService public interface IRealmService
{ {

View file

@ -16,8 +16,9 @@ public class Db(
ILoggerFactory? loggerFactory) ILoggerFactory? loggerFactory)
: DbContext : DbContext
{ {
public virtual DbSet<Client> Clients { get; set; }
public virtual DbSet<Realm> Realms { get; set; } public virtual DbSet<Realm> Realms { get; set; }
public virtual DbSet<Key> Keys { get; set; } public virtual DbSet<RealmKey> Keys { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View file

@ -1,11 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
[Table("client")]
[Index(nameof(ClientId), IsUnique = true)]
public class Client public class Client
{ {
[Key]
public Guid Id { get; set; } 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<ClientSecret> Secrets { get; set; } = [];
} }

View file

@ -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; }
}

View file

@ -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; }
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -20,7 +20,7 @@ public class Realm
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public List<Client> Clients { get; init; } = []; public List<Client> Clients { get; init; } = [];
public List<Key> Keys { get; init; } = []; public List<RealmKey> Keys { get; init; } = [];
/// <summary> /// <summary>
/// Can be overriden per client /// Can be overriden per client

View file

@ -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; }
/// <summary>
/// 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.
/// </summary>
public int Priority { get; set; } = 10;
}

View file

@ -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<Result<Client>> 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<Client?> 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),
};
}
}

View file

@ -0,0 +1,11 @@
using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Core.Services;
public class ClockService : IClock
{
public DateTime UtcNow()
{
return DateTime.UtcNow;
}
}

View file

@ -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;
// }
}

View file

@ -11,7 +11,7 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService( public class RealmService(
Db db, Db db,
IEncryptionService encryptionService) : IRealmService IKeyProvisioningService keyProvisioningService) : IRealmService
{ {
public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default) public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{ {
@ -26,7 +26,7 @@ public class RealmService(
Id = request.Id ?? Guid.CreateVersion7(), Id = request.Id ?? Guid.CreateVersion7(),
Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name),
Name = request.Name, Name = request.Name,
Keys = [ CreateKey() ], Keys = [ keyProvisioningService.CreateRsaKey() ],
}; };
db.Add(realm); db.Add(realm);
@ -40,21 +40,8 @@ public class RealmService(
{ {
await db.Entry(realm).Collection(r => r.Keys) await db.Entry(realm).Collection(r => r.Keys)
.Query() .Query()
.Where(k => k.DeactivatedAt == null) .Where(k => k.RevokedAt == null)
.LoadAsync(); .LoadAsync();
} }
private Key CreateKey()
{
using RSA rsa = RSA.Create(2048);
Key key = new()
{
Priority = 10,
};
key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
return key;
}
} }

View file

@ -13,11 +13,13 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlContainer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc82112acf224de1d157da0309437b227be6c1ef877865c23872f49eaf9d73c_003FPostgreSqlContainer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlContainer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc82112acf224de1d157da0309437b227be6c1ef877865c23872f49eaf9d73c_003FPostgreSqlContainer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResultsOfT_002EGenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fff2e2c5ca93c7786ef8425ca6caf751702328924211687ce72e74fd1265e8_003FResultsOfT_002EGenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResultsOfT_002EGenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fff2e2c5ca93c7786ef8425ca6caf751702328924211687ce72e74fd1265e8_003FResultsOfT_002EGenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARouteGroupBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd42b8f8feda3bfb3dc17f133a52ce45931ed5066c46a4d834c8ed46e0a6_003FRouteGroupBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebEncoders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce6b69dd397f614758bc5821136ec8af3fa22563dd657769e231f51be1fbbc_003FWebEncoders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String> <s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=6e5d049f_002D5af8_002D43d4_002D878d_002D591b09b1e74a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=6e5d049f_002D5af8_002D43d4_002D878d_002D591b09b1e74a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
@ -25,7 +27,7 @@
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b6b17914_002D7f7b_002D403e_002Db1eb_002D2c847c515018/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b6b17914_002D7f7b_002D403e_002Db1eb_002D2c847c515018/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>