Compare commits

..

17 commits

Author SHA1 Message Date
eelke
d8f6024afd Cleanup 2026-02-27 18:54:01 +01:00
eelke
1cd7fb659a Improve test coverage 2026-02-27 18:50:28 +01:00
eelke
ccc00d8e80 Pass Span instead of Memory 2026-02-26 20:41:59 +01:00
eelke
650fe99990 Encrypt realm data with dek which is encrypted with kek. The signing keys are also encrypted with the kek. 2026-02-26 19:46:14 +01:00
eelke
644b005f2a Support rotation of master key.
The EncryptionService now loads a set of keys and uses the active one to encrypt and selects key based on keyid during decryption. Introduced EncryptedValue to hold keyId and encrypted data.

(There are no intermeddiate keys yet)
2026-02-24 06:32:58 +01:00
eelke
4201d0240d Improve the binary storage format of encrypted secrets. Move the related code from AesGcmHelper into the EncryptionService. 2026-02-22 19:11:17 +01:00
eelke
ac08956339 No codecov (AI was over eager) just show the numbers in github. 2026-02-22 11:30:54 +01:00
eelke
21b53ff5b3 Fix injection of encryption secret 2026-02-22 09:58:20 +01:00
eelke
c2a2184353 Another reference fix 2026-02-22 09:49:10 +01:00
eelke
4b493ee28d Fix library reference 2026-02-22 09:46:09 +01:00
eelke
72dbc5acbf Add github job to run tests 2026-02-22 09:39:43 +01:00
Eelke76
ad2e952125
Merge pull request #1 from Eelke76/client-service-and-api
client-service-and-api
2026-02-22 09:31:43 +01:00
eelke
e0f6f3f8a9 Cleanup 2026-02-22 09:28:05 +01:00
eelke
3d73a9914c Tests voor client api and service 2026-02-22 09:27:57 +01:00
eelke
cd2ec646fd Add some tests 2026-02-22 08:21:54 +01:00
eelke
0c6f227049 Reworked code around signing keys have key details much more isolated from the other parts of the program. 2026-02-21 20:15:46 +01:00
eelke
eb872a4f44 WIP making ClientCreate endpoint 2026-02-20 17:35:38 +01:00
115 changed files with 604 additions and 2834 deletions

View file

@ -1,2 +0,0 @@
[*.cs]
resharper_naming_rules.abbreviations = QL, DB

View file

@ -1,36 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="HotChocolate.AspNetCore" Version="15.1.14" />
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
<PackageVersion Include="Scrutor" Version="7.0.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<PackageVersion Include="Testcontainers" Version="4.10.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.10.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.assert" Version="3.2.2" />
</ItemGroup>
</Project>

View file

@ -1,24 +1,16 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using IdentityShroud.Core;
using System.Text.Json;
using FluentResults;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace IdentityShroud.Api.Tests.Apis; namespace IdentityShroud.Api.Tests.Apis;
public class ClientApiTests : IClassFixture<ApplicationFactory> public class ClientApiTests : IClassFixture<ApplicationFactory>
{ {
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private readonly ApplicationFactory _factory; private readonly ApplicationFactory _factory;
public ClientApiTests(ApplicationFactory factory) public ClientApiTests(ApplicationFactory factory)
@ -40,7 +32,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
public async Task Create_Validation(string? clientId, bool succeeds, string fieldName) public async Task Create_Validation(string? clientId, bool succeeds, string fieldName)
{ {
// setup // setup
var realm = await CreateRealmAsync("test-realm", "Test Realm"); Realm realm = await CreateRealmAsync("test-realm", "Test Realm");
var client = _factory.CreateClient(); var client = _factory.CreateClient();
@ -72,56 +64,32 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
[Fact] [Fact]
public async Task Create_Success_ReturnsCreatedWithLocation() public async Task Create_Success_ReturnsCreatedWithLocation()
{ {
// setup
Realm realm = await CreateRealmAsync("create-realm", "Create Realm");
var client = _factory.CreateClient();
// act // act
var body = await DoCreateRequest(""" var response = await client.PostAsync(
{ $"/api/v1/realms/{realm.Id}/clients",
"clientId": "new-client", JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }),
"name": "New Client" TestContext.Current.CancellationToken);
}
"""); #if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
// verify // verify
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ClientCreateReponse>(
TestContext.Current.CancellationToken);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal("new-client", body.ClientId); Assert.Equal("new-client", body.ClientId);
Assert.True(body.Id > 0); 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<ClientRepresentation?> 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<ClientRepresentation>(contents, _jsonOptions);
}
[Fact] [Fact]
public async Task Create_UnknownRealm_ReturnsNotFound() public async Task Create_UnknownRealm_ReturnsNotFound()
{ {
@ -139,7 +107,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
public async Task Get_Success() public async Task Get_Success()
{ {
// setup // setup
var realm = await CreateRealmAsync("get-realm", "Get Realm"); Realm realm = await CreateRealmAsync("get-realm", "Get Realm");
Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client"); Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client");
var httpClient = _factory.CreateClient(); var httpClient = _factory.CreateClient();
@ -170,7 +138,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
public async Task Get_UnknownClient_ReturnsNotFound() public async Task Get_UnknownClient_ReturnsNotFound()
{ {
// setup // setup
var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
var httpClient = _factory.CreateClient(); var httpClient = _factory.CreateClient();
@ -186,11 +154,11 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
private async Task<Realm> CreateRealmAsync(string slug, string name) private async Task<Realm> CreateRealmAsync(string slug, string name)
{ {
using var scope = _factory.Services.CreateScope(); using var scope = _factory.Services.CreateScope();
var realmService = scope.ServiceProvider.GetRequiredService<IRealmService>(); var db = scope.ServiceProvider.GetRequiredService<Db>();
Result<Realm> result = await realmService.Create( var realm = new Realm { Slug = slug, Name = name };
new(null, slug, name), db.Realms.Add(realm);
TestContext.Current.CancellationToken); await db.SaveChangesAsync(TestContext.Current.CancellationToken);
return ResultAssert.Success(result); return realm;
} }
private async Task<Client> CreateClientAsync(Realm realm, string clientId, string? name = null) private async Task<Client> CreateClientAsync(Realm realm, string clientId, string? name = null)

View file

@ -1,123 +0,0 @@
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<ApplicationFactory>
{
private readonly ApplicationFactory _factory;
public OpenIdApiTests(ApplicationFactory factory)
{
_factory = factory;
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Db>();
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<RealmRepresentation>(
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<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", "secret"),
new KeyValuePair<string, string>("response_type", "token"),
new KeyValuePair<string, string>("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<TokenResponse>();
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; }
}
}

View file

@ -1,13 +1,14 @@
using System.Buffers.Text;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using IdentityShroud.Core.EFCore; using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts; using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -123,16 +124,28 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
[Fact] [Fact]
public async Task GetJwks() public async Task GetJwks()
{ {
var client = _factory.CreateClient(); // setup
var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService<IDekEncryptionService>();
using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new()
{ {
Slug = "foo", Id = Guid.NewGuid(),
Name = "Test'", KeyType = "RSA",
}), Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
TestContext.Current.CancellationToken); CreatedAt = DateTime.UtcNow,
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); };
await ScopedContextAsync(async db =>
{
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
});
// act // act
var client = _factory.CreateClient();
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -140,16 +153,9 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken); JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload); Assert.NotNull(payload);
string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString(); JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
Assert.NotNull(kid); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
Assert.True(kid.Length >= 16); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
//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( private async Task ScopedContextAsync(
@ -160,22 +166,4 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
var db = scope.ServiceProvider.GetRequiredService<Db>(); var db = scope.ServiceProvider.GetRequiredService<Db>();
await action(db); 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);
}
} }

View file

@ -1,4 +1,3 @@
using IdentityShroud.Api;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View file

@ -1,20 +0,0 @@
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);
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -8,20 +8,20 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" /> <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NSubstitute" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers" /> <PackageReference Include="Testcontainers" Version="4.10.0" />
<PackageReference Include="Testcontainers.PostgreSql" /> <PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
<Using Include="NSubstitute" /> <Using Include="NSubstitute"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,46 @@
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));
}
}

View file

