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.Http.Json;
using System.Text;
using System.Text.Json;
using FluentResults;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests;
using IdentityShroud.Core.Tests.Fixtures;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace IdentityShroud.Api.Tests.Apis;
public class ClientApiTests : IClassFixture<ApplicationFactory>
{
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private readonly 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)
{
// setup
var realm = await CreateRealmAsync("test-realm", "Test Realm");
Realm realm = await CreateRealmAsync("test-realm", "Test Realm");
var client = _factory.CreateClient();
@ -72,56 +64,32 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
[Fact]
public async Task Create_Success_ReturnsCreatedWithLocation()
{
// setup
Realm realm = await CreateRealmAsync("create-realm", "Create Realm");
var client = _factory.CreateClient();
// act
var body = await DoCreateRequest("""
{
"clientId": "new-client",
"name": "New Client"
}
""");
var response = await client.PostAsync(
$"/api/v1/realms/{realm.Id}/clients",
JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }),
TestContext.Current.CancellationToken);
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
// verify
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ClientCreateReponse>(
TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("new-client", body.ClientId);
Assert.True(body.Id > 0);
}
[Fact]
public async Task Create_Success_CreatesSecret()
{
// act
var body = await DoCreateRequest("""
{
"clientId": "new-client",
"name": "New Client",
"confidential": true,
"generateSecret": true
}
""");
// verify
body.ShouldNotBeNull();
body.Secret.ShouldNotBeNullOrWhiteSpace();
}
private async Task<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]
public async Task Create_UnknownRealm_ReturnsNotFound()
{
@ -139,7 +107,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
public async Task Get_Success()
{
// 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");
var httpClient = _factory.CreateClient();
@ -170,7 +138,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
public async Task Get_UnknownClient_ReturnsNotFound()
{
// setup
var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
var httpClient = _factory.CreateClient();
@ -186,11 +154,11 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
private async Task<Realm> CreateRealmAsync(string slug, string name)
{
using var scope = _factory.Services.CreateScope();
var realmService = scope.ServiceProvider.GetRequiredService<IRealmService>();
Result<Realm> result = await realmService.Create(
new(null, slug, name),
TestContext.Current.CancellationToken);
return ResultAssert.Success(result);
var db = scope.ServiceProvider.GetRequiredService<Db>();
var realm = new Realm { Slug = slug, Name = name };
db.Realms.Add(realm);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
return realm;
}
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.Http.Json;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@ -123,16 +124,28 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
[Fact]
public async Task GetJwks()
{
var client = _factory.CreateClient();
var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
// setup
IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService<IDekEncryptionService>();
using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new()
{
Slug = "foo",
Name = "Test'",
}),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
Id = Guid.NewGuid(),
KeyType = "RSA",
Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
CreatedAt = DateTime.UtcNow,
};
await ScopedContextAsync(async db =>
{
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
});
// act
var client = _factory.CreateClient();
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken);
@ -140,16 +153,9 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString();
Assert.NotNull(kid);
Assert.True(kid.Length >= 16);
//if (JsonObjectAssert.NavigateToPath(payload, "keys[0].kty")?.AsValue().ToString() == "RSA")
JsonObjectAssert.Equal("RSA", payload, "keys[0].kty");
string? n = payload["keys"]?[0]?["n"]?.AsValue().ToString();
string? e = payload["keys"]?[0]?["e"]?.AsValue().ToString();
AssertRsaParams(n, e);
JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
}
private async Task ScopedContextAsync(
@ -160,22 +166,4 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
var db = scope.ServiceProvider.GetRequiredService<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.Mvc.Testing;
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>
<TargetFramework>net10.0</TargetFramework>
@ -8,15 +8,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers" Version="4.10.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</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.Mvc;
namespace IdentityShroud.Api.Apis;
namespace IdentityShroud.Api;
public record ClientCreateReponse(int Id, string ClientId);
/// <summary>
/// The part of the api below realms/{slug}/clients
@ -19,9 +23,9 @@ public static class ClientApi
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate)
.Produces(StatusCodes.Status201Created)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate");
.WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>();
@ -39,12 +43,11 @@ public static class ClientApi
return TypedResults.Ok(new ClientMapper().ToDto(client));
}
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>>
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
ClientCreate(
Guid realmId,
ClientCreateRequest request,
[FromServices] IClientService service,
[FromServices] IDataEncryptionService cryptor,
HttpContext context,
CancellationToken cancellationToken)
{
@ -57,12 +60,9 @@ public static class ClientApi
}
Client client = result.Value;
ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client);
var secret = SelectBestSecret(client.Secrets);
if (secret is {} s)
clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret);
return TypedResults.CreatedAtRoute(
clientRepresentation,
new ClientCreateReponse(client.Id, client.ClientId),
ClientGetRouteName,
new RouteValueDictionary()
{
@ -70,28 +70,4 @@ public static class ClientApi
["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 bool Confidential { get; set; }
public bool AllowClientCredentialsFlow { get; set; } = false;
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 IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder)
where TDto : class
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
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]
public partial class ClientMapper
{
// skipping secret as we do not have the DEK
[MapperIgnoreSource(nameof(Client.Secrets))]
[MapperIgnoreTarget(nameof(ClientRepresentation.Secret))]
public partial ClientRepresentation ToDto(Client client);
}

View file

@ -1,28 +1,20 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
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();
foreach (var k in keys)
{
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType);
if (provider.IsPublic)
var wk = keyService.CreateJsonWebKey(k);
if (wk is {})
{
JsonWebKey jwk = new()
{
KeyId = k.Id.ToString(),
KeyType = k.KeyType,
Use = "sig",
};
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
wks.Keys.Add(jwk);
wks.Keys.Add(wk);
}
}
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.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services.OpenId;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@ -15,6 +11,8 @@ public static class OpenIdEndpoints
{
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
var realmsGroup = erp.MapGroup("/auth/realms");
@ -47,7 +45,7 @@ public static class OpenIdEndpoints
TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks",
});
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
@ -58,79 +56,17 @@ public static class OpenIdEndpoints
{
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys));
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
}
private static async Task<Results<
Ok<TokenResponse>,
BadRequest<ErrorDto>,
ISUnauthorizedHttpResult
>> OpenIdConnectToken(
string realmSlug,
[FromServices] IClientService clientService,
HttpContext context,
CancellationToken ct)
private static Task OpenIdConnectToken(HttpContext context)
{
IFormCollection form = await context.Request.ReadFormAsync();
string grantType = form["grant_type"].ToString();
string clientId = form["client_id"].ToString();
string scope = form["scope"].ToString();
if (grantType == "client_credentials")
{
string? clientSecret = null;
bool withAuthHeader = false;
if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password))
{
withAuthHeader = true;
clientId = user;
clientSecret = password;
throw new NotImplementedException();
}
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)
{
throw new NotImplementedException();
}
}

