diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..33a3ce8
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,2 @@
+[*.cs]
+resharper_naming_rules.abbreviations = QL, DB
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..653fdef
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,36 @@
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs
index db984f1..cf1eb9f 100644
--- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs
+++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs
@@ -1,16 +1,24 @@
using System.Net;
using System.Net.Http.Json;
-using IdentityShroud.Core;
+using System.Text;
+using System.Text.Json;
+using FluentResults;
+using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Tests;
using IdentityShroud.Core.Tests.Fixtures;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
namespace IdentityShroud.Api.Tests.Apis;
public class ClientApiTests : IClassFixture
{
+ private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
+
private readonly ApplicationFactory _factory;
public ClientApiTests(ApplicationFactory factory)
@@ -32,7 +40,7 @@ public class ClientApiTests : IClassFixture
public async Task Create_Validation(string? clientId, bool succeeds, string fieldName)
{
// setup
- Realm realm = await CreateRealmAsync("test-realm", "Test Realm");
+ var realm = await CreateRealmAsync("test-realm", "Test Realm");
var client = _factory.CreateClient();
@@ -64,32 +72,56 @@ public class ClientApiTests : IClassFixture
[Fact]
public async Task Create_Success_ReturnsCreatedWithLocation()
{
- // setup
- Realm realm = await CreateRealmAsync("create-realm", "Create Realm");
-
- var client = _factory.CreateClient();
-
// act
- var response = await client.PostAsync(
- $"/api/v1/realms/{realm.Id}/clients",
- JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }),
- TestContext.Current.CancellationToken);
-
-#if DEBUG
- string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
-#endif
+ var body = await DoCreateRequest("""
+ {
+ "clientId": "new-client",
+ "name": "New Client"
+ }
+ """);
// verify
- Assert.Equal(HttpStatusCode.Created, response.StatusCode);
-
- var body = await response.Content.ReadFromJsonAsync(
- TestContext.Current.CancellationToken);
-
Assert.NotNull(body);
Assert.Equal("new-client", body.ClientId);
Assert.True(body.Id > 0);
}
+ [Fact]
+ public async Task Create_Success_CreatesSecret()
+ {
+ // act
+ var body = await DoCreateRequest("""
+ {
+ "clientId": "new-client",
+ "name": "New Client",
+ "confidential": true,
+ "generateSecret": true
+ }
+ """);
+
+ // verify
+ body.ShouldNotBeNull();
+ body.Secret.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ private async Task DoCreateRequest(
+ string request)
+ {
+ var realm = await CreateRealmAsync("create-realm", "Create Realm");
+
+ var client = _factory.CreateClient();
+ var response = await client.PostAsync(
+ $"/api/v1/realms/{realm.Id}/clients",
+ //JsonContent.Create(request),
+ new StringContent(request, Encoding.UTF8, "application/json"),
+ TestContext.Current.CancellationToken);
+
+ string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.True(HttpStatusCode.Created == response.StatusCode, contents);
+
+ return JsonSerializer.Deserialize(contents, _jsonOptions);
+ }
+
[Fact]
public async Task Create_UnknownRealm_ReturnsNotFound()
{
@@ -107,7 +139,7 @@ public class ClientApiTests : IClassFixture
public async Task Get_Success()
{
// setup
- Realm realm = await CreateRealmAsync("get-realm", "Get Realm");
+ var realm = await CreateRealmAsync("get-realm", "Get Realm");
Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client");
var httpClient = _factory.CreateClient();
@@ -138,7 +170,7 @@ public class ClientApiTests : IClassFixture
public async Task Get_UnknownClient_ReturnsNotFound()
{
// setup
- Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
+ var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
var httpClient = _factory.CreateClient();
@@ -154,11 +186,11 @@ public class ClientApiTests : IClassFixture
private async Task CreateRealmAsync(string slug, string name)
{
using var scope = _factory.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
- var realm = new Realm { Slug = slug, Name = name };
- db.Realms.Add(realm);
- await db.SaveChangesAsync(TestContext.Current.CancellationToken);
- return realm;
+ var realmService = scope.ServiceProvider.GetRequiredService();
+ Result result = await realmService.Create(
+ new(null, slug, name),
+ TestContext.Current.CancellationToken);
+ return ResultAssert.Success(result);
}
private async Task CreateClientAsync(Realm realm, string clientId, string? name = null)
diff --git a/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs
new file mode 100644
index 0000000..93d241a
--- /dev/null
+++ b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs
@@ -0,0 +1,123 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using IdentityShroud.Api.Apis;
+using IdentityShroud.Core.EFCore;
+using IdentityShroud.Core.Tests.Fixtures;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace IdentityShroud.Api.Tests.Apis;
+
+public class OpenIdApiTests : IClassFixture
+{
+ private readonly ApplicationFactory _factory;
+
+ public OpenIdApiTests(ApplicationFactory factory)
+ {
+ _factory = factory;
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ if (!db.Database.EnsureCreated())
+ {
+ db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ClientCredentialsFlow(bool useAuthenticationHeader)
+ {
+ var client = _factory.CreateClient();
+
+ var createRealmResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
+ {
+ Slug = "foo",
+ Name = "Test'",
+ }),
+ TestContext.Current.CancellationToken);
+
+ createRealmResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
+
+ var realm = await createRealmResponse.Content.ReadFromJsonAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ realm.ShouldNotBeNull();
+ realm.Id.ShouldNotBe(Guid.Empty);
+
+ var createClientResponse = await client.PostAsync(
+ $"/api/v1/realms/{realm.Id}/clients",
+ JsonContent.Create(new
+ {
+ ClientId = "myclient",
+ Name = "New Client",
+ Confidential = true,
+ AllowClientCredentialsFlow = true,
+ GenerateSecret = true,
+ }),
+ TestContext.Current.CancellationToken);
+
+ createClientResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
+
+ // Act
+ const string clientId = "myclient";
+
+ var data = new[]
+ {
+ new KeyValuePair("client_id", clientId),
+ new KeyValuePair("client_secret", "secret"),
+ new KeyValuePair("response_type", "token"),
+ new KeyValuePair("grant_type", "client_credentials"),
+ };
+
+ if (useAuthenticationHeader)
+ {
+ // client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("basic",
+ // Convert.ToBase64String($"{clientId}:{clientSecret}"))
+ }
+
+ var content = new FormUrlEncodedContent(data);
+ var response = await client.PostAsync(
+ "/auth/realms/foo/openid-connect/token",
+ content,
+ TestContext.Current.CancellationToken);
+
+ // Verify
+ // var responseJson = await response.Content.ReadAsStringAsync(
+ // TestContext.Current.CancellationToken);
+ // Console.WriteLine($"Response: {responseJson}");
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ // Cache-Control: no-store
+ response.Headers.CacheControl.ShouldNotBeNull()
+ .NoStore.ShouldBe(true);
+ // Pragma: no-cache
+ response.Headers.Pragma.ShouldNotBeNull()
+ .ShouldContain(new NameValueHeaderValue("no-cache"));
+
+ var payload = await response.Content.ReadFromJsonAsync();
+ payload.ShouldNotBeNull();
+ Assert.Multiple(
+ () => payload.AccessToken.ShouldNotBeNull(),
+ () => payload.TokenType.ShouldBe("bearer"),
+ () => payload.ExpiresIn.ShouldBe(3600));
+
+ // - refresh_token OPTIONAL
+ // - scope OPTIONAL when identical to request otherwise REQUIRED
+ }
+
+ internal class TokenResponse
+ {
+ [JsonPropertyName("access_token")]
+ public string? AccessToken { get; set; }
+ [JsonPropertyName("token_type")]
+ public string? TokenType { get; set; }
+ [JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; set; }
+ }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs
index ecc46c0..7d3d49e 100644
--- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs
+++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs
@@ -1,14 +1,13 @@
+using System.Buffers.Text;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
-using IdentityShroud.Core;
-using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -124,28 +123,16 @@ public class RealmApisTests : IClassFixture
[Fact]
public async Task GetJwks()
{
- // setup
- IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService();
-
- using var rsa = RSA.Create(2048);
- RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
-
- RealmKey realmKey = new()
- {
- Id = Guid.NewGuid(),
- KeyType = "RSA",
- Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
- CreatedAt = DateTime.UtcNow,
- };
-
- await ScopedContextAsync(async db =>
- {
- db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
- await db.SaveChangesAsync(TestContext.Current.CancellationToken);
- });
-
- // act
var client = _factory.CreateClient();
+ var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
+ {
+ Slug = "foo",
+ Name = "Test'",
+ }),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ // act
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken);
@@ -153,9 +140,16 @@ public class RealmApisTests : IClassFixture
JsonObject? payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
- JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
- JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
- JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
+ string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString();
+ Assert.NotNull(kid);
+ Assert.True(kid.Length >= 16);
+
+ //if (JsonObjectAssert.NavigateToPath(payload, "keys[0].kty")?.AsValue().ToString() == "RSA")
+
+ JsonObjectAssert.Equal("RSA", payload, "keys[0].kty");
+ string? n = payload["keys"]?[0]?["n"]?.AsValue().ToString();
+ string? e = payload["keys"]?[0]?["e"]?.AsValue().ToString();
+ AssertRsaParams(n, e);
}
private async Task ScopedContextAsync(
@@ -166,4 +160,22 @@ public class RealmApisTests : IClassFixture
var db = scope.ServiceProvider.GetRequiredService();
await action(db);
}
+
+ private static void AssertRsaParams(string? n, string? e)
+ {
+ Assert.NotNull(n);
+ Assert.NotNull(e);
+
+ var rsa = RSA.Create();
+ rsa.ImportParameters(new RSAParameters
+ {
+ Modulus = Base64Url.DecodeFromChars(n),
+ Exponent = Base64Url.DecodeFromChars(e)
+ });
+
+ // If n and e are complete nonsense, this will throw
+ var encrypted = rsa.Encrypt(new byte[] { 1, 2, 3 }, RSAEncryptionPadding.OaepSHA256);
+ Assert.NotNull(encrypted);
+ Assert.NotEmpty(encrypted);
+ }
}
\ No newline at end of file
diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs
index 9846559..0c5337d 100644
--- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs
+++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs
@@ -1,3 +1,4 @@
+using IdentityShroud.Api;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
diff --git a/IdentityShroud.Api.Tests/HeaderHelpersTests.cs b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs
new file mode 100644
index 0000000..c08303a
--- /dev/null
+++ b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs
@@ -0,0 +1,20 @@
+using IdentityShroud.Api.Helpers;
+
+namespace IdentityShroud.Api.Tests;
+
+public class HeaderHelpersTests
+{
+ [Theory]
+ [InlineData("Basic dXNlcjpzZWNyZXQ=", true, "user", "secret")]
+ [InlineData("baSIC dXNlcjpzZWNyZXQ=", true, "user", "secret")]
+ [InlineData("Basic dXNlcnNlY3JldA==", false, null, null)] // no colon to seperate user and password
+ [InlineData("Bearer dXNlcjpzZWNyZXQ=", false, null, null)]
+ public void TryDecodeBasicAuth(string input, bool expectedResult, string? expectedUser, string? expectedPassword)
+ {
+ var result = HeaderHelpers.TryDecodeBasicAuth(input, out string? user, out string? password);
+
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(expectedUser, user);
+ Assert.Equal(expectedPassword, password);
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj
index a3aa6a8..67cca0e 100644
--- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj
+++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -8,22 +8,22 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs
deleted file mode 100644
index f423f54..0000000
--- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System.Buffers.Text;
-using System.Security.Cryptography;
-using IdentityShroud.Core.Contracts;
-using IdentityShroud.Core.Model;
-using IdentityShroud.Core.Security;
-using IdentityShroud.Core.Security.Keys;
-using IdentityShroud.Core.Services;
-using IdentityShroud.TestUtils.Substitutes;
-
-namespace IdentityShroud.Api.Tests.Mappers;
-
-public class KeyServiceTests
-{
- private readonly NullDekEncryptionService _dekEncryptionService = new();
-
- [Fact]
- public void Test()
- {
- // Setup
- using RSA rsa = RSA.Create(2048);
-
- RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
-
- DekId kid = DekId.NewId();
-
- RealmKey realmKey = new()
- {
- Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
- KeyType = "RSA",
- Key = new(_dekEncryptionService.KeyId, rsa.ExportPkcs8PrivateKey()),
- CreatedAt = DateTime.UtcNow,
- Priority = 10,
- };
-
- // Act
- KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService());
- var jwk = sut.CreateJsonWebKey(realmKey);
-
- Assert.NotNull(jwk);
- Assert.Equal("RSA", jwk.KeyType);
- Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
- Assert.Equal("sig", jwk.Use);
- Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
- Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
- }
-}
diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs
index e595e34..05b4aa7 100644
--- a/IdentityShroud.Api/Apis/ClientApi.cs
+++ b/IdentityShroud.Api/Apis/ClientApi.cs
@@ -5,11 +5,7 @@ using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-namespace IdentityShroud.Api;
-
-
-
-public record ClientCreateReponse(int Id, string ClientId);
+namespace IdentityShroud.Api.Apis;
///
/// The part of the api below realms/{slug}/clients
@@ -20,12 +16,12 @@ public static class ClientApi
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
- RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
-
+ RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
+
clientsGroup.MapPost("", ClientCreate)
- .Validate()
- .WithName("ClientCreate")
- .Produces(StatusCodes.Status201Created);
+ .Produces(StatusCodes.Status201Created)
+ .Validate()
+ .WithName("ClientCreate");
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter();
@@ -43,11 +39,12 @@ public static class ClientApi
return TypedResults.Ok(new ClientMapper().ToDto(client));
}
- private static async Task, InternalServerError>>
+ private static async Task, InternalServerError>>
ClientCreate(
Guid realmId,
ClientCreateRequest request,
[FromServices] IClientService service,
+ [FromServices] IDataEncryptionService cryptor,
HttpContext context,
CancellationToken cancellationToken)
{
@@ -60,9 +57,12 @@ public static class ClientApi
}
Client client = result.Value;
-
+ ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client);
+ var secret = SelectBestSecret(client.Secrets);
+ if (secret is {} s)
+ clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret);
return TypedResults.CreatedAtRoute(
- new ClientCreateReponse(client.Id, client.ClientId),
+ clientRepresentation,
ClientGetRouteName,
new RouteValueDictionary()
{
@@ -70,4 +70,28 @@ public static class ClientApi
["clientId"] = client.Id,
});
}
+
+ private static ClientSecret? SelectBestSecret(List clientSecrets)
+ {
+ ClientSecret? result = null;
+
+ foreach (var cs in clientSecrets)
+ {
+ if (cs.RevokedAt is null && (!cs.Expires.HasValue || cs.Expires.Value > DateTime.UtcNow))
+ {
+ if (result is null)
+ {
+ result = cs;
+ }
+ else
+ {
+ int d = (cs.Expires ?? DateTime.MaxValue).CompareTo(result.Expires ?? DateTime.MaxValue);
+ if (d > 0 || (d == 0 && cs.CreatedAt > result.CreatedAt))
+ result = cs;
+ }
+ }
+ }
+
+ return result;
+ }
}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs
index 80b5f13..d5e2853 100644
--- a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs
+++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs
@@ -10,7 +10,10 @@ public record ClientRepresentation
public string? SignatureAlgorithm { get; set; }
+ public bool Confidential { get; set; }
public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; }
+
+ public string? Secret { get; set; }
}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/Dto/ErrorDto.cs b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs
new file mode 100644
index 0000000..655d4c4
--- /dev/null
+++ b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs
@@ -0,0 +1,3 @@
+namespace IdentityShroud.Api.Apis;
+
+public record ErrorDto(string Error);
diff --git a/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
new file mode 100644
index 0000000..29f6ca5
--- /dev/null
+++ b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
@@ -0,0 +1,6 @@
+namespace IdentityShroud.Api.Apis;
+
+public record RealmRepresentation(
+ Guid Id,
+ string Slug,
+ string Name);
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
new file mode 100644
index 0000000..88672d6
--- /dev/null
+++ b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace IdentityShroud.Core.DTO.OpenId;
+
+public class TokenRequestBody
+{
+ [JsonPropertyName("grant_type")]
+ public GrantTypes GrantType { get; init; }
+
+ ///
+ /// In most cases required but not when basic auth header is used
+ ///
+ [JsonPropertyName("client_id")]
+ public string? ClientId { get; init; } = "";
+
+ [JsonPropertyName("client_secret")]
+ public string? ClientSecret { get; init; }
+
+ [JsonPropertyName("scope")]
+ public string? Scope { get; init; }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs
index 3c47b48..5e2590f 100644
--- a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs
+++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs
@@ -2,9 +2,10 @@ namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions
{
- public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class
- => builder.AddEndpointFilter>();
-
+ public static IEndpointConventionBuilder Validate(this IEndpointConventionBuilder builder)
+ where TDto : class
+ => builder.AddEndpointFilter>();
+
public static void MapApis(this IEndpointRouteBuilder erp)
{
RealmApi.MapRealmEndpoints(erp);
diff --git a/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Filters/ValidateFilter.cs
similarity index 100%
rename from IdentityShroud.Api/Apis/Validation/ValidateFilter.cs
rename to IdentityShroud.Api/Apis/Filters/ValidateFilter.cs
diff --git a/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
new file mode 100644
index 0000000..35f7f30
--- /dev/null
+++ b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
@@ -0,0 +1,50 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using Microsoft.Extensions.Primitives;
+
+namespace IdentityShroud.Api.Helpers;
+
+public static class HeaderHelpers
+{
+ public static bool TryGetBasicAuth(
+ HttpContext context,
+ [NotNullWhen(true)] out string? user,
+ [NotNullWhen(true)] out string? password)
+ {
+ var headers = context?.Request.Headers;
+ if (headers is not null)
+ {
+ if (headers.TryGetValue("Authorization", out StringValues s))
+ return TryDecodeBasicAuth(s.ToString(), out user, out password);
+ }
+
+ user = password = null;
+ return false;
+ }
+
+ public static bool TryDecodeBasicAuth(
+ string authorizationHeader,
+ [NotNullWhen(true)] out string? user,
+ [NotNullWhen(true)] out string? password)
+ {
+ if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase))
+ {
+ ReadOnlySpan val = authorizationHeader.AsSpan(6); // basic + space
+ Span b = new byte[(val.Length * 6 / 8) + 1];
+ if (Convert.TryFromBase64Chars(val, b, out int written))
+ {
+ int sepIdx = b.IndexOf((byte)':');
+ if (sepIdx > 0 && sepIdx < written - 1)
+ {
+ user = Encoding.UTF8.GetString(b.Slice(0, sepIdx));
+ password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1)));
+ return true;
+ }
+ }
+ }
+
+ user = password = null;
+ return false;
+ }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs
new file mode 100644
index 0000000..f7722cb
--- /dev/null
+++ b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs
@@ -0,0 +1,38 @@
+namespace IdentityShroud.Api.Apis.ISResults;
+
+public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult
+{
+ private readonly List _wwwAuthenticateValues;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ internal ISUnauthorizedHttpResult(List wwwAuthenticateValues)
+ {
+ _wwwAuthenticateValues = wwwAuthenticateValues;
+ }
+
+ ///
+ /// Gets the HTTP status code:
+ ///
+ public int StatusCode => StatusCodes.Status401Unauthorized;
+
+ int? IStatusCodeHttpResult.StatusCode => StatusCode;
+
+ ///
+ public Task ExecuteAsync(HttpContext httpContext)
+ {
+ ArgumentNullException.ThrowIfNull(httpContext);
+
+ // Creating the logger with a string to preserve the category after the refactoring.
+ // var loggerFactory = httpContext.RequestServices.GetRequiredService();
+ // var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult");
+ // HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode);
+
+
+ httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray());
+
+ httpContext.Response.StatusCode = StatusCode;
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs
index 8e58717..0c6563f 100644
--- a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs
+++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs
@@ -6,6 +6,9 @@ namespace IdentityShroud.Api.Mappers;
[Mapper]
public partial class ClientMapper
{
+ // skipping secret as we do not have the DEK
[MapperIgnoreSource(nameof(Client.Secrets))]
+ [MapperIgnoreTarget(nameof(ClientRepresentation.Secret))]
public partial ClientRepresentation ToDto(Client client);
+
}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
index 7155208..e37798b 100644
--- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
+++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
@@ -1,20 +1,28 @@
-using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Api.Mappers;
-public class KeyMapper(IKeyService keyService)
+public class KeyMapper(IKeyProviderFactory keyProviderFactory)
{
- public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys)
+ public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys)
{
JsonWebKeySet wks = new();
foreach (var k in keys)
{
- var wk = keyService.CreateJsonWebKey(k);
- if (wk is {})
+ IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType);
+ if (provider.IsPublic)
{
- wks.Keys.Add(wk);
+ JsonWebKey jwk = new()
+ {
+ KeyId = k.Id.ToString(),
+ KeyType = k.KeyType,
+ Use = "sig",
+ };
+
+ provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
+ wks.Keys.Add(jwk);
}
}
return wks;
diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs
index 6565413..54b972a 100644
--- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs
+++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs
@@ -1,7 +1,11 @@
+using IdentityShroud.Api.Apis;
+using IdentityShroud.Api.Apis.ISResults;
+using IdentityShroud.Api.Helpers;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Services.OpenId;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@@ -11,8 +15,6 @@ public static class OpenIdEndpoints
{
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
-
-
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
var realmsGroup = erp.MapGroup("/auth/realms");
@@ -45,7 +47,7 @@ public static class OpenIdEndpoints
TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks",
- }, AppJsonSerializerContext.Default.OpenIdConfiguration);
+ });
}
private static async Task, BadRequest>> OpenIdConnectJwks(
@@ -56,17 +58,79 @@ public static class OpenIdEndpoints
{
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
- return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
+ return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys));
}
- private static Task OpenIdConnectToken(HttpContext context)
+ private static async Task,
+ BadRequest,
+ ISUnauthorizedHttpResult
+ >> OpenIdConnectToken(
+ string realmSlug,
+ [FromServices] IClientService clientService,
+ HttpContext context,
+ CancellationToken ct)
{
- throw new NotImplementedException();
+ IFormCollection form = await context.Request.ReadFormAsync();
+
+ string grantType = form["grant_type"].ToString();
+ string clientId = form["client_id"].ToString();
+ string scope = form["scope"].ToString();
+
+ if (grantType == "client_credentials")
+ {
+ string? clientSecret = null;
+ bool withAuthHeader = false;
+ if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password))
+ {
+ withAuthHeader = true;
+ clientId = user;
+ clientSecret = password;
+ }
+ clientSecret ??= form["client_secret"].ToString();
+
+ if (string.IsNullOrEmpty(clientId) ||
+ string.IsNullOrEmpty(clientSecret))
+ {
+ return CreateBadRequest("invalid_request");
+ }
+
+ Realm realm = context.GetValidatedRealm();
+ Client? client = await clientService.GetByClientId(realm.Id, clientId, ct);
+ if (client is null)
+ {
+ if (withAuthHeader)
+ {
+ return new ISUnauthorizedHttpResult([$"Basic realm=\"{realm.Slug}\""]);
+ }
+ return CreateBadRequest("invalid_client");
+ }
+
+ if (!client.AllowClientCredentialsFlow)
+ return CreateBadRequest("unauthorized_client");
+
+ }
+ else
+ return CreateBadRequest("unsupported_grant_type");
+
+ context.Response.Headers.CacheControl = "no-store";
+ context.Response.Headers.Pragma = "no-cache";
+
+ return TypedResults.Ok(new TokenResponse()
+ {
+ AccessToken = "token",
+ TokenType = "bearer",
+ ExpiresIn = 3600,
+ });
}
+ private static BadRequest CreateBadRequest(string error) =>
+ TypedResults.BadRequest(new ErrorDto(error));
+
+
+
private static Task OpenIdConnectAuth(HttpContext context)
{
throw new NotImplementedException();
}
-
}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs
index 88a5179..ed78cef 100644
--- a/IdentityShroud.Api/Apis/RealmApi.cs
+++ b/IdentityShroud.Api/Apis/RealmApi.cs
@@ -1,7 +1,7 @@
+using IdentityShroud.Api.Apis;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
-using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@@ -19,31 +19,56 @@ public static class HttpContextExtensions
public static class RealmApi
{
+ public const string GetRealmRoute = "Get Realm";
+ public const string CreateRealmRoute = "Create Realm";
+
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
{
var realmsGroup = erp.MapGroup("/api/v1/realms");
+
realmsGroup.MapPost("", RealmCreate)
- .Validate()
- .WithName("Create Realm")
- .Produces(StatusCodes.Status201Created);
+ .Produces(StatusCodes.Status201Created)
+ .Validate()
+ .WithName(CreateRealmRoute);
+
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
.AddEndpointFilter();
- ClientApi.MapEndpoints(realmIdGroup);
-
-
+ realmIdGroup.MapGet("", RealmGet)
+ .WithName(GetRealmRoute);
+ ClientApi.MapEndpoints(realmIdGroup);
}
-
- private static async Task, InternalServerError>>
+
+ private static Ok RealmGet(
+ Guid realmId,
+ HttpContext context)
+ {
+ Realm realm = context.GetValidatedRealm();
+ return TypedResults.Ok(MapToRepresentation(realm));
+ }
+
+ private static async Task, InternalServerError>>
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
{
var response = await service.Create(request);
if (response.IsSuccess)
- return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value);
-
+ {
+ var realm = response.Value;
+ return TypedResults.CreatedAtRoute(
+ MapToRepresentation(realm),
+ GetRealmRoute,
+ new { realmId = realm.Id });
+ }
+
// TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError();
}
-}
\ No newline at end of file
+
+ private static RealmRepresentation MapToRepresentation(Realm realm)
+ => new(realm.Id, realm.Slug, realm.Name);
+}
+
+
+
diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs
index 7666b36..aef7c47 100644
--- a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs
+++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs
@@ -6,9 +6,9 @@ namespace IdentityShroud.Api;
public class ClientCreateRequestValidator : AbstractValidator
{
// most of standard ascii minus the control characters and space
- private const string ClientIdPattern = "^[\x21-\x7E]+";
+ private const string ClientIdPattern = "^[a-zA-Z0-9_-]+";
- private string[] AllowedAlgorithms = [ "RS256", "ES256" ];
+ private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ];
public ClientCreateRequestValidator()
{
@@ -16,7 +16,9 @@ public class ClientCreateRequestValidator : AbstractValidator e.Name).MaximumLength(80);
RuleFor(e => e.Description).MaximumLength(2048);
RuleFor(e => e.SignatureAlgorithm)
- .Must(v => v is null || AllowedAlgorithms.Contains(v))
- .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null");
+ .Must(v => v is null || _allowedAlgorithms.Contains(v))
+ .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null");
+ RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true);
+ RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true);
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs
deleted file mode 100644
index e7d90da..0000000
--- a/IdentityShroud.Api/AppJsonSerializerContext.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.Text.Json.Serialization;
-using IdentityShroud.Core.Messages;
-using IdentityShroud.Core.Messages.Realm;
-
-[JsonSerializable(typeof(OpenIdConfiguration))]
-[JsonSerializable(typeof(RealmCreateRequest))]
-internal partial class AppJsonSerializerContext : JsonSerializerContext
-{
-}
\ No newline at end of file
diff --git a/IdentityShroud.Api/GlobalExceptionHandler.cs b/IdentityShroud.Api/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..7729674
--- /dev/null
+++ b/IdentityShroud.Api/GlobalExceptionHandler.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Diagnostics;
+
+namespace IdentityShroud.Api;
+
+public class GlobalExceptionHandler : IExceptionHandler
+{
+ private readonly ILogger _logger;
+
+ public GlobalExceptionHandler(ILogger logger)
+ => _logger = logger;
+
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogError(exception, "Exception type: {Type}, Message: {Message}",
+ exception.GetType().Name, exception.Message);
+
+ // Return false to let other handlers or the default handle it
+ // Return true to mark it as handled
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj
index 31f88b2..f6f4148 100644
--- a/IdentityShroud.Api/IdentityShroud.Api.csproj
+++ b/IdentityShroud.Api/IdentityShroud.Api.csproj
@@ -5,7 +5,7 @@
enable
enable
true
- true
+ false
Linux
6b8ef434-0577-4a3c-8749-6b547d7787c5
@@ -15,16 +15,17 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs
index 29f6736..2ff5fe6 100644
--- a/IdentityShroud.Api/Program.cs
+++ b/IdentityShroud.Api/Program.cs
@@ -1,74 +1,74 @@
using FluentValidation;
-using IdentityShroud.Api;
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 IdentityShroud.Core.EFCore;
+using IdentityShroud.GraphQL;
using Serilog;
using Serilog.Formatting.Json;
-
// Initial logging until we can set it up from Configuration
-Log.Logger = new LoggerConfiguration()
- .Enrich.FromLogContext()
- .WriteTo.Console(new JsonFormatter())
- .CreateLogger();
-var applicationBuilder = WebApplication.CreateSlimBuilder(args);
-ConfigureBuilder(applicationBuilder);
-var application = applicationBuilder.Build();
-ConfigureApplication(application);
-application.Run();
+namespace IdentityShroud.Api;
-void ConfigureBuilder(WebApplicationBuilder builder)
+public class Program
{
- var services = builder.Services;
- var configuration = builder.Configuration;
-
- //services.AddControllers();
- services.ConfigureHttpJsonOptions(options =>
+ public static void Main(string[] args)
{
- options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
- });
+ Log.Logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .WriteTo.Console(new JsonFormatter())
+ .CreateLogger();
- // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
- services.AddOpenApi();
- services.AddScoped();
- services.AddScoped();
- services.AddSingleton();
- services.AddSingleton();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddOptions().Bind(configuration.GetSection("db"));
- services.AddSingleton();
- services.AddScoped();
- services.AddScoped();
-
- services.AddValidatorsFromAssemblyContaining();
- services.AddHttpContextAccessor();
-
- builder.Host.UseSerilog((context, services, configuration) => configuration
- .Enrich.FromLogContext()
- //.Enrich.With()
- .ReadFrom.Configuration(context.Configuration));
-}
-
-void ConfigureApplication(WebApplication app)
-{
- if (app.Environment.IsDevelopment())
- {
- app.MapOpenApi();
+ var applicationBuilder = WebApplication.CreateSlimBuilder(args);
+ ConfigureBuilder(applicationBuilder);
+ var application = applicationBuilder.Build();
+ ConfigureApplication(application);
+ application.Run();
}
- app.UseSerilogRequestLogging();
- app.MapApis();
-
- // app.UseRouting();
- // app.MapControllers();
-}
-public partial class Program { }
+ private static void ConfigureBuilder(WebApplicationBuilder builder)
+ {
+ var services = builder.Services;
+ var configuration = builder.Configuration;
+
+ services.AddOptions().Bind(configuration.GetSection("db"));
+
+ // services.ConfigureHttpJsonOptions(options =>
+ // {
+ // options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
+ // });
+
+ services.AddScoped();
+
+ services.AddValidatorsFromAssemblyContaining();
+
+ services.AddHttpContextAccessor();
+ services.AddOpenApi();
+ services.AddExceptionHandler();
+ services.AddProblemDetails();
+
+ services
+ .AddCore()
+ .AddIdentityShroudGraphQL();
+
+ builder.Host.UseSerilog((context, services, configuration) => configuration
+ .Enrich.FromLogContext()
+ //.Enrich.With()
+ .ReadFrom.Configuration(context.Configuration));
+ }
+
+ private static void ConfigureApplication(WebApplication app)
+ {
+ app.UseExceptionHandler();
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapOpenApi();
+ }
+ app.UseSerilogRequestLogging();
+ app.MapApis();
+ app.MapIdentityShroudGraphQL();
+
+ // app.UseRouting();
+ // app.MapControllers();
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Api/Properties/launchSettings.json b/IdentityShroud.Api/Properties/launchSettings.json
index 9472c5a..8556497 100644
--- a/IdentityShroud.Api/Properties/launchSettings.json
+++ b/IdentityShroud.Api/Properties/launchSettings.json
@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "launchUrl": "todos",
+ "launchUrl": "graphql",
"applicationUrl": "http://localhost:5249",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs
index 844d4ca..1df6559 100644
--- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs
+++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.Logging.Abstractions;
+using IdentityShroud.Core.EFCore;
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using Testcontainers.PostgreSql;
diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj
index 8af08c1..918119c 100644
--- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj
+++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -8,20 +8,19 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs
index bf4d0a6..4563ea4 100644
--- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs
+++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs
@@ -48,8 +48,7 @@ public class JwtSignatureGeneratorTests
}
]
}
- """;
-
+ """;
JsonWebKeySet keySet = JsonSerializer.Deserialize(keycloakKeySet)!;
using RSA publicKey = LoadFromJwk(keySet.Keys[0]);
diff --git a/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs
new file mode 100644
index 0000000..13b76ac
--- /dev/null
+++ b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs
@@ -0,0 +1,48 @@
+using System.Security.Cryptography;
+using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
+using IdentityShroud.Core.Services;
+
+namespace IdentityShroud.Core.Tests.Security.Jwt;
+
+public class RsaJwtSignerTests
+{
+ [Fact]
+ public void Test()
+ {
+ // ISecretProvider secretProvider = Substitute.For();
+ // RealmSigningKey privateKey = new()
+ // {
+ // Id = default,
+ // KeyType = KeyType.RSA,
+ // Key = new EncryptedDek(KekId.NewId(), [1]),
+ // CreatedAt = default,
+ // RevokedAt = null,
+ // Priority = 0,
+ // PublicKeyParameters = null
+ // };
+ DecryptedSigningKey key = new();
+ byte[] jwt = [];
+
+ RsaJwtSigner provider = new();
+ provider.CalculateSignature(JwtSigAlgName.RS256, key, jwt);
+ //
+ // new DekEncryptionService(secretProvider), privateKey,
+ // JwtSigAlgName.RS256);
+ }
+
+ [Theory]
+ [InlineData(1024)]
+ [InlineData(2048)]
+ [InlineData(4096)]
+ public void EstimateKeySizeTests(int keySizeBits)
+ {
+ using var rsa = RSA.Create();
+ rsa.KeySize = keySizeBits;
+ byte[] b = rsa.ExportPkcs8PrivateKey();
+ int estimate = DecryptedSigningKey.EstimatePkcs8ExportSize(keySizeBits);
+ Assert.True(b.Length < estimate - 100);
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs
index d0269e6..a0690c9 100644
--- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs
+++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs
@@ -1,5 +1,9 @@
+using IdentityShroud.Api;
using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Substitutes;
@@ -7,6 +11,29 @@ using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Tests.Services;
+public static class RealmDekBuilder
+{
+ public static RealmDek DefaultActive() =>
+ new()
+ {
+ Id = DekId.NewId(),
+ Active = true,
+ Algorithm = KeyType.AES,
+ KeyData = new EncryptedDek(KekId.NewId(),
+ [
+ 0
+ ])
+ };
+}
+
+public static class ClientCreateRequestBuilder
+{
+ public static ClientCreateRequest Default() => new(
+ "test-client",
+ "Test Client",
+ "A test client");
+}
+
public class ClientServiceTests : IClassFixture
{
private readonly DbFixture _dbFixture;
@@ -34,15 +61,28 @@ public class ClientServiceTests : IClassFixture
{
if (!db.Realms.Any(r => r.Id == _realmId))
{
- db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" });
+ db.Realms.Add(new()
+ {
+ Id = _realmId,
+ Slug = "test-realm",
+ Name = "Test Realm",
+ DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ],
+ });
+
db.SaveChanges();
}
}
+ private ClientService CreateSut(Db db) => new(db,
+ _dataEncryptionService,
+ new ClientCreateRequestValidator(),
+ _clock);
+
+
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task Create(bool allowClientCredentialsFlow)
+ public async Task Create(bool withSecret)
{
// Setup
DateTime now = DateTime.UtcNow;
@@ -52,15 +92,13 @@ public class ClientServiceTests : IClassFixture
await using (var db = _dbFixture.CreateDbContext())
{
// Act
- ClientService sut = new(db, _dataEncryptionService, _clock);
+ ClientService sut = CreateSut(db);
var response = await sut.Create(
_realmId,
- new ClientCreateRequest
+ ClientCreateRequestBuilder.Default() with
{
- ClientId = "test-client",
- Name = "Test Client",
- Description = "A test client",
- AllowClientCredentialsFlow = allowClientCredentialsFlow,
+ Confidential = withSecret,
+ GenerateSecret = withSecret,
},
TestContext.Current.CancellationToken);
@@ -70,7 +108,7 @@ public class ClientServiceTests : IClassFixture
Assert.Equal("test-client", val.ClientId);
Assert.Equal("Test Client", val.Name);
Assert.Equal("A test client", val.Description);
- Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow);
+ Assert.Equal(withSecret, val.Confidential);
Assert.Equal(now, val.CreatedAt);
}
@@ -80,7 +118,7 @@ public class ClientServiceTests : IClassFixture
.Include(e => e.Secrets)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
- if (allowClientCredentialsFlow)
+ if (withSecret)
Assert.Single(dbRecord.Secrets);
else
Assert.Empty(dbRecord.Secrets);
@@ -108,7 +146,7 @@ public class ClientServiceTests : IClassFixture
await using var actContext = _dbFixture.CreateDbContext();
// Act
- ClientService sut = new(actContext, _dataEncryptionService, _clock);
+ ClientService sut = CreateSut(actContext);
Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken);
// Verify
@@ -143,7 +181,7 @@ public class ClientServiceTests : IClassFixture
await using var actContext = _dbFixture.CreateDbContext();
// Act
- ClientService sut = new(actContext, _dataEncryptionService, _clock);
+ ClientService sut = CreateSut(actContext);
Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken);
// Verify
diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs
index 4f88e48..a61e7e0 100644
--- a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs
+++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.TestUtils.Substitutes;
@@ -9,23 +10,20 @@ namespace IdentityShroud.Core.Tests.Services;
public class DataEncryptionServiceTests
{
- private readonly IRealmContext _realmContext = Substitute.For();
+// private readonly IRealmContext _realmContext = Substitute.For();
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For();
private readonly DekId _activeDekId = DekId.NewId();
private readonly DekId _secondDekId = DekId.NewId();
private DataEncryptionService CreateSut()
- => new(_realmContext, _dekCryptor);
+ => new(_dekCryptor);
[Fact]
public void Encrypt_UsesActiveKey()
{
- _realmContext.GetDeks(Arg.Any()).Returns([
- CreateRealmDek(_secondDekId, false),
- CreateRealmDek(_activeDekId, true),
- ]);
-
- var cipher = CreateSut().Encrypt("Hello"u8);
+ var dek = CreateRealmDek(_activeDekId, true);
+
+ var cipher = CreateSut().Encrypt(dek, "Hello"u8);
Assert.Equal(_activeDekId, cipher.DekId);
}
@@ -34,20 +32,18 @@ public class DataEncryptionServiceTests
public void Decrypt_UsesCorrectKey()
{
var first = CreateRealmDek(_activeDekId, true);
- _realmContext.GetDeks(Arg.Any()).Returns([ first ]);
var sut = CreateSut();
- var cipher = sut.Encrypt("Hello"u8);
+ var cipher = sut.Encrypt(first, "Hello"u8);
// Deactivate original key
first.Active = false;
// Make new active
var second = CreateRealmDek(_secondDekId, true);
// Return both
- _realmContext.GetDeks(Arg.Any()).Returns([ first, second ]);
+ RealmDek[] list = [ first, second ];
-
- var decoded = sut.Decrypt(cipher);
+ var decoded = sut.Decrypt(list, cipher);
Assert.Equal("Hello"u8, decoded);
}
@@ -57,7 +53,7 @@ public class DataEncryptionServiceTests
{
Id = id,
Active = active,
- Algorithm = "AES",
+ Algorithm = KeyType.AES,
KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)),
RealmId = default,
};
diff --git a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs
index fc4a45f..c0b9f38 100644
--- a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs
+++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs
@@ -25,8 +25,13 @@ public class DekEncryptionServiceTests
// act
DekEncryptionService sut = new(secretProvider);
+
EncryptedDek cipher = sut.Encrypt(input.ToArray());
- byte[] result = sut.Decrypt(cipher);
+ int decryptedSize = sut.GetDecryptedSize(cipher);
+ Assert.Equal(input.Length, decryptedSize);
+
+ var result = new byte[decryptedSize];
+ sut.Decrypt(cipher, result);
// verify
Assert.Equal(input, result);
@@ -56,8 +61,10 @@ public class DekEncryptionServiceTests
// act
DekEncryptionService sut = new(secretProvider);
+ int decryptedSize = sut.GetDecryptedSize(secret);
+ var result = new byte[decryptedSize];
Assert.Throws(
- () => sut.Decrypt(secret),
+ () => sut.Decrypt(secret, result),
ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message");
}
@@ -89,7 +96,8 @@ public class DekEncryptionServiceTests
// act
DekEncryptionService sut = new(secretProvider);
- byte[] result = sut.Decrypt(secret);
+ byte[] result = new byte[sut.GetDecryptedSize(secret)];
+ sut.Decrypt(secret, result);
// verify
Assert.Equal("Hello, World!"u8, result);
diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs
index 2dfbb52..32e4538 100644
--- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs
+++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs
@@ -1,5 +1,4 @@
using IdentityShroud.Core.Security;
-using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Tests.Services;
@@ -20,7 +19,8 @@ public class EncryptionTests
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
// act
- byte[] result = Encryption.Decrypt(cipher, keyValue);
+ byte[] result = new byte[Encryption.GetDecryptedLength(cipher)];
+ Encryption.Decrypt(cipher, keyValue, result);
// verify
Assert.Equal("Hello, World!"u8, result);
diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs
index fda233e..70d6d11 100644
--- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs
+++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs
@@ -1,10 +1,13 @@
+using FluentResults;
using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
-using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures;
+using IdentityShroud.TestUtils.Substitutes;
using Microsoft.EntityFrameworkCore;
+using Shouldly;
namespace IdentityShroud.Core.Tests.Services;
@@ -12,6 +15,7 @@ public class RealmServiceTests : IClassFixture
{
private readonly DbFixture _dbFixture;
private readonly IKeyService _keyService = Substitute.For();
+ private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
public RealmServiceTests(DbFixture dbFixture)
{
@@ -25,6 +29,9 @@ public class RealmServiceTests : IClassFixture
{
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
}
+
+ private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService());
+
[Theory]
[InlineData(null)]
@@ -36,20 +43,14 @@ public class RealmServiceTests : IClassFixture
if (idString is not null)
realmId = new(idString);
- RealmCreateResponse? val;
+ Realm? val;
await using (var db = _dbFixture.CreateDbContext())
{
_keyService.CreateKey(Arg.Any())
- .Returns(new RealmKey()
- {
- Id = Guid.NewGuid(),
- KeyType = "TST",
- Key = new(KekId.NewId(), [21]),
- CreatedAt = DateTime.UtcNow
- });
+ .Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21])));
// Act
- RealmService sut = new(db, _keyService);
- var response = await sut.Create(
+ RealmService sut = CreateSut(db);
+ Result response = await sut.Create(
new(realmId, "slug", "New realm"),
TestContext.Current.CancellationToken);
@@ -60,8 +61,12 @@ public class RealmServiceTests : IClassFixture
else
Assert.NotEqual(Guid.Empty, val.Id);
- Assert.Equal("slug", val.Slug);
- Assert.Equal("New realm", val.Name);
+ Assert.Multiple(
+ () => val.Slug.ShouldBe("slug"),
+ () => val.Name.ShouldBe("New realm"),
+ () => val.DataEncryptionKeys.ShouldContain(d => d.Active),
+ () => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
+ );
_keyService.Received().CreateKey(Arg.Any());
}
@@ -69,9 +74,9 @@ public class RealmServiceTests : IClassFixture
await using (var db = _dbFixture.CreateDbContext())
{
var dbRecord = await db.Realms
- .Include(e => e.Keys)
+ .Include(e => e.TokenSigningKeys)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
- Assert.Equal("TST", dbRecord.Keys[0].KeyType);
+ Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType);
}
}
@@ -98,7 +103,7 @@ public class RealmServiceTests : IClassFixture
await using var actContext = _dbFixture.CreateDbContext();
// Act
- RealmService sut = new(actContext, _keyService);
+ RealmService sut = CreateSut(actContext);
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
// Verify
@@ -131,7 +136,7 @@ public class RealmServiceTests : IClassFixture
await using var actContext = _dbFixture.CreateDbContext();
// Act
- RealmService sut = new(actContext, _keyService);
+ RealmService sut = CreateSut(actContext);
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
// Verify
diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs
index 7506fd0..7cfc961 100644
--- a/IdentityShroud.Core.Tests/UnitTest1.cs
+++ b/IdentityShroud.Core.Tests/UnitTest1.cs
@@ -1,8 +1,7 @@
-using System.Security.Cryptography;
-using System.Text;
+using System.Buffers.Text;
+using System.Security.Cryptography;
using System.Text.Json;
using IdentityShroud.Core.DTO;
-using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Tests;
@@ -74,10 +73,10 @@ public static class JwtReader
return new JsonWebToken()
{
Header = JsonSerializer.Deserialize(
- Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
+ Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!,
Payload = JsonSerializer.Deserialize(
- Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!,
- Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
+ Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!,
+ Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))),
};
}
}
diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs
index 2810aaa..1a89862 100644
--- a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs
+++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs
@@ -1,9 +1,22 @@
+using System.Text;
+using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Contracts;
public interface IDataEncryptionService
{
- EncryptedValue Encrypt(ReadOnlySpan plain);
- byte[] Decrypt(EncryptedValue input);
+ EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain);
+ byte[] Decrypt(IReadOnlyList deks, EncryptedValue input);
+}
+
+public static class DataEncryptionServiceExtensions
+{
+ public static string DecryptUtf8ToString(
+ this IDataEncryptionService des,
+ IReadOnlyList deks,
+ EncryptedValue input)
+ {
+ return Encoding.UTF8.GetString(des.Decrypt(deks, input));
+ }
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs
index 3032040..bbb234c 100644
--- a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs
+++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs
@@ -7,5 +7,7 @@ namespace IdentityShroud.Core.Contracts;
public interface IDekEncryptionService
{
EncryptedDek Encrypt(ReadOnlySpan plain);
- byte[] Decrypt(EncryptedDek input);
+
+ void Decrypt(EncryptedDek input, Span output);
+ int GetDecryptedSize(EncryptedDek input);
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs
index 4f6b5f7..08a5bf6 100644
--- a/IdentityShroud.Core/Contracts/IKeyService.cs
+++ b/IdentityShroud.Core/Contracts/IKeyService.cs
@@ -1,12 +1,10 @@
-using IdentityShroud.Core.Messages;
-using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Contracts;
+public record CreateKeyResponse(KeyType KeyType, KeyData Key);
+
public interface IKeyService
{
- RealmKey CreateKey(KeyPolicy policy);
-
- JsonWebKey? CreateJsonWebKey(RealmKey realmKey);
+ CreateKeyResponse CreateKey(KeyPolicy policy);
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs
index 4598b97..1724e6c 100644
--- a/IdentityShroud.Core/Contracts/IRealmService.cs
+++ b/IdentityShroud.Core/Contracts/IRealmService.cs
@@ -1,6 +1,5 @@
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
-using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Contracts;
@@ -9,7 +8,7 @@ public interface IRealmService
Task FindById(Guid id, CancellationToken ct = default);
Task FindBySlug(string slug, CancellationToken ct = default);
- Task> Create(RealmCreateRequest request, CancellationToken ct = default);
+ Task> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm);
Task LoadDeks(Realm realm);
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/CoreServiceCollectionExtensions.cs b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs
new file mode 100644
index 0000000..86d7339
--- /dev/null
+++ b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs
@@ -0,0 +1,38 @@
+using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
+using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
+using IdentityShroud.Core.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace IdentityShroud.Core;
+
+public static class CoreServiceCollectionExtensions
+{
+ public static IServiceCollection AddCore(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ services.Scan(scan => scan
+ .FromAssemblyOf()
+ .AddClasses(classes => classes.AssignableTo())
+ .AsImplementedInterfaces()
+ .WithSingletonLifetime());
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+
+
+ services.AddScoped();
+ services.AddScoped();
+
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs
index a162131..f1c3b40 100644
--- a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs
+++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs
@@ -1,10 +1,10 @@
namespace IdentityShroud.Core.Contracts;
-public class ClientCreateRequest
-{
- public required string ClientId { get; set; }
- public string? Name { get; set; }
- public string? Description { get; set; }
- public string? SignatureAlgorithm { get; set; }
- public bool? AllowClientCredentialsFlow { get; set; }
-}
\ No newline at end of file
+public record ClientCreateRequest(
+ string ClientId,
+ string? Name = null,
+ string? Description = null,
+ string? SignatureAlgorithm = null,
+ bool Confidential = false,
+ bool AllowClientCredentialsFlow = false,
+ bool GenerateSecret = false);
\ No newline at end of file
diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs
index 4f16955..afc9367 100644
--- a/IdentityShroud.Core/DTO/JsonWebKey.cs
+++ b/IdentityShroud.Core/DTO/JsonWebKey.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using IdentityShroud.Core.Helpers;
+using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Messages;
@@ -9,7 +10,7 @@ namespace IdentityShroud.Core.Messages;
public class JsonWebKey
{
[JsonPropertyName("kty")]
- public string KeyType { get; set; } = "RSA";
+ public required KeyType KeyType { get; set; }
// Common values sig(nature) enc(ryption)
[JsonPropertyName("use")]
diff --git a/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs
new file mode 100644
index 0000000..e764e24
--- /dev/null
+++ b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace IdentityShroud.Core.DTO.OpenId;
+
+public enum GrantTypes
+{
+ [JsonStringEnumMemberName("client_credentials")]
+ ClientCredentials
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs
new file mode 100644
index 0000000..23d9718
--- /dev/null
+++ b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace IdentityShroud.Core.Services.OpenId;
+
+public class TokenResponse
+{
+ [JsonPropertyName("access_token")]
+ public required string AccessToken { get; set; }
+
+ [JsonPropertyName("token_type")]
+ public required string TokenType { get; set; }
+
+ [JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; set; }
+
+ [JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; set; }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs
index fab91aa..143c75b 100644
--- a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs
+++ b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs
@@ -1,3 +1,3 @@
namespace IdentityShroud.Core.Messages.Realm;
-public record RealmCreateRequest(Guid? Id, string? Slug, string Name);
\ No newline at end of file
+public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null);
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs
new file mode 100644
index 0000000..df12fc2
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs
@@ -0,0 +1,6 @@
+using IdentityShroud.Core.Security;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class DekIdConverter() : ValueConverter(id => id.Id, guid => new DekId(guid));
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs
new file mode 100644
index 0000000..1236b67
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs
@@ -0,0 +1,14 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class DictionaryToJsonConverter : ValueConverter, string>
+ where TKey : notnull
+{
+ public DictionaryToJsonConverter() : base(
+ v => JsonSerializer.Serialize(v),
+ v => JsonSerializer.Deserialize>(v) ?? new())
+ {
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs
new file mode 100644
index 0000000..d570d61
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs
@@ -0,0 +1,5 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class JwtSigAlgNameConverter() : ValueConverter(j => j.ToString(), s => new(s));
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs
new file mode 100644
index 0000000..23f55fe
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs
@@ -0,0 +1,12 @@
+using IdentityShroud.Core.Security;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class KekIdConverter : ValueConverter
+{
+ public KekIdConverter()
+ : base(id => id.Id, guid => new KekId(guid))
+ {
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs
new file mode 100644
index 0000000..18c8574
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs
@@ -0,0 +1,6 @@
+using IdentityShroud.Core.Security.Keys;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class KeyTypeConverter() : ValueConverter(id => id.ToString(), s => new(s));
\ No newline at end of file
diff --git a/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs
new file mode 100644
index 0000000..f36ff9a
--- /dev/null
+++ b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs
@@ -0,0 +1,13 @@
+using IdentityShroud.Core.Model;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace IdentityShroud.Core.EFCore;
+
+public class RealmSigningKeyIdConverter : ValueConverter
+{
+ public RealmSigningKeyIdConverter()
+ : base(id => id.Id, guid => new RealmSigningKeyId(guid))
+ {
+ }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/EFCore/Db.cs
similarity index 55%
rename from IdentityShroud.Core/Db.cs
rename to IdentityShroud.Core/EFCore/Db.cs
index a37136c..b2bc12e 100644
--- a/IdentityShroud.Core/Db.cs
+++ b/IdentityShroud.Core/EFCore/Db.cs
@@ -1,11 +1,11 @@
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-namespace IdentityShroud.Core;
+namespace IdentityShroud.Core.EFCore;
public class DbConfiguration
{
@@ -20,42 +20,9 @@ public class Db(
{
public virtual DbSet Clients { get; set; }
public virtual DbSet Realms { get; set; }
- public virtual DbSet Keys { get; set; }
+ public virtual DbSet Keys { get; set; }
public virtual DbSet Deks { get; set; }
- protected override void OnModelCreating(ModelBuilder modelBuilder)
- {
- var dekIdConverter = new ValueConverter(
- id => id.Id,
- guid => new DekId(guid));
-
- var kekIdConverter = new ValueConverter(
- id => id.Id,
- guid => new KekId(guid));
-
- modelBuilder.Entity()
- .Property(d => d.Id)
- .HasConversion(dekIdConverter);
-
- modelBuilder.Entity()
- .OwnsOne(d => d.KeyData, keyData =>
- {
- keyData.Property(k => k.KekId).HasConversion(kekIdConverter);
- });
-
- modelBuilder.Entity()
- .OwnsOne(k => k.Key, key =>
- {
- key.Property(k => k.KekId).HasConversion(kekIdConverter);
- });
-
- modelBuilder.Entity()
- .OwnsOne(c => c.Secret, secret =>
- {
- secret.Property(s => s.DekId).HasConversion(dekIdConverter);
- });
- }
-
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql("");
@@ -71,6 +38,22 @@ public class Db(
{
optionsBuilder.UseLoggerFactory(loggerFactory);
}
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(Db).Assembly);
+ }
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder b)
+ {
+ base.ConfigureConventions(b);
+ b.Properties().HaveConversion();
+ b.Properties>().HaveConversion>();
+ b.Properties().HaveConversion();
+ b.Properties().HaveConversion();
+ b.Properties().HaveConversion();
+ b.Properties().HaveConversion();
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj
index 9dd3e34..fe5ed22 100644
--- a/IdentityShroud.Core/IdentityShroud.Core.csproj
+++ b/IdentityShroud.Core/IdentityShroud.Core.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -7,19 +7,24 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings
new file mode 100644
index 0000000..f42aea1
--- /dev/null
+++ b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs
index 5df6c1a..b7d9c60 100644
--- a/IdentityShroud.Core/Model/Client.cs
+++ b/IdentityShroud.Core/Model/Client.cs
@@ -19,8 +19,16 @@ public class Client
public string? Description { get; set; }
[MaxLength(20)]
- public string? SignatureAlgorithm { get; set; }
+ public JwtSigAlgName? SignatureAlgorithm { get; set; }
+ ///
+ /// Enables confidential flows
+ ///
+ public bool Confidential { get; set; }
+
+ ///
+ /// Enables the client credentials flow which required Confidential to be true too.
+ ///
public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; }
diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs
index 52d25cc..189039f 100644
--- a/IdentityShroud.Core/Model/ClientSecret.cs
+++ b/IdentityShroud.Core/Model/ClientSecret.cs
@@ -1,7 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
-using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model;
@@ -12,6 +13,17 @@ public class ClientSecret
public int Id { get; set; }
public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; }
+ public DateTime? Expires { get; set; }
public DateTime? RevokedAt { get; set; }
public required EncryptedValue Secret { get; set; }
+}
+
+public class ClientSecretConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder b)
+ {
+ b.ToTable("client_secret");
+ b.HasKey(e => e.Id);
+ b.ComplexProperty(e => e.Secret);
+ }
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Model/DecryptedSigningKey.cs b/IdentityShroud.Core/Model/DecryptedSigningKey.cs
new file mode 100644
index 0000000..4a94dc7
--- /dev/null
+++ b/IdentityShroud.Core/Model/DecryptedSigningKey.cs
@@ -0,0 +1,66 @@
+using System.Security.Cryptography;
+using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.Security.Keys;
+
+namespace IdentityShroud.Core.Model;
+
+public sealed class DecryptedSigningKey : IDisposable
+{
+ private readonly byte[] _keyData;
+ private readonly int _keyLength;
+ private bool _disposed;
+
+ public RealmSigningKeyId Id { get; }
+ public KeyType KeyType { get; }
+ public ReadOnlySpan KeyData => _disposed
+ ? throw new ObjectDisposedException(nameof(DecryptedSigningKey))
+ : _keyData.AsSpan(0, _keyLength);
+
+ public DecryptedSigningKey(RealmSigningKey realmSigningKey, IDekEncryptionService encryptionService)
+ {
+ Id = realmSigningKey.Id;
+ KeyType = realmSigningKey.KeyType;
+ int keySize = encryptionService.GetDecryptedSize(realmSigningKey.Key);
+ _keyData = GC.AllocateArray(keySize, pinned: true);
+ _keyLength = keySize;
+ encryptionService.Decrypt(realmSigningKey.Key, _keyData);
+ }
+
+ public DecryptedSigningKey()
+ {
+ Id = RealmSigningKeyId.NewId();
+ KeyType = KeyType.RSA;
+ const int keySize = 2048;
+
+ using var rsa = RSA.Create();
+ rsa.KeySize = keySize;
+ int estimatedSize = EstimatePkcs8ExportSize(keySize);
+
+ Span temp = stackalloc byte[estimatedSize * 2];
+ try
+ {
+ if (!rsa.TryExportPkcs8PrivateKey(temp, out int bytesWritten))
+ throw new CryptographicException("Unable to export RSA private key.");
+
+ _keyData = GC.AllocateArray(bytesWritten, pinned: true);
+ _keyLength = bytesWritten;
+ temp[..bytesWritten].CopyTo(_keyData);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(temp);
+ }
+ }
+
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ CryptographicOperations.ZeroMemory(_keyData);
+ }
+
+ // Note actual accurate coefficients would be *0.566 and +57.4
+ public static int EstimatePkcs8ExportSize(int keySizeBits)
+ => ((keySizeBits * 6) / 10) + 150;
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs
index bbe9631..97f08c7 100644
--- a/IdentityShroud.Core/Model/Realm.cs
+++ b/IdentityShroud.Core/Model/Realm.cs
@@ -1,13 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
-using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Model;
[Table("realm")]
public class Realm
{
-
public Guid Id { get; set; }
///
/// Note this is part of the url we should encourage users to keep it short but we do not want to limit them too much
@@ -19,22 +17,17 @@ public class Realm
public string Name { get; set; } = "";
public List Clients { get; init; } = [];
- public List Keys { get; init; } = [];
+
+ ///
+ /// Note multiple keys can be in use at the same time because different clients may be configured to use
+ /// a different keytype depending on their clients requirements/capabilities.
+ ///
+ public List TokenSigningKeys { get; init; } = [];
- public List Deks { get; init; } = [];
+ public List DataEncryptionKeys { get; init; } = [];
///
/// Can be overriden per client
///
- public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
-}
-
-[Table("realm_dek")]
-public record RealmDek
-{
- public required DekId Id { get; init; }
- public required bool Active { get; set; }
- public required string Algorithm { get; init; }
- public required EncryptedDek KeyData { get; init; }
- public required Guid RealmId { get; init; }
-}
+ public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256;
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Model/RealmDek.cs b/IdentityShroud.Core/Model/RealmDek.cs
new file mode 100644
index 0000000..92bc57b
--- /dev/null
+++ b/IdentityShroud.Core/Model/RealmDek.cs
@@ -0,0 +1,27 @@
+using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace IdentityShroud.Core.Model;
+
+
+public record RealmDek
+{
+ public required DekId Id { get; init; }
+ public required bool Active { get; set; }
+ public required KeyType Algorithm { get; init; }
+ public required EncryptedDek KeyData { get; init; }
+ public Guid RealmId { get; init; }
+}
+
+public class RealmDekConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder b)
+ {
+ b.ToTable("realm_dek");
+ b.HasKey(e => e.Id);
+ b.ComplexProperty(e => e.KeyData, e => e.IsRequired());
+ }
+}
+
diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs
deleted file mode 100644
index 3fcf2d1..0000000
--- a/IdentityShroud.Core/Model/RealmKey.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using System.ComponentModel.DataAnnotations.Schema;
-using IdentityShroud.Core.Contracts;
-using IdentityShroud.Core.Security;
-using Microsoft.EntityFrameworkCore;
-
-namespace IdentityShroud.Core.Model;
-
-
-[Table("realm_key")]
-public record RealmKey
-{
- public required Guid Id { get; init; }
- public required string KeyType { get; init; }
-
-
- public required EncryptedDek Key { get; init; }
- public required DateTime CreatedAt { get; init; }
- public DateTime? RevokedAt { get; set; }
-
- ///
- /// Key with highest priority will be used. While there is not really a use case for this I know some users
- /// are more comfortable replacing keys by using priority then directly deactivating the old key.
- ///
- public int Priority { get; set; } = 10;
-
-
-}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Model/RealmSigningKey.cs b/IdentityShroud.Core/Model/RealmSigningKey.cs
new file mode 100644
index 0000000..25b37a2
--- /dev/null
+++ b/IdentityShroud.Core/Model/RealmSigningKey.cs
@@ -0,0 +1,34 @@
+using IdentityShroud.Core.Security;
+using IdentityShroud.Core.Security.Keys;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace IdentityShroud.Core.Model;
+
+public record RealmSigningKey
+{
+ public required RealmSigningKeyId Id { get; init; }
+ public required KeyType KeyType { get; init; }
+ public required EncryptedDek Key { get; init; }
+ public required DateTime CreatedAt { get; init; }
+ public DateTime? RevokedAt { get; set; }
+ ///
+ /// Key with highest priority will be used. While there is not really a use case for this I know some users
+ /// are more comfortable replacing keys by using priority then directly deactivating the old key.
+ ///
+ public int Priority { get; set; } = 10;
+
+ public Dictionary? PublicKeyParameters { get; set; }
+}
+
+public class RealmKeyConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder b)
+ {
+ b.ToTable("realm_key");
+ b.HasKey(e => e.Id);
+
+ b.ComplexProperty(e => e.Key, e => e.IsRequired());
+ b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb");
+ }
+}
diff --git a/IdentityShroud.Core/Model/RealmSigningKeyId.cs b/IdentityShroud.Core/Model/RealmSigningKeyId.cs
new file mode 100644
index 0000000..085b9ff
--- /dev/null
+++ b/IdentityShroud.Core/Model/RealmSigningKeyId.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace IdentityShroud.Core.Model;
+
+[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))]
+public readonly record struct RealmSigningKeyId(Guid Id)
+{
+ public override string ToString() => Id.ToString("N");
+
+ public static RealmSigningKeyId NewId()
+ {
+ return new(Guid.NewGuid());
+ }
+}
+
+public class RealmSigningKeyIdJsonConverter : JsonConverter
+{
+ public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new (reader.GetGuid());
+
+ public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+}
diff --git a/IdentityShroud.Core/Plugins/PluginLoader.cs b/IdentityShroud.Core/Plugins/PluginLoader.cs
new file mode 100644
index 0000000..e216a57
--- /dev/null
+++ b/IdentityShroud.Core/Plugins/PluginLoader.cs
@@ -0,0 +1,59 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using IdentityShroud.PluginSupport;
+
+namespace IdentityShroud.Core.Plugins;
+
+public static class PluginLoader
+{
+ public static IEnumerable LoadPlugins(string pluginsFolder)
+ {
+ if (!Directory.Exists(pluginsFolder))
+ yield break;
+
+ foreach (var dll in Directory.EnumerateFiles(pluginsFolder, "*.dll"))
+ {
+ foreach (var plugin in LoadPluginDll(dll)) yield return plugin;
+ }
+ }
+
+ private static IEnumerable LoadPluginDll(string dll)
+ {
+ Assembly asm;
+ try
+ {
+ asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(dll));
+ }
+ catch
+ {
+ yield break;
+ }
+
+ IEnumerable pluginTypes;
+ try
+ {
+ pluginTypes = asm.GetTypes()
+ .Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false });
+ }
+ catch
+ {
+ yield break;
+ }
+
+ foreach (var t in pluginTypes)
+ {
+ IPlugin? instance = null;
+ try
+ {
+ instance = (IPlugin?)Activator.CreateInstance(t);
+ }
+ catch
+ {
+ // ignore bad plugin types
+ }
+
+ if (instance != null)
+ yield return instance;
+ }
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Plugins/PluginRegistry.cs b/IdentityShroud.Core/Plugins/PluginRegistry.cs
new file mode 100644
index 0000000..d58863c
--- /dev/null
+++ b/IdentityShroud.Core/Plugins/PluginRegistry.cs
@@ -0,0 +1,18 @@
+using System.Collections.ObjectModel;
+using IdentityShroud.PluginSupport;
+
+namespace IdentityShroud.Core.Plugins;
+
+///
+/// Note
+///
+///
+public class PluginRegistry where TPlugin : IPlugin
+{
+ private ReadOnlyDictionary _plugins;
+
+ public PluginRegistry(ReadOnlyDictionary plugins)
+ {
+ _plugins = plugins;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs
index 276178e..d68a985 100644
--- a/IdentityShroud.Core/Security/DekId.cs
+++ b/IdentityShroud.Core/Security/DekId.cs
@@ -1,6 +1,8 @@
namespace IdentityShroud.Core.Security;
-public record struct DekId(Guid Id)
+public readonly record struct DekId(Guid Id)
{
public static DekId NewId() => new(Guid.NewGuid());
+
+ public override string ToString() => Id.ToString("N");
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs
index 377a2f6..2e44afe 100644
--- a/IdentityShroud.Core/Security/EncryptedDek.cs
+++ b/IdentityShroud.Core/Security/EncryptedDek.cs
@@ -1,6 +1,3 @@
-using Microsoft.EntityFrameworkCore;
-
namespace IdentityShroud.Core.Security;
-[Owned]
public record EncryptedDek(KekId KekId, byte[] Value);
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs
index 173c295..03dad86 100644
--- a/IdentityShroud.Core/Security/EncryptedValue.cs
+++ b/IdentityShroud.Core/Security/EncryptedValue.cs
@@ -1,8 +1,5 @@
-using Microsoft.EntityFrameworkCore;
-
namespace IdentityShroud.Core.Security;
-[Owned]
public record EncryptedValue(DekId DekId, byte[] Value);
diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs
index 47344c1..01c8843 100644
--- a/IdentityShroud.Core/Security/Encryption.cs
+++ b/IdentityShroud.Core/Security/Encryption.cs
@@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security;
public static class Encryption
{
- private record struct AlgVersion(int Version, int NonceSize, int TagSize);
+ private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize);
private static AlgVersion[] _versions =
[
@@ -35,36 +35,45 @@ public static class Encryption
return result;
}
- public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key)
+ public static void Decrypt(ReadOnlyMemory input, ReadOnlySpan key, Span output)
{
- var payload = input.Span;
- int versionNumber = (int)payload[0];
- if (versionNumber != 1)
- throw new ArgumentException("Invalid payload");
-
- AlgVersion versionParams = _versions[versionNumber];
-
-
- if (payload.Length < 1 + versionParams.NonceSize + versionParams.TagSize)
- throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload));
+ AlgVersion versionParams = GetVersionParams(input);
+ if (input.Length < 1 + versionParams.NonceSize + versionParams.TagSize)
+ throw new ArgumentException("Cypher data is too short to be valid.", nameof(input));
+ var payload = input.Span;
ReadOnlySpan nonce = payload.Slice(1, versionParams.NonceSize);
ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize);
ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize);
- byte[] plaintext = new byte[cipher.Length];
-
using var aes = new AesGcm(key, versionParams.TagSize);
try
{
- aes.Decrypt(nonce, cipher, tag, plaintext);
+ aes.Decrypt(nonce, cipher, tag, output);
}
catch (CryptographicException ex)
{
// Tag verification failed → tampering or wrong key/nonce.
throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex);
}
+ }
- return plaintext;
+ public static int GetDecryptedLength(ReadOnlyMemory input)
+ {
+ AlgVersion versionParams = GetVersionParams(input);
+ int length = input.Length - (1 + versionParams.NonceSize + versionParams.TagSize);
+ if (length < 0)
+ throw new ArgumentException("Cypher data is too short to be valid.", nameof(input));
+
+ return length;
+ }
+
+ private static AlgVersion GetVersionParams(ReadOnlyMemory input)
+ {
+ var versionNumber = (int)input.Span[0];
+ if (versionNumber != 1)
+ throw new ArgumentException("Invalid payload");
+
+ return _versions[versionNumber];
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs
deleted file mode 100644
index dc9bc28..0000000
--- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace IdentityShroud.Core.Security;
-
-public static class JsonWebAlgorithm
-{
- public const string RS256 = "RS256";
-}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs
new file mode 100644
index 0000000..80fc37e
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs
@@ -0,0 +1,20 @@
+using System.Text.Json;
+using IdentityShroud.Core.Model;
+
+namespace IdentityShroud.Core;
+
+public interface IJwtSigner
+{
+ /*
+ Of the signature and MAC algorithms specified in JSON Web Algorithms
+ [JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by
+ conforming JWT implementations. It is RECOMMENDED that
+ implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash
+ algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256
+ hash algorithm ("ES256"). Support for other algorithms and key sizes
+ is OPTIONAL.
+ */
+ IReadOnlyList Algorithms { get; }
+
+ byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt);
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs
new file mode 100644
index 0000000..fbab369
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs
@@ -0,0 +1,6 @@
+namespace IdentityShroud.Core;
+
+public interface IJwtSignerFactory
+{
+ IJwtSigner Create(JwtSigAlgName algorithm);
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs
new file mode 100644
index 0000000..7e59dbb
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs
@@ -0,0 +1,23 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace IdentityShroud.Core;
+
+[SuppressMessage("ReSharper", "InconsistentNaming")]
+public readonly record struct JwtSigAlgName(string Name) : IEquatable
+{
+ // HMAC using SHA-???
+ public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED
+ public static JwtSigAlgName HS384 => new("HS384");
+ public static JwtSigAlgName HS512 => new("HS512");
+
+ // RSASSA-PKCS1-v1_5 using SHA-???
+ public static JwtSigAlgName RS256 => new("RS256");
+ public static JwtSigAlgName RS384 => new("RS384");
+ public static JwtSigAlgName RS512 => new("RS512");
+
+ public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256
+ public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384
+ public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512
+
+ public override string ToString() => Name;
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs
new file mode 100644
index 0000000..99b9097
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs
@@ -0,0 +1,101 @@
+using System.Buffers.Text;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using IdentityShroud.Core.Model;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace IdentityShroud.Core;
+
+public static class JwtSignatureGenerator
+{
+ ///
+ /// Generates a JWT signature using RS256 algorithm
+ ///
+ /// Base64Url encoded header
+ /// Base64Url encoded payload
+ /// RSA private key (PEM format or RSA parameters)
+ /// Base64Url encoded signature
+ public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
+ {
+ // Combine header and payload with a period
+ string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
+
+ // Convert to bytes
+ byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
+
+ // Sign the data using RSA-SHA256
+ byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ // Convert signature to Base64Url encoding
+ string signature = WebEncoders.Base64UrlEncode(signatureBytes);
+
+ return signature;
+ }
+
+ public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
+ {
+ string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
+ return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
+ }
+
+}
+
+public class JwtService(IJwtSignerFactory signerFactory)
+{
+
+ public byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, JwtSigAlgName algName, DecryptedSigningKey key)
+ {
+ // LATER might be able to improve performance using ArrayPool
+
+ IJwtSigner signer = signerFactory.Create(algName);
+ MemoryStream headerMemStream = new();
+ Utf8JsonWriter headerWriter = new(headerMemStream);
+ WriteJwtHeader(headerWriter, algName, key.Id.ToString());
+ headerWriter.Flush();
+ headerMemStream.Seek(0, SeekOrigin.Begin);
+
+ int headerBase64Length = Base64Url.GetEncodedLength((int)headerMemStream.Length);
+ int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length);
+ var jwtData = new byte[headerBase64Length + payloadBase64Length + 1];
+
+ //
+ var byteArray = new byte[headerMemStream.Length];
+ headerMemStream.ReadExactly(byteArray, 0, (int)headerMemStream.Length);
+ int written = Base64Url.EncodeToUtf8(byteArray, jwtData);
+
+ if (written != headerBase64Length)
+ throw new Exception("expected header length did not match bytes written");
+
+ jwtData[headerBase64Length] = (byte)'.';
+
+ written = Base64Url.EncodeToUtf8(payloadUtf8, jwtData.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length));
+
+ if (written != payloadBase64Length)
+ throw new Exception("expected payload length did not match bytes written");
+
+ byte[] signature = signer.CalculateSignature(algName, key, jwtData.AsSpan());
+
+ int signatureBase64Length = Base64Url.GetEncodedLength(signature.Length);
+
+ var completeJwt = new byte[jwtData.Length + 1 + signatureBase64Length];
+ Array.Copy(jwtData, completeJwt, jwtData.Length);
+ completeJwt[jwtData.Length] = (byte)'.';
+
+ written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan().Slice(jwtData.Length + 1, signatureBase64Length));
+
+ if (written != signatureBase64Length)
+ throw new Exception("expected signature length did not match bytes written");
+
+ return completeJwt;
+ }
+
+ private static void WriteJwtHeader(Utf8JsonWriter writer, JwtSigAlgName algName, string keyId)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("typ"u8, "JWT"u8);
+ writer.WriteString("alg"u8, algName.ToString());
+ writer.WriteString("kid"u8, keyId);
+ writer.WriteEndObject();
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs
new file mode 100644
index 0000000..85ffe21
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs
@@ -0,0 +1,17 @@
+namespace IdentityShroud.Core;
+
+public class JwtSignerFactory(IEnumerable signers) : IJwtSignerFactory
+{
+ private readonly IReadOnlyDictionary _signers = signers
+ .SelectMany(s => s.Algorithms.Select(alg => (alg, signer: s)))
+ .ToDictionary(x => x.alg, x => x.signer);
+
+ public IJwtSigner Create(JwtSigAlgName algorithm)
+ {
+ if (_signers.TryGetValue(algorithm, out var signer))
+ return signer;
+
+ throw new NotSupportedException($"JWT signing algorithm '{algorithm}' is not registered.");
+ }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs
new file mode 100644
index 0000000..80af03c
--- /dev/null
+++ b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs
@@ -0,0 +1,36 @@
+using System.Security.Cryptography;
+using IdentityShroud.Core.Model;
+
+namespace IdentityShroud.Core;
+
+public class RsaJwtSigner : IJwtSigner
+{
+ public IReadOnlyList Algorithms => [JwtSigAlgName.RS256, JwtSigAlgName.RS384, JwtSigAlgName.RS512];
+
+ // +-------------------+---------------------------------+
+ // | "alg" Param Value | Digital Signature Algorithm |
+ // +-------------------+---------------------------------+
+ // | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 |
+ // | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 |
+ // | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 |
+ // +-------------------+---------------------------------+
+
+ public byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt)
+ {
+ using var rsa = RSA.Create();
+ rsa.ImportPkcs8PrivateKey(key.KeyData, out int _);
+ var sig = new byte[rsa.KeySize / 8];
+ rsa.SignData(jwt, sig, GetHashAlgorithmName(algName), RSASignaturePadding.Pkcs1);
+ return sig;
+ }
+
+ private static HashAlgorithmName GetHashAlgorithmName(JwtSigAlgName algName)
+ => algName.Name switch
+ {
+ "RS256" => HashAlgorithmName.SHA256,
+ "RS384" => HashAlgorithmName.SHA384,
+ "RS512" => HashAlgorithmName.SHA512,
+ _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider")
+ };
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs
deleted file mode 100644
index e22cfca..0000000
--- a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System.Security.Cryptography;
-using System.Text;
-using Microsoft.AspNetCore.WebUtilities;
-
-namespace IdentityShroud.Core;
-
-public static class JwtSignatureGenerator
-{
- ///
- /// Generates a JWT signature using RS256 algorithm
- ///
- /// Base64Url encoded header
- /// Base64Url encoded payload
- /// RSA private key (PEM format or RSA parameters)
- /// Base64Url encoded signature
- public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
- {
- // Combine header and payload with a period
- string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
-
- // Convert to bytes
- byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
-
- // Sign the data using RSA-SHA256
- byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
-
- // Convert signature to Base64Url encoding
- string signature = WebEncoders.Base64UrlEncode(signatureBytes);
-
- return signature;
- }
-
- public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
- {
- string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
- return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
- }
-}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs
new file mode 100644
index 0000000..5e44402
--- /dev/null
+++ b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs
@@ -0,0 +1,10 @@
+namespace IdentityShroud.Core.Security.Keys.Aes;
+
+public class AesKeyPolicy : KeyPolicy
+{
+ public AesKeyPolicy()
+ {
+ KeyType = KeyType.AES;
+ KeySize = 256;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs
new file mode 100644
index 0000000..b30428f
--- /dev/null
+++ b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs
@@ -0,0 +1,19 @@
+using System.Security.Cryptography;
+using IdentityShroud.Core.Messages;
+
+namespace IdentityShroud.Core.Security.Keys.Aes;
+
+public class AesProvider : IKeyProvider
+{
+ public bool IsPublic => false;
+ public KeyData CreateKey(KeyPolicy policy)
+ {
+ return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8));
+ }
+
+ public void SetJwkParameters(Dictionary parameters, JsonWebKey jwk)
+ {
+ // Can we use this for Jwe?
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs
index 8e32309..6a5ce45 100644
--- a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs
+++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs
@@ -2,17 +2,32 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys;
-public abstract class KeyPolicy
+public class KeyPolicy
{
- public abstract string KeyType { get; }
+ public KeyType KeyType { get; protected init; }
+ public int KeySize { get; protected init; }
+}
+
+public record KeyData(byte[] PrivateKey, Dictionary? PublicKeyParameters = null)
+{
+ ///
+ /// The data to be kept private, also used for symmetric keys
+ ///
+ public byte[] PrivateKey { get; set; } = PrivateKey;
+
+ public Dictionary? PublicKeyParameters { get; set; } = PublicKeyParameters;
}
public interface IKeyProvider
{
- byte[] CreateKey(KeyPolicy policy);
+ ///
+ /// Returns true when this key uses public key cryptography
+ ///
+ bool IsPublic { get; }
+ KeyData CreateKey(KeyPolicy policy);
- void SetJwkParameters(byte[] key, JsonWebKey jwk);
+ void SetJwkParameters(Dictionary parameters, JsonWebKey jwk);
}
diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs
index 485e6e5..c39a836 100644
--- a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs
+++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs
@@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys;
public interface IKeyProviderFactory
{
- public IKeyProvider CreateProvider(string keyType);
+ public IKeyProvider CreateProvider(KeyType keyType);
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs
index a1c3472..33d5092 100644
--- a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs
+++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs
@@ -1,15 +1,18 @@
+using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa;
namespace IdentityShroud.Core.Security.Keys;
public class KeyProviderFactory : IKeyProviderFactory
{
- public IKeyProvider CreateProvider(string keyType)
+ public IKeyProvider CreateProvider(KeyType keyType)
{
- switch (keyType)
+ switch (keyType.Name)
{
case "RSA":
return new RsaProvider();
+ case "AES":
+ return new AesProvider();
default:
throw new NotImplementedException();
}
diff --git a/IdentityShroud.Core/Security/Keys/KeyType.cs b/IdentityShroud.Core/Security/Keys/KeyType.cs
new file mode 100644
index 0000000..224e989
--- /dev/null
+++ b/IdentityShroud.Core/Security/Keys/KeyType.cs
@@ -0,0 +1,21 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace IdentityShroud.Core.Security.Keys;
+
+[JsonConverter(typeof(KeyTypeJsonConverter))]
+public readonly record struct KeyType(string Name)
+{
+ public static KeyType AES => new("AES");
+ public static KeyType RSA => new("RSA");
+ public override string ToString() => Name;
+}
+
+public class KeyTypeJsonConverter : JsonConverter
+{
+ public override KeyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new KeyType(reader.GetString()!);
+
+ public override void Write(Utf8JsonWriter writer, KeyType value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+}
diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs
new file mode 100644
index 0000000..0e2919c
--- /dev/null
+++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs
@@ -0,0 +1,10 @@
+namespace IdentityShroud.Core.Security.Keys.Rsa;
+
+public class RsaKeyPolicy : KeyPolicy
+{
+ public RsaKeyPolicy()
+ {
+ KeyType = KeyType.RSA;
+ KeySize = 2048;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs
index daf2b7f..717f9de 100644
--- a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs
+++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs
@@ -4,32 +4,31 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys.Rsa;
-public class RsaKeyPolicy : KeyPolicy
-{
- public override string KeyType => "RSA";
- public int KeySize { get; } = 2048;
-}
-
public class RsaProvider : IKeyProvider
{
- public byte[] CreateKey(KeyPolicy policy)
+ public bool IsPublic => true;
+
+ public KeyData CreateKey(KeyPolicy policy)
{
if (policy is RsaKeyPolicy p)
{
using var rsa = RSA.Create(p.KeySize);
- return rsa.ExportPkcs8PrivateKey();
+ var publicParamaters = rsa.ExportParameters(includePrivateParameters: false);
+ return new KeyData(
+ rsa.ExportPkcs8PrivateKey(),
+ new()
+ {
+ ["e"] = Base64Url.EncodeToString(publicParamaters.Exponent),
+ ["n"] = Base64Url.EncodeToString(publicParamaters.Modulus),
+ });
}
throw new ArgumentException("Incorrect policy type", nameof(policy));
}
- public void SetJwkParameters(byte[] key, JsonWebKey jwk)
+ public void SetJwkParameters(Dictionary parameters, JsonWebKey jwk)
{
- using var rsa = RSA.Create();
- rsa.ImportPkcs8PrivateKey(key, out _);
- var parameters = rsa.ExportParameters(includePrivateParameters: false);
-
- jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
- jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
+ jwk.Exponent = parameters["e"];
+ jwk.Modulus = parameters["n"];
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs
index 0887ccd..61be016 100644
--- a/IdentityShroud.Core/Services/ClientService.cs
+++ b/IdentityShroud.Core/Services/ClientService.cs
@@ -1,5 +1,7 @@
using System.Security.Cryptography;
+using FluentValidation;
using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore;
@@ -8,24 +10,35 @@ namespace IdentityShroud.Core.Services;
public class ClientService(
Db db,
IDataEncryptionService cryptor,
+ IValidator clientCreateValidator,
IClock clock) : IClientService
{
public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default)
{
+ clientCreateValidator.ValidateAndThrow(request);
+
+ Realm realm = await db.Realms.FirstOrDefaultAsync(e => e.Id == realmId, ct)
+ ?? throw new InvalidOperationException("Require the id of an existing realm");
+
Client client = new()
{
RealmId = realmId,
ClientId = request.ClientId,
Name = request.Name,
Description = request.Description,
- SignatureAlgorithm = request.SignatureAlgorithm,
- AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
+ SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm),
+ Confidential = request.Confidential,
+ AllowClientCredentialsFlow = request.AllowClientCredentialsFlow,
CreatedAt = clock.UtcNow(),
};
- if (client.AllowClientCredentialsFlow)
+ if (request.GenerateSecret is true)
{
- client.Secrets.Add(CreateSecret());
+ await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
+ .Query()
+ .LoadAsync(ct);
+
+ client.Secrets.Add(CreateSecret(realm));
}
await db.AddAsync(client, ct);
@@ -50,15 +63,17 @@ public class ClientService(
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct);
}
- private ClientSecret CreateSecret()
+ private ClientSecret CreateSecret(Realm realm)
{
Span secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret);
+
+ var dek = realm.DataEncryptionKeys.Single(k => k.Active);
return new ClientSecret()
{
CreatedAt = clock.UtcNow(),
- Secret = cryptor.Encrypt(secret.ToArray()),
+ Secret = cryptor.Encrypt(dek, secret),
};
}
diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs
index a06cbae..be0cf51 100644
--- a/IdentityShroud.Core/Services/DataEncryptionService.cs
+++ b/IdentityShroud.Core/Services/DataEncryptionService.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
@@ -5,37 +6,42 @@ using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
public class DataEncryptionService(
- IRealmContext realmContext,
IDekEncryptionService dekCryptor) : IDataEncryptionService
{
-
- // Note this array is expected to have one item in it most of the during key rotation it will have two
- // until it is ensured the old key can safely be removed. More then two will work but is not really expected.
- private IList? _deks = null;
-
- private IList GetDeks()
+ public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain)
{
- if (_deks is null)
- _deks = realmContext.GetDeks().Result;
-
- return _deks;
+ Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)];
+ try
+ {
+ dekCryptor.Decrypt(dek.KeyData, key);
+ byte[] cipher = Encryption.Encrypt(plain, key);
+ return new (dek.Id, cipher);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
}
- private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active);
- private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id);
-
- public byte[] Decrypt(EncryptedValue input)
+ public byte[] Decrypt(IReadOnlyList deks, EncryptedValue input)
{
- var dek = GetKey(input.DekId);
- var key = dekCryptor.Decrypt(dek.KeyData);
- return Encryption.Decrypt(input.Value, key);
- }
+ // Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like
+ // - Old key removed before migration completed (should not be possible)
+ // - Wrong keyset because of programming error.
+ var dek = deks.SingleOrDefault(d => d.Id == input.DekId)
+ ?? throw new InvalidOperationException("Required key not found");
- public EncryptedValue Encrypt(ReadOnlySpan plain)
- {
- var dek = GetActiveDek();
- var key = dekCryptor.Decrypt(dek.KeyData);
- byte[] cipher = Encryption.Encrypt(plain, key);
- return new (dek.Id, cipher);
+ Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)];
+ try
+ {
+ dekCryptor.Decrypt(dek.KeyData, key);
+ byte[] output = new byte[Encryption.GetDecryptedLength(input.Value)];
+ Encryption.Decrypt(input.Value, key, output);
+ return output;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs
index add9267..b80ea4d 100644
--- a/IdentityShroud.Core/Services/DekEncryptionService.cs
+++ b/IdentityShroud.Core/Services/DekEncryptionService.cs
@@ -18,8 +18,6 @@ public class DekEncryptionService : IDekEncryptionService
public DekEncryptionService(ISecretProvider secretProvider)
{
_encryptionKeys = secretProvider.GetKeys("master");
- // if (_encryptionKey.Length != 32) // 256‑bit key
- // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM.");
}
public EncryptedDek Encrypt(ReadOnlySpan plaintext)
@@ -29,10 +27,14 @@ public class DekEncryptionService : IDekEncryptionService
return new (encryptionKey.Id, cipher);
}
- public byte[] Decrypt(EncryptedDek input)
+ public void Decrypt(EncryptedDek input, Span output)
{
var encryptionKey = GetKey(input.KekId);
+ Encryption.Decrypt(input.Value, encryptionKey.Key, output);
+ }
- return Encryption.Decrypt(input.Value, encryptionKey.Key);
+ public int GetDecryptedSize(EncryptedDek input)
+ {
+ return Encryption.GetDecryptedLength(input.Value);
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs
index a2ce9dc..10900dd 100644
--- a/IdentityShroud.Core/Services/KeyService.cs
+++ b/IdentityShroud.Core/Services/KeyService.cs
@@ -1,46 +1,16 @@
using IdentityShroud.Core.Contracts;
-using IdentityShroud.Core.Messages;
-using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
public class KeyService(
- IDekEncryptionService cryptor,
- IKeyProviderFactory keyProviderFactory,
- IClock clock) : IKeyService
+ IKeyProviderFactory keyProviderFactory) : IKeyService
{
- public RealmKey CreateKey(KeyPolicy policy)
+ public CreateKeyResponse CreateKey(KeyPolicy policy)
{
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
- var plainKey = provider.CreateKey(policy);
+ KeyData plainKey = provider.CreateKey(policy);
- return CreateKey(policy.KeyType, plainKey);
+ return new CreateKeyResponse(policy.KeyType, plainKey);
}
-
- public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
- {
- JsonWebKey jwk = new()
- {
- KeyId = realmKey.Id.ToString(),
- KeyType = realmKey.KeyType,
- Use = "sig",
- };
-
- IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
- provider.SetJwkParameters(
- cryptor.Decrypt(realmKey.Key),
- jwk);
-
- return jwk;
- }
-
- private RealmKey CreateKey(string keyType, byte[] plainKey) =>
- new RealmKey()
- {
- Id = Guid.NewGuid(),
- KeyType = keyType,
- Key = cryptor.Encrypt(plainKey),
- CreatedAt = clock.UtcNow(),
- };
}
diff --git a/IdentityShroud.Core/Services/OpenId/TokenService.cs b/IdentityShroud.Core/Services/OpenId/TokenService.cs
new file mode 100644
index 0000000..964b1d9
--- /dev/null
+++ b/IdentityShroud.Core/Services/OpenId/TokenService.cs
@@ -0,0 +1,30 @@
+namespace IdentityShroud.Core.Services.OpenId;
+
+public interface ITokenService
+{
+ Task> Handle(
+ Dictionary form,
+ string? basicAuthUser,
+ string? basicAuthPassword,
+ CancellationToken ct = default);
+}
+
+public class TokenService : ITokenService
+{
+ public async Task> Handle(
+ Dictionary form,
+ string? basicAuthUser,
+ string? basicAuthPassword,
+ CancellationToken ct = default)
+ {
+ return new();
+ }
+
+ public async Task> ClientCredentialsFlow(
+ string clientId,
+ string clientSecret,
+ CancellationToken ct = default)
+ {
+ return new();
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs
index 7daa399..8c5de16 100644
--- a/IdentityShroud.Core/Services/RealmContext.cs
+++ b/IdentityShroud.Core/Services/RealmContext.cs
@@ -16,11 +16,11 @@ public class RealmContext(
public async Task> GetDeks(CancellationToken ct = default)
{
Realm realm = GetRealm();
- if (realm.Deks.Count == 0)
+ if (realm.DataEncryptionKeys.Count == 0)
{
await realmService.LoadDeks(realm);
}
- return realm.Deks;
+ return realm.DataEncryptionKeys;
}
}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs
index 949c9fe..9dd3ba8 100644
--- a/IdentityShroud.Core/Services/RealmService.cs
+++ b/IdentityShroud.Core/Services/RealmService.cs
@@ -1,18 +1,21 @@
using IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
+using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
+using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services;
-public record RealmCreateResponse(Guid Id, string Slug, string Name);
-
public class RealmService(
Db db,
- IKeyService keyService) : IRealmService
+ IKeyService keyService,
+ IDekEncryptionService dekCryptor,
+ IClock clock) : IRealmService
{
public async Task FindById(Guid id, CancellationToken ct = default)
{
@@ -26,7 +29,7 @@ public class RealmService(
.SingleOrDefaultAsync(r => r.Slug == slug, ct);
}
- public async Task> Create(RealmCreateRequest request, CancellationToken ct = default)
+ public async Task> Create(RealmCreateRequest request, CancellationToken ct = default)
{
Realm realm = new()
{
@@ -35,26 +38,52 @@ public class RealmService(
Name = request.Name,
};
- realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
+ realm.TokenSigningKeys.Add(CreateSigningKey(realm));
+ realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm));
db.Add(realm);
await db.SaveChangesAsync(ct);
-
- return new RealmCreateResponse(
- realm.Id, realm.Slug, realm.Name);
+
+ return realm;
}
+
+ private RealmSigningKey CreateSigningKey(Realm realm)
+ {
+ var k = keyService.CreateKey(GetSigningKeyPolicy(realm));
+ return new RealmSigningKey
+ {
+ Id = RealmSigningKeyId.NewId(),
+ KeyType = k.KeyType,
+ Key = dekCryptor.Encrypt(k.Key.PrivateKey),
+ PublicKeyParameters = k.Key.PublicKeyParameters,
+ CreatedAt = clock.UtcNow(),
+ };
+ }
+
+ private RealmDek CreateDataEncryptionKey(Realm realm)
+ {
+ var k = keyService.CreateKey(GetDataKeyPolicy(realm));
+ return new RealmDek()
+ {
+ Id = DekId.NewId(),
+ Active = true,
+ Algorithm = k.KeyType,
+ KeyData = dekCryptor.Encrypt(k.Key.PrivateKey),
+ };
+ }
+
///
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
///
///
///
- private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
-
+ private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy();
+ private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy();
public async Task LoadActiveKeys(Realm realm)
{
- await db.Entry(realm).Collection(r => r.Keys)
+ await db.Entry(realm).Collection(r => r.TokenSigningKeys)
.Query()
.Where(k => k.RevokedAt == null)
.LoadAsync();
@@ -62,7 +91,7 @@ public class RealmService(
public async Task LoadDeks(Realm realm)
{
- await db.Entry(realm).Collection(r => r.Deks)
+ await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
.Query()
.LoadAsync();
}
diff --git a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj
new file mode 100644
index 0000000..1ba525f
--- /dev/null
+++ b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/IdentityShroud.GraphQL/Query.cs b/IdentityShroud.GraphQL/Query.cs
new file mode 100644
index 0000000..5ccbeb1
--- /dev/null
+++ b/IdentityShroud.GraphQL/Query.cs
@@ -0,0 +1,26 @@
+susing IdentityShroud.Core.Contracts;
+using IdentityShroud.Core.Model;
+
+namespace IdentityShroud.GraphQL;
+
+public class Query
+{
+ public string GetHello() => "Hello, world!";
+
+ public async Task GetRealms(
+ Guid id,
+ [Service] IRealmService realmService)
+ {
+ return await realmService.FindById(id);
+ }
+}
+
+public class Mutation
+{
+ public async Task RealmCreate(string name)
+ {
+ Realm r = new();
+
+ return r;
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.GraphQL/RegistrationExtensions.cs b/IdentityShroud.GraphQL/RegistrationExtensions.cs
new file mode 100644
index 0000000..dad3056
--- /dev/null
+++ b/IdentityShroud.GraphQL/RegistrationExtensions.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace IdentityShroud.GraphQL;
+
+public static class RegistrationExtensions
+{
+ extension(IServiceCollection services)
+ {
+ public IServiceCollection AddIdentityShroudGraphQL()
+ {
+ services
+ .AddGraphQLServer()
+ .AddMutationConventions(applyToAllMutations: true)
+ .AddMutationType()
+ .AddQueryType();
+
+ return services;
+ }
+ }
+
+ extension(IEndpointRouteBuilder app)
+ {
+ public IEndpointRouteBuilder MapIdentityShroudGraphQL()
+ {
+ app.MapGraphQL();
+ return app;
+ }
+ }
+}
\ No newline at end of file
diff --git a/IdentityShroud.Migrations/DesignTimeDbFactory.cs b/IdentityShroud.Migrations/DesignTimeDbFactory.cs
index 9459610..e03d3ef 100644
--- a/IdentityShroud.Migrations/DesignTimeDbFactory.cs
+++ b/IdentityShroud.Migrations/DesignTimeDbFactory.cs
@@ -1,4 +1,4 @@
-using IdentityShroud.Core;
+using IdentityShroud.Core.EFCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
diff --git a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj
index f4583e2..8cc28ca 100644
--- a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj
+++ b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -7,7 +7,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs
new file mode 100644
index 0000000..6c3df6d
--- /dev/null
+++ b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs
@@ -0,0 +1,318 @@
+//
+using System;
+using System.Collections.Generic;
+using IdentityShroud.Core.EFCore;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IdentityShroud.Migrations.Migrations
+{
+ [DbContext(typeof(Db))]
+ [Migration("20260412083710_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IdentityShroud.Core.Model.Client", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowClientCredentialsFlow")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_client_credentials_flow");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("client_id");
+
+ b.Property("Confidential")
+ .HasColumnType("boolean")
+ .HasColumnName("confidential");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("description");
+
+ b.Property("Name")
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name");
+
+ b.Property("RealmId")
+ .HasColumnType("uuid")
+ .HasColumnName("realm_id");
+
+ b.Property("SignatureAlgorithm")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("signature_algorithm");
+
+ b.HasKey("Id")
+ .HasName("pk_client");
+
+ b.HasIndex("ClientId")
+ .IsUnique()
+ .HasDatabaseName("ix_client_client_id");
+
+ b.HasIndex("RealmId")
+ .HasDatabaseName("ix_client_realm_id");
+
+ b.ToTable("client", (string)null);
+ });
+
+ modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("uuid")
+ .HasColumnName("client_id");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer")
+ .HasColumnName("client_id1");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.ComplexProperty(typeof(Dictionary), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DekId")
+ .HasColumnType("uuid")
+ .HasColumnName("secret_dek_id");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("secret_value");
+ });
+
+ b.HasKey("Id")
+ .HasName("pk_client_secret");
+
+ b.HasIndex("ClientId1")
+ .HasDatabaseName("ix_client_secret_client_id1");
+
+ b.ToTable("client_secret", (string)null);
+ });
+
+ modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DefaultSignatureAlgorithm")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("default_signature_algorithm");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("slug");
+
+ b.HasKey("Id")
+ .HasName("pk_realm");
+
+ b.ToTable("realm", (string)null);
+ });
+
+ modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Active")
+ .HasColumnType("boolean")
+ .HasColumnName("active");
+
+ b.Property("Algorithm")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("algorithm");
+
+ b.Property("RealmId")
+ .HasColumnType("uuid")
+ .HasColumnName("realm_id");
+
+ b.ComplexProperty(typeof(Dictionary), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("KekId")
+ .HasColumnType("uuid")
+ .HasColumnName("key_data_kek_id");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("key_data_value");
+ });
+
+ b.HasKey("Id")
+ .HasName("pk_realm_dek");
+
+ b.HasIndex("RealmId")
+ .HasDatabaseName("ix_realm_dek_realm_id");
+
+ b.ToTable("realm_dek", (string)null);
+ });
+
+ modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("KeyType")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("key_type");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("PublicKeyParameters")
+ .HasColumnType("jsonb")
+ .HasColumnName("public_key_parameters");
+
+ b.Property("RealmId")
+ .HasColumnType("uuid")
+ .HasColumnName("realm_id");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.ComplexProperty(typeof(Dictionary), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("KekId")
+ .HasColumnType("uuid")
+ .HasColumnName("key_kek_id");
+
+ b1.Property