@ -5,7 +5,11 @@ using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api.Apis; namespace IdentityShroud.Api;
public record ClientCreateReponse(int Id, string ClientId);
/// <summary> /// <summary>
/// The part of the api below realms/{slug}/clients /// The part of the api below realms/{slug}/clients
@ -19,9 +23,9 @@ public static class ClientApi
RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate) clientsGroup.MapPost("", ClientCreate)
.Produces(StatusCodes.Status201Created)
.Validate<ClientCreateRequest>() .Validate<ClientCreateRequest>()
.WithName("ClientCreate"); .WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
var clientIdGroup = clientsGroup.MapGroup("{clientId}") var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>(); .AddEndpointFilter<ClientIdValidationFilter>();
@ -39,12 +43,11 @@ public static class ClientApi
return TypedResults.Ok(new ClientMapper().ToDto(client)); return TypedResults.Ok(new ClientMapper().ToDto(client));
} }
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>> private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
ClientCreate( ClientCreate(
Guid realmId, Guid realmId,
ClientCreateRequest request, ClientCreateRequest request,
[FromServices] IClientService service, [FromServices] IClientService service,
[FromServices] IDataEncryptionService cryptor,
HttpContext context, HttpContext context,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -57,12 +60,9 @@ public static class ClientApi
} }
Client client = result.Value; 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( return TypedResults.CreatedAtRoute(
clientRepresentation, new ClientCreateReponse(client.Id, client.ClientId),
ClientGetRouteName, ClientGetRouteName,
new RouteValueDictionary() new RouteValueDictionary()
{ {
@ -70,28 +70,4 @@ public static class ClientApi
["clientId"] = client.Id, ["clientId"] = client.Id,
}); });
} }
private static ClientSecret? SelectBestSecret(List<ClientSecret> 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;
}
} }

View file

@ -10,10 +10,7 @@ public record ClientRepresentation
public string? SignatureAlgorithm { get; set; } public string? SignatureAlgorithm { get; set; }
public bool Confidential { get; set; }
public bool AllowClientCredentialsFlow { get; set; } = false; public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; } public required DateTime CreatedAt { get; set; }
public string? Secret { get; set; }
} }

View file

@ -1,3 +0,0 @@
namespace IdentityShroud.Api.Apis;
public record ErrorDto(string Error);

View file

@ -1,6 +0,0 @@
namespace IdentityShroud.Api.Apis;
public record RealmRepresentation(
Guid Id,
string Slug,
string Name);

View file

@ -1,21 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.DTO.OpenId;
public class TokenRequestBody
{
[JsonPropertyName("grant_type")]
public GrantTypes GrantType { get; init; }
/// <summary>
/// In most cases required but not when basic auth header is used
/// </summary>
[JsonPropertyName("client_id")]
public string? ClientId { get; init; } = "";
[JsonPropertyName("client_secret")]
public string? ClientSecret { get; init; }
[JsonPropertyName("scope")]
public string? Scope { get; init; }
}

View file

@ -2,9 +2,8 @@ namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions public static class EndpointRouteBuilderExtensions
{ {
public static IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder) public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
where TDto : class => builder.AddEndpointFilter<ValidateFilter<TDto>>();
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
public static void MapApis(this IEndpointRouteBuilder erp) public static void MapApis(this IEndpointRouteBuilder erp)
{ {

View file

@ -1,50 +0,0 @@
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<char> val = authorizationHeader.AsSpan(6); // basic + space
Span<byte> 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;
}
}

View file

@ -1,38 +0,0 @@
namespace IdentityShroud.Api.Apis.ISResults;
public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult
{
private readonly List<string> _wwwAuthenticateValues;
/// <summary>
/// Initializes a new instance of the <see cref="UnauthorizedHttpResult"/> class.
/// </summary>
internal ISUnauthorizedHttpResult(List<string> wwwAuthenticateValues)
{
_wwwAuthenticateValues = wwwAuthenticateValues;
}
/// <summary>
/// Gets the HTTP status code: <see cref="StatusCodes.Status401Unauthorized"/>
/// </summary>
public int StatusCode => StatusCodes.Status401Unauthorized;
int? IStatusCodeHttpResult.StatusCode => StatusCode;
/// <inheritdoc />
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<ILoggerFactory>();
// 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;
}
}

View file

@ -6,9 +6,6 @@ namespace IdentityShroud.Api.Mappers;
[Mapper] [Mapper]
public partial class ClientMapper public partial class ClientMapper
{ {
// skipping secret as we do not have the DEK
[MapperIgnoreSource(nameof(Client.Secrets))] [MapperIgnoreSource(nameof(Client.Secrets))]
[MapperIgnoreTarget(nameof(ClientRepresentation.Secret))]
public partial ClientRepresentation ToDto(Client client); public partial ClientRepresentation ToDto(Client client);
} }

View file

@ -1,28 +1,20 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Api.Mappers; namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IKeyProviderFactory keyProviderFactory) public class KeyMapper(IKeyService keyService)
{ {
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmSigningKey> keys) public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
{ {
JsonWebKeySet wks = new(); JsonWebKeySet wks = new();
foreach (var k in keys) foreach (var k in keys)
{ {
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType); var wk = keyService.CreateJsonWebKey(k);
if (provider.IsPublic) if (wk is {})
{ {
JsonWebKey jwk = new() wks.Keys.Add(wk);
{
KeyId = k.Id.ToString(),
KeyType = k.KeyType,
Use = "sig",
};
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
wks.Keys.Add(jwk);
} }
} }
return wks; return wks;

View file

@ -1,11 +1,7 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Api.Apis.ISResults;
using IdentityShroud.Api.Helpers;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services.OpenId;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -15,6 +11,8 @@ public static class OpenIdEndpoints
{ {
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration // openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
public static void MapEndpoints(this IEndpointRouteBuilder erp) public static void MapEndpoints(this IEndpointRouteBuilder erp)
{ {
var realmsGroup = erp.MapGroup("/auth/realms"); var realmsGroup = erp.MapGroup("/auth/realms");
@ -47,7 +45,7 @@ public static class OpenIdEndpoints
TokenEndpoint = baseUri + "/openid-connect/token", TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri, Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks", JwksUri = baseUri + "/openid-connect/jwks",
}); }, AppJsonSerializerContext.Default.OpenIdConfiguration);
} }
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks( private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
@ -58,79 +56,17 @@ public static class OpenIdEndpoints
{ {
Realm realm = context.GetValidatedRealm(); Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm); await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys)); return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
} }
private static async Task<Results< private static Task OpenIdConnectToken(HttpContext context)
Ok<TokenResponse>,
BadRequest<ErrorDto>,
ISUnauthorizedHttpResult
>> OpenIdConnectToken(
string realmSlug,
[FromServices] IClientService clientService,
HttpContext context,
CancellationToken ct)
{ {
IFormCollection form = await context.Request.ReadFormAsync(); throw new NotImplementedException();
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<ErrorDto> CreateBadRequest(string error) =>
TypedResults.BadRequest(new ErrorDto(error));
private static Task OpenIdConnectAuth(HttpContext context) private static Task OpenIdConnectAuth(HttpContext context)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }

View file

@ -1,7 +1,7 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -19,56 +19,31 @@ public static class HttpContextExtensions
public static class RealmApi public static class RealmApi
{ {
public const string GetRealmRoute = "Get Realm";
public const string CreateRealmRoute = "Create Realm";
public static void MapRealmEndpoints(IEndpointRouteBuilder erp) public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
{ {
var realmsGroup = erp.MapGroup("/api/v1/realms"); var realmsGroup = erp.MapGroup("/api/v1/realms");
realmsGroup.MapPost("", RealmCreate) realmsGroup.MapPost("", RealmCreate)
.Produces(StatusCodes.Status201Created)
.Validate<RealmCreateRequest>() .Validate<RealmCreateRequest>()
.WithName(CreateRealmRoute); .WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
var realmIdGroup = realmsGroup.MapGroup("{realmId}") var realmIdGroup = realmsGroup.MapGroup("{realmId}")
.AddEndpointFilter<RealmIdValidationFilter>(); .AddEndpointFilter<RealmIdValidationFilter>();
realmIdGroup.MapGet("", RealmGet)
.WithName(GetRealmRoute);
ClientApi.MapEndpoints(realmIdGroup); ClientApi.MapEndpoints(realmIdGroup);
} }
private static Ok<RealmRepresentation> RealmGet( private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
Guid realmId,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
return TypedResults.Ok(MapToRepresentation(realm));
}
private static async Task<Results<CreatedAtRoute<RealmRepresentation>, InternalServerError>>
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service) RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
{ {
var response = await service.Create(request); var response = await service.Create(request);
if (response.IsSuccess) 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. // TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError(); return TypedResults.InternalServerError();
} }
private static RealmRepresentation MapToRepresentation(Realm realm)
=> new(realm.Id, realm.Slug, realm.Name);
} }