View file

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

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

View file

@ -1,19 +1,16 @@
using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core;
using IdentityShroud.Core.EFCore;
using IdentityShroud.GraphQL;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using Serilog;
using Serilog.Formatting.Json;
// Initial logging until we can set it up from Configuration
namespace IdentityShroud.Api;
public class Program
{
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter())
@ -24,32 +21,36 @@ public class Program
var application = applicationBuilder.Build();
ConfigureApplication(application);
application.Run();
}
private static void ConfigureBuilder(WebApplicationBuilder builder)
void ConfigureBuilder(WebApplicationBuilder builder)
{
var services = builder.Services;
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.ConfigureHttpJsonOptions(options =>
// {
// options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
// });
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddScoped<KeyMapper>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
services.AddHttpContextAccessor();
services.AddOpenApi();
services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails();
services
.AddCore()
.AddIdentityShroudGraphQL();
builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
@ -57,18 +58,17 @@ public class Program
.ReadFrom.Configuration(context.Configuration));
}
private static void ConfigureApplication(WebApplication app)
void ConfigureApplication(WebApplication app)
{
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseSerilogRequestLogging();
app.MapApis();
app.MapIdentityShroudGraphQL();
// app.UseRouting();
// app.MapControllers();
}
}
public partial class Program { }