View file

@ -6,9 +6,9 @@ namespace IdentityShroud.Api;
public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest> public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest>
{ {
// most of standard ascii minus the control characters and space // most of standard ascii minus the control characters and space
private const string ClientIdPattern = "^[a-zA-Z0-9_-]+"; private const string ClientIdPattern = "^[\x21-\x7E]+";
private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ]; private string[] AllowedAlgorithms = [ "RS256", "ES256" ];
public ClientCreateRequestValidator() public ClientCreateRequestValidator()
{ {
@ -16,9 +16,7 @@ public class ClientCreateRequestValidator : AbstractValidator<ClientCreateReques
RuleFor(e => e.Name).MaximumLength(80); RuleFor(e => e.Name).MaximumLength(80);
RuleFor(e => e.Description).MaximumLength(2048); RuleFor(e => e.Description).MaximumLength(2048);
RuleFor(e => e.SignatureAlgorithm) RuleFor(e => e.SignatureAlgorithm)
.Must(v => v is null || _allowedAlgorithms.Contains(v)) .Must(v => v is null || AllowedAlgorithms.Contains(v))
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null"); .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);
} }
} }

View file

@ -0,0 +1,9 @@
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
{
}

View file

@ -1,24 +0,0 @@
using Microsoft.AspNetCore.Diagnostics;
namespace IdentityShroud.Api;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> 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;
}
}

View file

@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>false</PublishAot> <PublishAot>true</PublishAot>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId> <UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
</PropertyGroup> </PropertyGroup>
@ -15,17 +15,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Riok.Mapperly" /> <PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Serilog" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Expressions" /> <PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" /> <ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
<ProjectReference Include="..\IdentityShroud.GraphQL\IdentityShroud.GraphQL.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,74 +1,74 @@
using FluentValidation; using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Core; using IdentityShroud.Core;
using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Contracts;
using IdentityShroud.GraphQL; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using Serilog; using Serilog;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
// Initial logging until we can set it up from Configuration // Initial logging until we can set it up from Configuration
Log.Logger = new LoggerConfiguration()
namespace IdentityShroud.Api;
public class Program
{
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter()) .WriteTo.Console(new JsonFormatter())
.CreateLogger(); .CreateLogger();
var applicationBuilder = WebApplication.CreateSlimBuilder(args); var applicationBuilder = WebApplication.CreateSlimBuilder(args);
ConfigureBuilder(applicationBuilder); ConfigureBuilder(applicationBuilder);
var application = applicationBuilder.Build(); var application = applicationBuilder.Build();
ConfigureApplication(application); ConfigureApplication(application);
application.Run(); application.Run();
}
private static void ConfigureBuilder(WebApplicationBuilder builder) void ConfigureBuilder(WebApplicationBuilder builder)
{ {
var services = builder.Services; var services = builder.Services;
var configuration = builder.Configuration; var configuration = builder.Configuration;
//services.AddControllers();
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
services.AddOpenApi();
services.AddScoped<Db>();
services.AddScoped<IClientService, ClientService>();
services.AddSingleton<IClock, ClockService>();
services.AddSingleton<IDekEncryptionService, DekEncryptionService>();
services.AddScoped<IDataEncryptionService, DataEncryptionService>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
services.AddScoped<IKeyService, KeyService>();
services.AddScoped<IRealmService, RealmService>();
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db")); services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
// services.ConfigureHttpJsonOptions(options =>
// {
// options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
// });
services.AddScoped<KeyMapper>(); services.AddScoped<KeyMapper>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>(); services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddOpenApi();
services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails();
services
.AddCore()
.AddIdentityShroudGraphQL();
builder.Host.UseSerilog((context, services, configuration) => configuration builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext() .Enrich.FromLogContext()
//.Enrich.With<UserEnricher>() //.Enrich.With<UserEnricher>()
.ReadFrom.Configuration(context.Configuration)); .ReadFrom.Configuration(context.Configuration));
} }
private static void ConfigureApplication(WebApplication app) void ConfigureApplication(WebApplication app)
{ {
app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.MapApis(); app.MapApis();
app.MapIdentityShroudGraphQL();
// app.UseRouting(); // app.UseRouting();
// app.MapControllers(); // app.MapControllers();
}
} }
public partial class Program { }

View file

@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "graphql", "launchUrl": "todos",
"applicationUrl": "http://localhost:5249", "applicationUrl": "http://localhost:5249",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"

View file

@ -1,5 +1,4 @@
using IdentityShroud.Core.EFCore; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql; using Npgsql;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -8,19 +8,20 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" /> <PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="NSubstitute" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Testcontainers" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" /> <PackageReference Include="Testcontainers" Version="4.10.0" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
<Using Include="NSubstitute" /> <Using Include="NSubstitute"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -49,6 +49,7 @@ public class JwtSignatureGeneratorTests
] ]
} }
"""; """;
JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!; JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!;
using RSA publicKey = LoadFromJwk(keySet.Keys[0]); using RSA publicKey = LoadFromJwk(keySet.Keys[0]);

View file

@ -1,48 +0,0 @@
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<ISecretProvider>();
// 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);
}
}

View file

@ -1,9 +1,5 @@
using IdentityShroud.Api;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Substitutes; using IdentityShroud.TestUtils.Substitutes;
@ -11,29 +7,6 @@ using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Tests.Services; 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<DbFixture> public class ClientServiceTests : IClassFixture<DbFixture>
{ {
private readonly DbFixture _dbFixture; private readonly DbFixture _dbFixture;
@ -61,28 +34,15 @@ public class ClientServiceTests : IClassFixture<DbFixture>
{ {
if (!db.Realms.Any(r => r.Id == _realmId)) if (!db.Realms.Any(r => r.Id == _realmId))
{ {
db.Realms.Add(new() db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" });
{
Id = _realmId,
Slug = "test-realm",
Name = "Test Realm",
DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ],
});
db.SaveChanges(); db.SaveChanges();
} }
} }
private ClientService CreateSut(Db db) => new(db,
_dataEncryptionService,
new ClientCreateRequestValidator(),
_clock);
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
public async Task Create(bool withSecret) public async Task Create(bool allowClientCredentialsFlow)
{ {
// Setup // Setup
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
@ -92,13 +52,15 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
// Act // Act
ClientService sut = CreateSut(db); ClientService sut = new(db, _dataEncryptionService, _clock);
var response = await sut.Create( var response = await sut.Create(
_realmId, _realmId,
ClientCreateRequestBuilder.Default() with new ClientCreateRequest
{ {
Confidential = withSecret, ClientId = "test-client",
GenerateSecret = withSecret, Name = "Test Client",
Description = "A test client",
AllowClientCredentialsFlow = allowClientCredentialsFlow,
}, },
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -108,7 +70,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
Assert.Equal("test-client", val.ClientId); Assert.Equal("test-client", val.ClientId);
Assert.Equal("Test Client", val.Name); Assert.Equal("Test Client", val.Name);
Assert.Equal("A test client", val.Description); Assert.Equal("A test client", val.Description);
Assert.Equal(withSecret, val.Confidential); Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow);
Assert.Equal(now, val.CreatedAt); Assert.Equal(now, val.CreatedAt);
} }
@ -118,7 +80,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
.Include(e => e.Secrets) .Include(e => e.Secrets)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
if (withSecret) if (allowClientCredentialsFlow)
Assert.Single(dbRecord.Secrets); Assert.Single(dbRecord.Secrets);
else else
Assert.Empty(dbRecord.Secrets); Assert.Empty(dbRecord.Secrets);
@ -146,7 +108,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
ClientService sut = CreateSut(actContext); ClientService sut = new(actContext, _dataEncryptionService, _clock);
Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken);
// Verify // Verify
@ -181,7 +143,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
ClientService sut = CreateSut(actContext); ClientService sut = new(actContext, _dataEncryptionService, _clock);
Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken);
// Verify // Verify

View file

@ -2,7 +2,6 @@ using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using IdentityShroud.TestUtils.Substitutes; using IdentityShroud.TestUtils.Substitutes;
@ -10,20 +9,23 @@ namespace IdentityShroud.Core.Tests.Services;
public class DataEncryptionServiceTests public class DataEncryptionServiceTests
{ {
// private readonly IRealmContext _realmContext = Substitute.For<IRealmContext>(); private readonly IRealmContext _realmContext = Substitute.For<IRealmContext>();
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For<IDekEncryptionService>(); private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For<IDekEncryptionService>();
private readonly DekId _activeDekId = DekId.NewId(); private readonly DekId _activeDekId = DekId.NewId();
private readonly DekId _secondDekId = DekId.NewId(); private readonly DekId _secondDekId = DekId.NewId();
private DataEncryptionService CreateSut() private DataEncryptionService CreateSut()
=> new(_dekCryptor); => new(_realmContext, _dekCryptor);
[Fact] [Fact]
public void Encrypt_UsesActiveKey() public void Encrypt_UsesActiveKey()
{ {
var dek = CreateRealmDek(_activeDekId, true); _realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([
CreateRealmDek(_secondDekId, false),
CreateRealmDek(_activeDekId, true),
]);
var cipher = CreateSut().Encrypt(dek, "Hello"u8); var cipher = CreateSut().Encrypt("Hello"u8);
Assert.Equal(_activeDekId, cipher.DekId); Assert.Equal(_activeDekId, cipher.DekId);
} }
@ -32,18 +34,20 @@ public class DataEncryptionServiceTests
public void Decrypt_UsesCorrectKey() public void Decrypt_UsesCorrectKey()
{ {
var first = CreateRealmDek(_activeDekId, true); var first = CreateRealmDek(_activeDekId, true);
_realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([ first ]);
var sut = CreateSut(); var sut = CreateSut();
var cipher = sut.Encrypt(first, "Hello"u8); var cipher = sut.Encrypt("Hello"u8);
// Deactivate original key // Deactivate original key
first.Active = false; first.Active = false;
// Make new active // Make new active
var second = CreateRealmDek(_secondDekId, true); var second = CreateRealmDek(_secondDekId, true);
// Return both // Return both
RealmDek[] list = [ first, second ]; _realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([ first, second ]);
var decoded = sut.Decrypt(list, cipher);
var decoded = sut.Decrypt(cipher);
Assert.Equal("Hello"u8, decoded); Assert.Equal("Hello"u8, decoded);
} }
@ -53,7 +57,7 @@ public class DataEncryptionServiceTests
{ {
Id = id, Id = id,
Active = active, Active = active,
Algorithm = KeyType.AES, Algorithm = "AES",
KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)),
RealmId = default, RealmId = default,
}; };

View file

@ -25,13 +25,8 @@ public class DekEncryptionServiceTests
// act // act
DekEncryptionService sut = new(secretProvider); DekEncryptionService sut = new(secretProvider);
EncryptedDek cipher = sut.Encrypt(input.ToArray()); EncryptedDek cipher = sut.Encrypt(input.ToArray());
int decryptedSize = sut.GetDecryptedSize(cipher); byte[] result = sut.Decrypt(cipher);
Assert.Equal(input.Length, decryptedSize);
var result = new byte[decryptedSize];
sut.Decrypt(cipher, result);
// verify // verify
Assert.Equal(input, result); Assert.Equal(input, result);
@ -61,10 +56,8 @@ public class DekEncryptionServiceTests
// act // act
DekEncryptionService sut = new(secretProvider); DekEncryptionService sut = new(secretProvider);
int decryptedSize = sut.GetDecryptedSize(secret);
var result = new byte[decryptedSize];
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => sut.Decrypt(secret, result), () => sut.Decrypt(secret),
ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message");
} }
@ -96,8 +89,7 @@ public class DekEncryptionServiceTests
// act // act
DekEncryptionService sut = new(secretProvider); DekEncryptionService sut = new(secretProvider);
byte[] result = new byte[sut.GetDecryptedSize(secret)]; byte[] result = sut.Decrypt(secret);
sut.Decrypt(secret, result);
// verify // verify
Assert.Equal("Hello, World!"u8, result); Assert.Equal("Hello, World!"u8, result);

View file

@ -1,4 +1,5 @@
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Tests.Services; namespace IdentityShroud.Core.Tests.Services;
@ -19,8 +20,7 @@ public class EncryptionTests
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
// act // act
byte[] result = new byte[Encryption.GetDecryptedLength(cipher)]; byte[] result = Encryption.Decrypt(cipher, keyValue);
Encryption.Decrypt(cipher, keyValue, result);
// verify // verify
Assert.Equal("Hello, World!"u8, result); Assert.Equal("Hello, World!"u8, result);

View file

@ -1,13 +1,10 @@
using FluentResults;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace IdentityShroud.Core.Tests.Services; namespace IdentityShroud.Core.Tests.Services;
@ -15,7 +12,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
{ {
private readonly DbFixture _dbFixture; private readonly DbFixture _dbFixture;
private readonly IKeyService _keyService = Substitute.For<IKeyService>(); private readonly IKeyService _keyService = Substitute.For<IKeyService>();
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
public RealmServiceTests(DbFixture dbFixture) public RealmServiceTests(DbFixture dbFixture)
{ {
@ -30,9 +26,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
} }
private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService());
[Theory] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")] [InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")]
@ -43,14 +36,20 @@ public class RealmServiceTests : IClassFixture<DbFixture>
if (idString is not null) if (idString is not null)
realmId = new(idString); realmId = new(idString);
Realm? val; RealmCreateResponse? val;
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
_keyService.CreateKey(Arg.Any<KeyPolicy>()) _keyService.CreateKey(Arg.Any<KeyPolicy>())
.Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21]))); .Returns(new RealmKey()
{
Id = Guid.NewGuid(),
KeyType = "TST",
Key = new(KekId.NewId(), [21]),
CreatedAt = DateTime.UtcNow
});
// Act // Act
RealmService sut = CreateSut(db); RealmService sut = new(db, _keyService);
Result<Realm> response = await sut.Create( var response = await sut.Create(
new(realmId, "slug", "New realm"), new(realmId, "slug", "New realm"),
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -61,12 +60,8 @@ public class RealmServiceTests : IClassFixture<DbFixture>
else else
Assert.NotEqual(Guid.Empty, val.Id); Assert.NotEqual(Guid.Empty, val.Id);
Assert.Multiple( Assert.Equal("slug", val.Slug);
() => val.Slug.ShouldBe("slug"), Assert.Equal("New realm", val.Name);
() => val.Name.ShouldBe("New realm"),
() => val.DataEncryptionKeys.ShouldContain(d => d.Active),
() => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
);
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>()); _keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
} }
@ -74,9 +69,9 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
var dbRecord = await db.Realms var dbRecord = await db.Realms
.Include(e => e.TokenSigningKeys) .Include(e => e.Keys)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType); Assert.Equal("TST", dbRecord.Keys[0].KeyType);
} }
} }
@ -103,7 +98,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
RealmService sut = CreateSut(actContext); RealmService sut = new(actContext, _keyService);
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
// Verify // Verify
@ -136,7 +131,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
RealmService sut = CreateSut(actContext); RealmService sut = new(actContext, _keyService);
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
// Verify // Verify

View file

@ -1,7 +1,8 @@
using System.Buffers.Text; using System.Security.Cryptography;
using System.Security.Cryptography; using System.Text;
using System.Text.Json; using System.Text.Json;
using IdentityShroud.Core.DTO; using IdentityShroud.Core.DTO;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Tests; namespace IdentityShroud.Core.Tests;
@ -73,10 +74,10 @@ public static class JwtReader
return new JsonWebToken() return new JsonWebToken()
{ {
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>( Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!, Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>( Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!, Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!,
Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))), Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
}; };
} }
} }

View file

@ -1,22 +1,9 @@
using System.Text;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
public interface IDataEncryptionService public interface IDataEncryptionService
{ {
EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain); EncryptedValue Encrypt(ReadOnlySpan<byte> plain);
byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input); byte[] Decrypt(EncryptedValue input);
}
public static class DataEncryptionServiceExtensions
{
public static string DecryptUtf8ToString(
this IDataEncryptionService des,
IReadOnlyList<RealmDek> deks,
EncryptedValue input)
{
return Encoding.UTF8.GetString(des.Decrypt(deks, input));
}
} }