View file

@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "graphql",
"launchUrl": "todos",
"applicationUrl": "http://localhost:5249",
"environmentVariables": {
"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 Npgsql;
using Testcontainers.PostgreSql;

View file

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

View file

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

View file

@ -2,7 +2,6 @@ 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;
@ -10,20 +9,23 @@ namespace IdentityShroud.Core.Tests.Services;
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 DekId _activeDekId = DekId.NewId();
private readonly DekId _secondDekId = DekId.NewId();
private DataEncryptionService CreateSut()
=> new(_dekCryptor);
=> new(_realmContext, _dekCryptor);
[Fact]
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);
}
@ -32,18 +34,20 @@ public class DataEncryptionServiceTests
public void Decrypt_UsesCorrectKey()
{
var first = CreateRealmDek(_activeDekId, true);
_realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([ first ]);
var sut = CreateSut();
var cipher = sut.Encrypt(first, "Hello"u8);
var cipher = sut.Encrypt("Hello"u8);
// Deactivate original key
first.Active = false;
// Make new active
var second = CreateRealmDek(_secondDekId, true);
// 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);
}
@ -53,7 +57,7 @@ public class DataEncryptionServiceTests
{
Id = id,
Active = active,
Algorithm = KeyType.AES,
Algorithm = "AES",
KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)),
RealmId = default,
};

View file

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

View file

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

View file

@ -1,13 +1,10 @@
using FluentResults;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace IdentityShroud.Core.Tests.Services;
@ -15,7 +12,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
{
private readonly DbFixture _dbFixture;
private readonly IKeyService _keyService = Substitute.For<IKeyService>();
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
public RealmServiceTests(DbFixture dbFixture)
{
@ -30,9 +26,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
}
private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService());
[Theory]
[InlineData(null)]
[InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")]
@ -43,14 +36,20 @@ public class RealmServiceTests : IClassFixture<DbFixture>
if (idString is not null)
realmId = new(idString);
Realm? val;
RealmCreateResponse? val;
await using (var db = _dbFixture.CreateDbContext())
{
_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
RealmService sut = CreateSut(db);
Result<Realm> response = await sut.Create(
RealmService sut = new(db, _keyService);
var response = await sut.Create(
new(realmId, "slug", "New realm"),
TestContext.Current.CancellationToken);
@ -61,12 +60,8 @@ public class RealmServiceTests : IClassFixture<DbFixture>
else
Assert.NotEqual(Guid.Empty, val.Id);
Assert.Multiple(
() => val.Slug.ShouldBe("slug"),
() => val.Name.ShouldBe("New realm"),
() => val.DataEncryptionKeys.ShouldContain(d => d.Active),
() => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
);
Assert.Equal("slug", val.Slug);
Assert.Equal("New realm", val.Name);
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
}
@ -74,9 +69,9 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext())
{
var dbRecord = await db.Realms
.Include(e => e.TokenSigningKeys)
.Include(e => e.Keys)
.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();
// Act
RealmService sut = CreateSut(actContext);
RealmService sut = new(actContext, _keyService);
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
// Verify
@ -136,7 +131,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext();
// Act
RealmService sut = CreateSut(actContext);
RealmService sut = new(actContext, _keyService);
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
// 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 IdentityShroud.Core.DTO;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Tests;
@ -73,10 +74,10 @@ public static class JwtReader
return new JsonWebToken()
{
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!,
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!,
Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))),
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 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;
namespace IdentityShroud.Core.Contracts;
public interface IDataEncryptionService
{
EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain);
byte[] Decrypt(IReadOnlyList<RealmDek> deks, 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));
}
EncryptedValue Encrypt(ReadOnlySpan<byte> plain);
byte[] Decrypt(EncryptedValue input);
}

View file

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

View file