View file

@ -7,7 +7,5 @@ namespace IdentityShroud.Core.Contracts;
public interface IDekEncryptionService public interface IDekEncryptionService
{ {
EncryptedDek Encrypt(ReadOnlySpan<byte> plain); EncryptedDek Encrypt(ReadOnlySpan<byte> plain);
byte[] Decrypt(EncryptedDek input);
void Decrypt(EncryptedDek input, Span<byte> output);
int GetDecryptedSize(EncryptedDek input);
} }

View file

@ -1,10 +1,12 @@
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
public interface IKeyService public interface IKeyService
{ {
CreateKeyResponse CreateKey(KeyPolicy policy); RealmKey CreateKey(KeyPolicy policy);
JsonWebKey? CreateJsonWebKey(RealmKey realmKey);
} }

View file

@ -1,5 +1,6 @@
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
@ -8,7 +9,7 @@ public interface IRealmService
Task<Realm?> FindById(Guid id, CancellationToken ct = default); Task<Realm?> FindById(Guid id, CancellationToken ct = default);
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default); Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default); Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm); Task LoadActiveKeys(Realm realm);
Task LoadDeks(Realm realm); Task LoadDeks(Realm realm);
} }

View file

@ -1,38 +0,0 @@
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<Db>();
services.Scan(scan => scan
.FromAssemblyOf<RealmService>()
.AddClasses(classes => classes.AssignableTo<IJwtSigner>())
.AsImplementedInterfaces()
.WithSingletonLifetime());
services.AddSingleton<IJwtSignerFactory, JwtSignerFactory>();
services.AddSingleton<IClock, ClockService>();
services.AddSingleton<IDekEncryptionService, DekEncryptionService>();
services.AddScoped<IDataEncryptionService, DataEncryptionService>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
services.AddScoped<IKeyService, KeyService>();
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddScoped<IClientService, ClientService>();
services.AddScoped<IRealmService, RealmService>();
return services;
}
}

View file

@ -1,10 +1,10 @@
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
public record ClientCreateRequest( public class ClientCreateRequest
string ClientId, {
string? Name = null, public required string ClientId { get; set; }
string? Description = null, public string? Name { get; set; }
string? SignatureAlgorithm = null, public string? Description { get; set; }
bool Confidential = false, public string? SignatureAlgorithm { get; set; }
bool AllowClientCredentialsFlow = false, public bool? AllowClientCredentialsFlow { get; set; }
bool GenerateSecret = false); }

View file

@ -1,6 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Messages; namespace IdentityShroud.Core.Messages;
@ -10,7 +9,7 @@ namespace IdentityShroud.Core.Messages;
public class JsonWebKey public class JsonWebKey
{ {
[JsonPropertyName("kty")] [JsonPropertyName("kty")]
public required KeyType KeyType { get; set; } public string KeyType { get; set; } = "RSA";
// Common values sig(nature) enc(ryption) // Common values sig(nature) enc(ryption)
[JsonPropertyName("use")] [JsonPropertyName("use")]

View file

@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.DTO.OpenId;
public enum GrantTypes
{
[JsonStringEnumMemberName("client_credentials")]
ClientCredentials
}

View file

@ -1,19 +0,0 @@
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; }
}

View file

@ -1,3 +1,3 @@
namespace IdentityShroud.Core.Messages.Realm; namespace IdentityShroud.Core.Messages.Realm;
public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null); public record RealmCreateRequest(Guid? Id, string? Slug, string Name);

View file

@ -1,11 +1,11 @@
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace IdentityShroud.Core.EFCore; namespace IdentityShroud.Core;
public class DbConfiguration public class DbConfiguration
{ {
@ -20,9 +20,42 @@ public class Db(
{ {
public virtual DbSet<Client> Clients { get; set; } public virtual DbSet<Client> Clients { get; set; }
public virtual DbSet<Realm> Realms { get; set; } public virtual DbSet<Realm> Realms { get; set; }
public virtual DbSet<RealmSigningKey> Keys { get; set; } public virtual DbSet<RealmKey> Keys { get; set; }
public virtual DbSet<RealmDek> Deks { get; set; } public virtual DbSet<RealmDek> Deks { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var dekIdConverter = new ValueConverter<DekId, Guid>(
id => id.Id,
guid => new DekId(guid));
var kekIdConverter = new ValueConverter<KekId, Guid>(
id => id.Id,
guid => new KekId(guid));
modelBuilder.Entity<RealmDek>()
.Property(d => d.Id)
.HasConversion(dekIdConverter);
modelBuilder.Entity<RealmDek>()
.OwnsOne(d => d.KeyData, keyData =>
{
keyData.Property(k => k.KekId).HasConversion(kekIdConverter);
});
modelBuilder.Entity<RealmKey>()
.OwnsOne(k => k.Key, key =>
{
key.Property(k => k.KekId).HasConversion(kekIdConverter);
});
modelBuilder.Entity<ClientSecret>()
.OwnsOne(c => c.Secret, secret =>
{
secret.Property(s => s.DekId).HasConversion(dekIdConverter);
});
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql("<connection string>"); optionsBuilder.UseNpgsql("<connection string>");
@ -38,22 +71,6 @@ public class Db(
{ {
optionsBuilder.UseLoggerFactory(loggerFactory); 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<DekId>().HaveConversion<DekIdConverter>();
b.Properties<Dictionary<string, string>>().HaveConversion<DictionaryToJsonConverter<string, string>>();
b.Properties<JwtSigAlgName>().HaveConversion<JwtSigAlgNameConverter>();
b.Properties<KekId>().HaveConversion<KekIdConverter>();
b.Properties<KeyType>().HaveConversion<KeyTypeConverter>();
b.Properties<RealmSigningKeyId>().HaveConversion<RealmSigningKeyIdConverter>();
} }
} }

View file

@ -1,6 +0,0 @@
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class DekIdConverter() : ValueConverter<DekId, Guid>(id => id.Id, guid => new DekId(guid));

View file

@ -1,14 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class DictionaryToJsonConverter<TKey, TValue> : ValueConverter<Dictionary<TKey, TValue>, string>
where TKey : notnull
{
public DictionaryToJsonConverter() : base(
v => JsonSerializer.Serialize(v),
v => JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(v) ?? new())
{
}
}

View file

@ -1,5 +0,0 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class JwtSigAlgNameConverter() : ValueConverter<JwtSigAlgName, string>(j => j.ToString(), s => new(s));

View file

@ -1,12 +0,0 @@
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class KekIdConverter : ValueConverter<KekId, Guid>
{
public KekIdConverter()
: base(id => id.Id, guid => new KekId(guid))
{
}
}

View file

@ -1,6 +0,0 @@
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class KeyTypeConverter() : ValueConverter<KeyType, string>(id => id.ToString(), s => new(s));

View file

@ -1,13 +0,0 @@
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class RealmSigningKeyIdConverter : ValueConverter<RealmSigningKeyId, Guid>
{
public RealmSigningKeyIdConverter()
: base(id => id.Id, guid => new RealmSigningKeyId(guid))
{
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -7,24 +7,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" /> <PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="FluentResults" /> <PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="FluentValidation" /> <PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="LanguageExt.Core" /> <PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Scrutor" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Shouldly" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="FluentResults" /> <Using Include="FluentResults" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.PluginSupport\IdentityShroud.PluginSupport.csproj" />
</ItemGroup>
</Project> </Project>

View file

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

View file

@ -19,16 +19,8 @@ public class Client
public string? Description { get; set; } public string? Description { get; set; }
[MaxLength(20)] [MaxLength(20)]
public JwtSigAlgName? SignatureAlgorithm { get; set; } public string? SignatureAlgorithm { get; set; }
/// <summary>
/// Enables confidential flows
/// </summary>
public bool Confidential { get; set; }
/// <summary>
/// Enables the client credentials flow which required Confidential to be true too.
/// </summary>
public bool AllowClientCredentialsFlow { get; set; } = false; public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; } public required DateTime CreatedAt { get; set; }

View file

@ -1,8 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
@ -13,17 +12,6 @@ public class ClientSecret
public int Id { get; set; } public int Id { get; set; }
public Guid ClientId { get; set; } public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? Expires { get; set; }
public DateTime? RevokedAt { get; set; } public DateTime? RevokedAt { get; set; }
public required EncryptedValue Secret { get; set; } public required EncryptedValue Secret { get; set; }
} }
public class ClientSecretConfiguration : IEntityTypeConfiguration<ClientSecret>
{
public void Configure(EntityTypeBuilder<ClientSecret> b)
{
b.ToTable("client_secret");
b.HasKey(e => e.Id);
b.ComplexProperty(e => e.Secret);
}
}

View file

@ -1,66 +0,0 @@
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<byte> 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<byte>(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<byte> 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<byte>(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;
}

View file

@ -1,11 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
[Table("realm")] [Table("realm")]
public class Realm public class Realm
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary> /// <summary>
/// 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 /// 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
@ -17,17 +19,22 @@ public class Realm
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public List<Client> Clients { get; init; } = []; public List<Client> Clients { get; init; } = [];
public List<RealmKey> Keys { get; init; } = [];
/// <summary> public List<RealmDek> Deks { 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.
/// </summary>
public List<RealmSigningKey> TokenSigningKeys { get; init; } = [];
public List<RealmDek> DataEncryptionKeys { get; init; } = [];
/// <summary> /// <summary>
/// Can be overriden per client /// Can be overriden per client
/// </summary> /// </summary>
public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256; 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; }
} }

View file

@ -1,27 +0,0 @@
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<RealmDek>
{
public void Configure(EntityTypeBuilder<RealmDek> b)
{
b.ToTable("realm_dek");
b.HasKey(e => e.Id);
b.ComplexProperty(e => e.KeyData, e => e.IsRequired());
}
}

View file

@ -0,0 +1,27 @@
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; }
/// <summary>
/// Key with highest priority will be used. While there is not really a use case for this I know some users
/// are more comfortable replacing keys by using priority then directly deactivating the old key.
/// </summary>
public int Priority { get; set; } = 10;
}

View file

@ -1,34 +0,0 @@
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; }
/// <summary>
/// Key with highest priority will be used. While there is not really a use case for this I know some users
/// are more comfortable replacing keys by using priority then directly deactivating the old key.
/// </summary>
public int Priority { get; set; } = 10;
public Dictionary<string, string>? PublicKeyParameters { get; set; }
}
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmSigningKey>
{
public void Configure(EntityTypeBuilder<RealmSigningKey> 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");
}
}

View file

@ -1,24 +0,0 @@
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<RealmSigningKeyId>
{
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());
}

View file

@ -1,59 +0,0 @@
using System.Reflection;
using System.Runtime.Loader;
using IdentityShroud.PluginSupport;
namespace IdentityShroud.Core.Plugins;
public static class PluginLoader
{
public static IEnumerable<IPlugin> 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<IPlugin> LoadPluginDll(string dll)
{
Assembly asm;
try
{
asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(dll));
}
catch
{
yield break;
}
IEnumerable<Type> 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;
}
}
}

View file

@ -1,18 +0,0 @@
using System.Collections.ObjectModel;
using IdentityShroud.PluginSupport;
namespace IdentityShroud.Core.Plugins;
/// <summary>
/// Note
/// </summary>
/// <typeparam name="TPlugin"></typeparam>
public class PluginRegistry<TPlugin> where TPlugin : IPlugin
{
private ReadOnlyDictionary<string, TPlugin> _plugins;
public PluginRegistry(ReadOnlyDictionary<string, TPlugin> plugins)
{
_plugins = plugins;
}
}

View file

@ -1,8 +1,6 @@
namespace IdentityShroud.Core.Security; namespace IdentityShroud.Core.Security;
public readonly record struct DekId(Guid Id) public record struct DekId(Guid Id)
{ {
public static DekId NewId() => new(Guid.NewGuid()); public static DekId NewId() => new(Guid.NewGuid());
public override string ToString() => Id.ToString("N");
} }

View file

@ -1,3 +1,6 @@
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Security; namespace IdentityShroud.Core.Security;
[Owned]
public record EncryptedDek(KekId KekId, byte[] Value); public record EncryptedDek(KekId KekId, byte[] Value);

View file

@ -1,5 +1,8 @@
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Security; namespace IdentityShroud.Core.Security;
[Owned]
public record EncryptedValue(DekId DekId, byte[] Value); public record EncryptedValue(DekId DekId, byte[] Value);

View file

@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security;
public static class Encryption public static class Encryption
{ {
private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize); private record struct AlgVersion(int Version, int NonceSize, int TagSize);
private static AlgVersion[] _versions = private static AlgVersion[] _versions =
[ [
@ -35,45 +35,36 @@ public static class Encryption
return result; return result;
} }
public static void Decrypt(ReadOnlyMemory<byte> input, ReadOnlySpan<byte> key, Span<byte> output) public static byte[] Decrypt(ReadOnlyMemory<byte> input, ReadOnlySpan<byte> key)
{ {
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; 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));
ReadOnlySpan<byte> nonce = payload.Slice(1, versionParams.NonceSize); ReadOnlySpan<byte> nonce = payload.Slice(1, versionParams.NonceSize);
ReadOnlySpan<byte> tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); ReadOnlySpan<byte> tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize);
ReadOnlySpan<byte> cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); ReadOnlySpan<byte> cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize);
byte[] plaintext = new byte[cipher.Length];
using var aes = new AesGcm(key, versionParams.TagSize); using var aes = new AesGcm(key, versionParams.TagSize);
try try
{ {
aes.Decrypt(nonce, cipher, tag, output); aes.Decrypt(nonce, cipher, tag, plaintext);
} }
catch (CryptographicException ex) catch (CryptographicException ex)
{ {
// Tag verification failed → tampering or wrong key/nonce. // Tag verification failed → tampering or wrong key/nonce.
throw new InvalidOperationException("Decryption failed authentication tag mismatch.", ex); throw new InvalidOperationException("Decryption failed authentication tag mismatch.", ex);
} }
}
public static int GetDecryptedLength(ReadOnlyMemory<byte> input) return plaintext;
{
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<byte> input)
{
var versionNumber = (int)input.Span[0];
if (versionNumber != 1)
throw new ArgumentException("Invalid payload");
return _versions[versionNumber];
} }
} }

View file

@ -0,0 +1,6 @@
namespace IdentityShroud.Core.Security;
public static class JsonWebAlgorithm
{
public const string RS256 = "RS256";
}

View file

@ -1,20 +0,0 @@
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<JwtSigAlgName> Algorithms { get; }
byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan<byte> jwt);
}

View file

@ -1,6 +0,0 @@
namespace IdentityShroud.Core;
public interface IJwtSignerFactory
{
IJwtSigner Create(JwtSigAlgName algorithm);
}

View file

@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace IdentityShroud.Core;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public readonly record struct JwtSigAlgName(string Name) : IEquatable<JwtSigAlgName>
{
// 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;
}

View file

@ -1,101 +0,0 @@
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
{
/// <summary>
/// Generates a JWT signature using RS256 algorithm
/// </summary>
/// <param name="headerBase64Url">Base64Url encoded header</param>
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
/// <returns>Base64Url encoded signature</returns>
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<byte> 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();
}
}

View file

@ -1,17 +0,0 @@
namespace IdentityShroud.Core;
public class JwtSignerFactory(IEnumerable<IJwtSigner> signers) : IJwtSignerFactory
{
private readonly IReadOnlyDictionary<JwtSigAlgName, IJwtSigner> _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.");
}
}

View file

@ -1,36 +0,0 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core;
public class RsaJwtSigner : IJwtSigner
{
public IReadOnlyList<JwtSigAlgName> 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<byte> 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")
};
}

View file

@ -0,0 +1,38 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core;
public static class JwtSignatureGenerator
{
/// <summary>
/// Generates a JWT signature using RS256 algorithm
/// </summary>
/// <param name="headerBase64Url">Base64Url encoded header</param>
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
/// <returns>Base64Url encoded signature</returns>
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}";
}
}

View file

@ -1,10 +0,0 @@
namespace IdentityShroud.Core.Security.Keys.Aes;
public class AesKeyPolicy : KeyPolicy
{
public AesKeyPolicy()
{
KeyType = KeyType.AES;
KeySize = 256;
}
}