@ -1,10 +1,12 @@
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Contracts;
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
public interface IKeyService
{
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.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Contracts;
@ -8,7 +9,7 @@ public interface IRealmService
Task<Realm?> FindById(Guid id, 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 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;
public record ClientCreateRequest(
string ClientId,
string? Name = null,
string? Description = null,
string? SignatureAlgorithm = null,
bool Confidential = false,
bool AllowClientCredentialsFlow = false,
bool GenerateSecret = false);
public class ClientCreateRequest
{
public required string ClientId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? SignatureAlgorithm { get; set; }
public bool? AllowClientCredentialsFlow { get; set; }
}

View file

@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Messages;
@ -10,7 +9,7 @@ namespace IdentityShroud.Core.Messages;
public class JsonWebKey
{
[JsonPropertyName("kty")]
public required KeyType KeyType { get; set; }
public string KeyType { get; set; } = "RSA";
// Common values sig(nature) enc(ryption)
[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;
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.Security;
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace IdentityShroud.Core.EFCore;
namespace IdentityShroud.Core;
public class DbConfiguration
{
@ -20,9 +20,42 @@ public class Db(
{
public virtual DbSet<Client> Clients { 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; }
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)
{
optionsBuilder.UseNpgsql("<connection string>");
@ -38,22 +71,6 @@ public class Db(
{
optionsBuilder.UseLoggerFactory(loggerFactory);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(Db).Assembly);
}
protected override void ConfigureConventions(ModelConfigurationBuilder b)
{
base.ConfigureConventions(b);
b.Properties<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>
<TargetFramework>net10.0</TargetFramework>
@ -7,24 +7,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="FluentResults" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="LanguageExt.Core" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Scrutor" />
<PackageReference Include="Shouldly" />
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="FluentResults" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.PluginSupport\IdentityShroud.PluginSupport.csproj" />
</ItemGroup>
</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; }
[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 required DateTime CreatedAt { get; set; }

View file

@ -1,8 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model;
@ -13,17 +12,6 @@ public class ClientSecret
public int Id { get; set; }
public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? Expires { get; set; }
public DateTime? RevokedAt { get; set; }
public required EncryptedValue Secret { get; set; }
}
public class ClientSecretConfiguration : IEntityTypeConfiguration<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.Schema;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Model;
[Table("realm")]
public class Realm
{
public Guid Id { get; set; }
/// <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
@ -17,17 +19,22 @@ public class Realm
public string Name { get; set; } = "";
public List<Client> Clients { get; init; } = [];
public List<RealmKey> Keys { get; init; } = [];
/// <summary>
/// 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; } = [];
public List<RealmDek> Deks { get; init; } = [];
/// <summary>
/// Can be overriden per client
/// </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;
public readonly record struct DekId(Guid Id)
public record struct DekId(Guid Id)
{
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;
[Owned]
public record EncryptedDek(KekId KekId, byte[] Value);

View file

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

View file

@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security;
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 =
[
@ -35,45 +35,36 @@ public static class Encryption
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;
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> tag = 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);
try
{
aes.Decrypt(nonce, cipher, tag, output);
aes.Decrypt(nonce, cipher, tag, plaintext);
}
catch (CryptographicException ex)
{
// Tag verification failed → tampering or wrong key/nonce.
throw new InvalidOperationException("Decryption failed authentication tag mismatch.", ex);
}
}
public static int GetDecryptedLength(ReadOnlyMemory<byte> input)
{
AlgVersion versionParams = GetVersionParams(input);
int length = input.Length - (1 + versionParams.NonceSize + versionParams.TagSize);
if (length < 0)
throw new ArgumentException("Cypher data is too short to be valid.", nameof(input));
return length;
}
private static AlgVersion GetVersionParams(ReadOnlyMemory<byte> input)
{
var versionNumber = (int)input.Span[0];
if (versionNumber != 1)
throw new ArgumentException("Invalid payload");
return _versions[versionNumber];
return plaintext;
}
}

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;
public class KeyPolicy
public abstract class KeyPolicy
{
public KeyType KeyType { get; protected init; }
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 abstract string KeyType { get; }
}
public interface IKeyProvider
{
/// <summary>
/// Returns true when this key uses public key cryptography
/// </summary>
bool IsPublic { get; }
KeyData CreateKey(KeyPolicy policy);
byte[] 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 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;
namespace IdentityShroud.Core.Security.Keys;
public class KeyProviderFactory : IKeyProviderFactory
{
public IKeyProvider CreateProvider(KeyType keyType)
public IKeyProvider CreateProvider(string keyType)
{
switch (keyType.Name)
switch (keyType)
{
case "RSA":
return new RsaProvider();
case "AES":
return new AesProvider();
default:
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;
public class RsaKeyPolicy : KeyPolicy
{
public override string KeyType => "RSA";
public int KeySize { get; } = 2048;
}
public class RsaProvider : IKeyProvider
{
public bool IsPublic => true;
public KeyData CreateKey(KeyPolicy policy)
public byte[] CreateKey(KeyPolicy policy)
{
if (policy is RsaKeyPolicy p)
{
using var rsa = RSA.Create(p.KeySize);
var publicParamaters = rsa.ExportParameters(includePrivateParameters: false);
return new KeyData(
rsa.ExportPkcs8PrivateKey(),
new()
{
["e"] = Base64Url.EncodeToString(publicParamaters.Exponent),
["n"] = Base64Url.EncodeToString(publicParamaters.Modulus),
});
return rsa.ExportPkcs8PrivateKey();
}
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"];
jwk.Modulus = parameters["n"];
using var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(key, out _);
var parameters = rsa.ExportParameters(includePrivateParameters: false);
jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
}
}

View file

@ -1,7 +1,5 @@
using System.Security.Cryptography;
using FluentValidation;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore;
@ -10,35 +8,24 @@ namespace IdentityShroud.Core.Services;
public class ClientService(
Db db,
IDataEncryptionService cryptor,
IValidator<ClientCreateRequest> clientCreateValidator,
IClock clock) : IClientService
{
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()
{
RealmId = realmId,
ClientId = request.ClientId,
Name = request.Name,
Description = request.Description,
SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm),
Confidential = request.Confidential,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow,
SignatureAlgorithm = request.SignatureAlgorithm,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
CreatedAt = clock.UtcNow(),
};
if (request.GenerateSecret is true)
if (client.AllowClientCredentialsFlow)
{
await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
.Query()
.LoadAsync(ct);
client.Secrets.Add(CreateSecret(realm));
client.Secrets.Add(CreateSecret());
}
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);
}
private ClientSecret CreateSecret(Realm realm)
private ClientSecret CreateSecret()
{
Span<byte> secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret);
var dek = realm.DataEncryptionKeys.Single(k => k.Active);
return new ClientSecret()
{
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.Model;
using IdentityShroud.Core.Security;
@ -6,42 +5,37 @@ using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
public class DataEncryptionService(
IRealmContext realmContext,
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)];
try
if (_deks is null)
_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);
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)
{
_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)
@ -27,14 +29,10 @@ public class DekEncryptionService : IDekEncryptionService
return new (encryptionKey.Id, cipher);
}
public void Decrypt(EncryptedDek input, Span<byte> output)
public byte[] Decrypt(EncryptedDek input)
{
var encryptionKey = GetKey(input.KekId);
Encryption.Decrypt(input.Value, encryptionKey.Key, output);
}
public int GetDecryptedSize(EncryptedDek input)
{
return Encryption.GetDecryptedLength(input.Value);
return Encryption.Decrypt(input.Value, encryptionKey.Key);
}
}

View file

@ -1,16 +1,46 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
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);
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)
{
Realm realm = GetRealm();
if (realm.DataEncryptionKeys.Count == 0)
if (realm.Deks.Count == 0)
{
await realmService.LoadDeks(realm);
}
return realm.DataEncryptionKeys;
return realm.Deks;
}
}

View file

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

View file

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