View file

@ -1,19 +0,0 @@
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<string, string> parameters, JsonWebKey jwk)
{
// Can we use this for Jwe?
throw new NotImplementedException();
}
}

View file

@ -2,32 +2,17 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Security.Keys;
public class KeyPolicy public abstract class KeyPolicy
{ {
public KeyType KeyType { get; protected init; } public abstract string KeyType { get; }
public int KeySize { get; protected init; }
}
public record KeyData(byte[] PrivateKey, Dictionary<string, string>? PublicKeyParameters = null)
{
/// <summary>
/// The data to be kept private, also used for symmetric keys
/// </summary>
public byte[] PrivateKey { get; set; } = PrivateKey;
public Dictionary<string, string>? PublicKeyParameters { get; set; } = PublicKeyParameters;
} }
public interface IKeyProvider public interface IKeyProvider
{ {
/// <summary> byte[] CreateKey(KeyPolicy policy);
/// Returns true when this key uses public key cryptography
/// </summary>
bool IsPublic { get; }
KeyData CreateKey(KeyPolicy policy);
void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk); void SetJwkParameters(byte[] key, JsonWebKey jwk);
} }

View file

@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys;
public interface IKeyProviderFactory public interface IKeyProviderFactory
{ {
public IKeyProvider CreateProvider(KeyType keyType); public IKeyProvider CreateProvider(string keyType);
} }

View file

@ -1,18 +1,15 @@
using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa; using IdentityShroud.Core.Security.Keys.Rsa;
namespace IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Security.Keys;
public class KeyProviderFactory : IKeyProviderFactory public class KeyProviderFactory : IKeyProviderFactory
{ {
public IKeyProvider CreateProvider(KeyType keyType) public IKeyProvider CreateProvider(string keyType)
{ {
switch (keyType.Name) switch (keyType)
{ {
case "RSA": case "RSA":
return new RsaProvider(); return new RsaProvider();
case "AES":
return new AesProvider();
default: default:
throw new NotImplementedException(); throw new NotImplementedException();
} }

View file

@ -1,21 +0,0 @@
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<KeyType>
{
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());
}

View file

@ -1,10 +0,0 @@
namespace IdentityShroud.Core.Security.Keys.Rsa;
public class RsaKeyPolicy : KeyPolicy
{
public RsaKeyPolicy()
{
KeyType = KeyType.RSA;
KeySize = 2048;
}
}

View file

@ -4,31 +4,32 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys.Rsa; 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 class RsaProvider : IKeyProvider
{ {
public bool IsPublic => true; public byte[] CreateKey(KeyPolicy policy)
public KeyData CreateKey(KeyPolicy policy)
{ {
if (policy is RsaKeyPolicy p) if (policy is RsaKeyPolicy p)
{ {
using var rsa = RSA.Create(p.KeySize); using var rsa = RSA.Create(p.KeySize);
var publicParamaters = rsa.ExportParameters(includePrivateParameters: false); return rsa.ExportPkcs8PrivateKey();
return new KeyData(
rsa.ExportPkcs8PrivateKey(),
new()
{
["e"] = Base64Url.EncodeToString(publicParamaters.Exponent),
["n"] = Base64Url.EncodeToString(publicParamaters.Modulus),
});
} }
throw new ArgumentException("Incorrect policy type", nameof(policy)); throw new ArgumentException("Incorrect policy type", nameof(policy));
} }
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk) public void SetJwkParameters(byte[] key, JsonWebKey jwk)
{ {
jwk.Exponent = parameters["e"]; using var rsa = RSA.Create();
jwk.Modulus = parameters["n"]; rsa.ImportPkcs8PrivateKey(key, out _);
var parameters = rsa.ExportParameters(includePrivateParameters: false);
jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
} }
} }

View file

@ -1,7 +1,5 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using FluentValidation;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -10,35 +8,24 @@ namespace IdentityShroud.Core.Services;
public class ClientService( public class ClientService(
Db db, Db db,
IDataEncryptionService cryptor, IDataEncryptionService cryptor,
IValidator<ClientCreateRequest> clientCreateValidator,
IClock clock) : IClientService IClock clock) : IClientService
{ {
public async Task<Result<Client>> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) public async Task<Result<Client>> 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() Client client = new()
{ {
RealmId = realmId, RealmId = realmId,
ClientId = request.ClientId, ClientId = request.ClientId,
Name = request.Name, Name = request.Name,
Description = request.Description, Description = request.Description,
SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm), SignatureAlgorithm = request.SignatureAlgorithm,
Confidential = request.Confidential, AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow,
CreatedAt = clock.UtcNow(), CreatedAt = clock.UtcNow(),
}; };
if (request.GenerateSecret is true) if (client.AllowClientCredentialsFlow)
{ {
await db.Entry(realm).Collection(r => r.DataEncryptionKeys) client.Secrets.Add(CreateSecret());
.Query()
.LoadAsync(ct);
client.Secrets.Add(CreateSecret(realm));
} }
await db.AddAsync(client, ct); await db.AddAsync(client, ct);
@ -63,17 +50,15 @@ public class ClientService(
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct);
} }
private ClientSecret CreateSecret(Realm realm) private ClientSecret CreateSecret()
{ {
Span<byte> secret = stackalloc byte[24]; Span<byte> secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret); RandomNumberGenerator.Fill(secret);
var dek = realm.DataEncryptionKeys.Single(k => k.Active);
return new ClientSecret() return new ClientSecret()
{ {
CreatedAt = clock.UtcNow(), CreatedAt = clock.UtcNow(),
Secret = cryptor.Encrypt(dek, secret), Secret = cryptor.Encrypt(secret.ToArray()),
}; };
} }

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
@ -6,42 +5,37 @@ using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Services;
public class DataEncryptionService( public class DataEncryptionService(
IRealmContext realmContext,
IDekEncryptionService dekCryptor) : IDataEncryptionService IDekEncryptionService dekCryptor) : IDataEncryptionService
{ {
public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain)
// 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<RealmDek>? _deks = null;
private IList<RealmDek> GetDeks()
{ {
Span<byte> key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)]; if (_deks is null)
try _deks = realmContext.GetDeks().Result;
return _deks;
}
private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active);
private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id);
public byte[] Decrypt(EncryptedValue input)
{ {
dekCryptor.Decrypt(dek.KeyData, key); var dek = GetKey(input.DekId);
var key = dekCryptor.Decrypt(dek.KeyData);
return Encryption.Decrypt(input.Value, key);
}
public EncryptedValue Encrypt(ReadOnlySpan<byte> plain)
{
var dek = GetActiveDek();
var key = dekCryptor.Decrypt(dek.KeyData);
byte[] cipher = Encryption.Encrypt(plain, key); byte[] cipher = Encryption.Encrypt(plain, key);
return new (dek.Id, cipher); return new (dek.Id, cipher);
} }
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
public byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input)
{
// Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like
// - Old key removed before migration completed (should not be possible)
// - Wrong keyset because of programming error.
var dek = deks.SingleOrDefault(d => d.Id == input.DekId)
?? throw new InvalidOperationException("Required key not found");
Span<byte> 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);
}
}
} }

View file

@ -18,6 +18,8 @@ public class DekEncryptionService : IDekEncryptionService
public DekEncryptionService(ISecretProvider secretProvider) public DekEncryptionService(ISecretProvider secretProvider)
{ {
_encryptionKeys = secretProvider.GetKeys("master"); _encryptionKeys = secretProvider.GetKeys("master");
// if (_encryptionKey.Length != 32) // 256bit key
// throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
} }
public EncryptedDek Encrypt(ReadOnlySpan<byte> plaintext) public EncryptedDek Encrypt(ReadOnlySpan<byte> plaintext)
@ -27,14 +29,10 @@ public class DekEncryptionService : IDekEncryptionService
return new (encryptionKey.Id, cipher); return new (encryptionKey.Id, cipher);
} }
public void Decrypt(EncryptedDek input, Span<byte> output) public byte[] Decrypt(EncryptedDek input)
{ {
var encryptionKey = GetKey(input.KekId); var encryptionKey = GetKey(input.KekId);
Encryption.Decrypt(input.Value, encryptionKey.Key, output);
}
public int GetDecryptedSize(EncryptedDek input) return Encryption.Decrypt(input.Value, encryptionKey.Key);
{
return Encryption.GetDecryptedLength(input.Value);
} }
} }

View file

@ -1,16 +1,46 @@
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Services;
public class KeyService( public class KeyService(
IKeyProviderFactory keyProviderFactory) : IKeyService IDekEncryptionService cryptor,
IKeyProviderFactory keyProviderFactory,
IClock clock) : IKeyService
{ {
public CreateKeyResponse CreateKey(KeyPolicy policy) public RealmKey CreateKey(KeyPolicy policy)
{ {
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType); IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
KeyData plainKey = provider.CreateKey(policy); var plainKey = provider.CreateKey(policy);
return new CreateKeyResponse(policy.KeyType, plainKey); return CreateKey(policy.KeyType, plainKey);
} }
public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
{
JsonWebKey jwk = new()
{
KeyId = realmKey.Id.ToString(),
KeyType = realmKey.KeyType,
Use = "sig",
};
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
provider.SetJwkParameters(
cryptor.Decrypt(realmKey.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(),
};
} }

View file

@ -1,30 +0,0 @@
namespace IdentityShroud.Core.Services.OpenId;
public interface ITokenService
{
Task<Result<TokenResponse>> Handle(
Dictionary<string, string> form,
string? basicAuthUser,
string? basicAuthPassword,
CancellationToken ct = default);
}
public class TokenService : ITokenService
{
public async Task<Result<TokenResponse>> Handle(
Dictionary<string, string> form,
string? basicAuthUser,
string? basicAuthPassword,
CancellationToken ct = default)
{
return new();
}
public async Task<Result<TokenResponse>> ClientCredentialsFlow(
string clientId,
string clientSecret,
CancellationToken ct = default)
{
return new();
}
}

View file

@ -16,11 +16,11 @@ public class RealmContext(
public async Task<IList<RealmDek>> GetDeks(CancellationToken ct = default) public async Task<IList<RealmDek>> GetDeks(CancellationToken ct = default)
{ {
Realm realm = GetRealm(); Realm realm = GetRealm();
if (realm.DataEncryptionKeys.Count == 0) if (realm.Deks.Count == 0)
{ {
await realmService.LoadDeks(realm); await realmService.LoadDeks(realm);
} }
return realm.DataEncryptionKeys; return realm.Deks;
} }
} }

View file

@ -1,21 +1,18 @@
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa; using IdentityShroud.Core.Security.Keys.Rsa;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Services;
public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService( public class RealmService(
Db db, Db db,
IKeyService keyService, IKeyService keyService) : IRealmService
IDekEncryptionService dekCryptor,
IClock clock) : IRealmService
{ {
public async Task<Realm?> FindById(Guid id, CancellationToken ct = default) public async Task<Realm?> FindById(Guid id, CancellationToken ct = default)
{ {
@ -29,7 +26,7 @@ public class RealmService(
.SingleOrDefaultAsync(r => r.Slug == slug, ct); .SingleOrDefaultAsync(r => r.Slug == slug, ct);
} }
public async Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default) public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
{ {
Realm realm = new() Realm realm = new()
{ {
@ -38,52 +35,26 @@ public class RealmService(
Name = request.Name, Name = request.Name,
}; };
realm.TokenSigningKeys.Add(CreateSigningKey(realm)); realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm));
db.Add(realm); db.Add(realm);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return realm; return new RealmCreateResponse(
realm.Id, realm.Slug, realm.Name);
} }
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),
};
}
/// <summary> /// <summary>
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
/// </summary> /// </summary>
/// <param name="_"></param> /// <param name="_"></param>
/// <returns></returns> /// <returns></returns>
private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy(); private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy();
public async Task LoadActiveKeys(Realm realm) public async Task LoadActiveKeys(Realm realm)
{ {
await db.Entry(realm).Collection(r => r.TokenSigningKeys) await db.Entry(realm).Collection(r => r.Keys)
.Query() .Query()
.Where(k => k.RevokedAt == null) .Where(k => k.RevokedAt == null)
.LoadAsync(); .LoadAsync();
@ -91,7 +62,7 @@ public class RealmService(
public async Task LoadDeks(Realm realm) public async Task LoadDeks(Realm realm)
{ {
await db.Entry(realm).Collection(r => r.DataEncryptionKeys) await db.Entry(realm).Collection(r => r.Deks)
.Query() .Query()
.LoadAsync(); .LoadAsync();
} }

View file

@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -1,26 +0,0 @@
susing IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
namespace IdentityShroud.GraphQL;
public class Query
{
public string GetHello() => "Hello, world!";
public async Task<Realm?> GetRealms(
Guid id,
[Service] IRealmService realmService)
{
return await realmService.FindById(id);
}
}
public class Mutation
{
public async Task<Realm> RealmCreate(string name)
{
Realm r = new();
return r;
}
}

View file

@ -1,31 +0,0 @@
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<Mutation>()
.AddQueryType<Query>();
return services;
}
}
extension(IEndpointRouteBuilder app)
{
public IEndpointRouteBuilder MapIdentityShroudGraphQL()
{
app.MapGraphQL();
return app;
}
}
}

View file

@ -1,4 +1,4 @@
using IdentityShroud.Core.EFCore; using IdentityShroud.Core;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View file

@ -1,318 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowClientCredentialsFlow")
.HasColumnType("boolean")
.HasColumnName("allow_client_credentials_flow");
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("client_id");
b.Property<bool>("Confidential")
.HasColumnType("boolean")
.HasColumnName("confidential");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("description");
b.Property<string>("Name")
.HasMaxLength(80)
.HasColumnType("character varying(80)")
.HasColumnName("name");
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<int?>("ClientId1")
.HasColumnType("integer")
.HasColumnName("client_id1");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("Expires")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.ComplexProperty(typeof(Dictionary<string, object>), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 =>
{
b1.IsRequired();
b1.Property<Guid>("DekId")
.HasColumnType("uuid")
.HasColumnName("secret_dek_id");
b1.Property<byte[]>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("DefaultSignatureAlgorithm")
.IsRequired()
.HasColumnType("text")
.HasColumnName("default_signature_algorithm");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<bool>("Active")
.HasColumnType("boolean")
.HasColumnName("active");
b.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("text")
.HasColumnName("algorithm");
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.ComplexProperty(typeof(Dictionary<string, object>), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 =>
{
b1.IsRequired();
b1.Property<Guid>("KekId")
.HasColumnType("uuid")
.HasColumnName("key_data_kek_id");
b1.Property<byte[]>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("KeyType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("key_type");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("PublicKeyParameters")
.HasColumnType("jsonb")
.HasColumnName("public_key_parameters");
b.Property<Guid?>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.ComplexProperty(typeof(Dictionary<string, object>), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 =>
{
b1.IsRequired();
b1.Property<Guid>("KekId")
.HasColumnType("uuid")
.HasColumnName("key_kek_id");
b1.Property<byte[]>("Value")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("key_value");
});
b.HasKey("Id")
.HasName("pk_realm_key");
b.HasIndex("RealmId")
.HasDatabaseName("ix_realm_key_realm_id");
b.ToTable("realm_key", (string)null);
});
modelBuilder.Entity("IdentityShroud.Core.Model.Client", b =>
{
b.HasOne("IdentityShroud.Core.Model.Realm", null)
.WithMany("Clients")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_client_realm_realm_id");
});
modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b =>
{
b.HasOne("IdentityShroud.Core.Model.Client", null)
.WithMany("Secrets")
.HasForeignKey("ClientId1")
.HasConstraintName("fk_client_secret_client_client_id1");
});
modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b =>
{
b.HasOne("IdentityShroud.Core.Model.Realm", null)
.WithMany("DataEncryptionKeys")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_dek_realm_realm_id");
});
modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b =>
{
b.HasOne("IdentityShroud.Core.Model.Realm", null)
.WithMany("TokenSigningKeys")
.HasForeignKey("RealmId")
.HasConstraintName("fk_realm_key_realm_realm_id");
});
modelBuilder.Entity("IdentityShroud.Core.Model.Client", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b =>
{
b.Navigation("Clients");
b.Navigation("DataEncryptionKeys");
b.Navigation("TokenSigningKeys");
});
#pragma warning restore 612, 618
}
}
}

Some files were not shown because too many files have changed in this diff Show more