Implement jwks endpoint and add test for it.

This also let to some improvements/cleanups of the other tests and fixtures.
This commit is contained in:
eelke 2026-02-15 19:06:09 +01:00
parent a80c133e2a
commit ccb06b260c
24 changed files with 353 additions and 107 deletions

View file

@ -1,19 +1,35 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using FluentResults; using IdentityShroud.Core;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts; using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NSubstitute.ClearExtensions; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace IdentityShroud.Api.Tests.Apis; namespace IdentityShroud.Api.Tests.Apis;
public class RealmApisTests(ApplicationFactory factory) : IClassFixture<ApplicationFactory> public class RealmApisTests : IClassFixture<ApplicationFactory>
{ {
private readonly ApplicationFactory _factory;
public RealmApisTests(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] [Theory]
[InlineData(null, null, null, false, "Name")] [InlineData(null, null, null, false, "Name")]
[InlineData(null, null, "Foo", true, "")] [InlineData(null, null, "Foo", true, "")]
@ -25,11 +41,7 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
[InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")] [InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")]
public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName) public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName)
{ {
var client = factory.CreateClient(); var client = _factory.CreateClient();
factory.RealmService.ClearSubstitute();
factory.RealmService.Create(Arg.Any<RealmCreateRequest>(), Arg.Any<CancellationToken>())
.Returns(Result.Ok(new RealmCreateResponse(Guid.NewGuid(), "foo", "Foo")));
Guid? inputId = id is null ? (Guid?)null : new Guid(id); Guid? inputId = id is null ? (Guid?)null : new Guid(id);
var response = await client.PostAsync("/realms", JsonContent.Create(new var response = await client.PostAsync("/realms", JsonContent.Create(new
@ -46,9 +58,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
if (succeeds) if (succeeds)
{ {
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await factory.RealmService.Received(1).Create( // await factory.RealmService.Received(1).Create(
Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name), // Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
Arg.Any<CancellationToken>()); // Arg.Any<CancellationToken>());
} }
else else
{ {
@ -58,9 +70,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
await factory.RealmService.DidNotReceive().Create( // await factory.RealmService.DidNotReceive().Create(
Arg.Any<RealmCreateRequest>(), // Arg.Any<RealmCreateRequest>(),
Arg.Any<CancellationToken>()); // Arg.Any<CancellationToken>());
} }
} }
@ -68,11 +80,14 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
public async Task GetOpenIdConfiguration_Success() public async Task GetOpenIdConfiguration_Success()
{ {
// setup // setup
factory.RealmService.FindBySlug(Arg.Is<string>("foo"), Arg.Any<CancellationToken>()) await ScopedContextAsync(async db =>
.Returns(new Realm()); {
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo" });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
});
// act // act
var client = factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -91,11 +106,56 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
public async Task GetOpenIdConfiguration_NotFound(string slug) public async Task GetOpenIdConfiguration_NotFound(string slug)
{ {
// act // act
var client = factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration", var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
// verify // verify
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
} }
[Fact]
public async Task GetJwks()
{
// setup
IEncryptionService encryptionService = _factory.Services.GetRequiredService<IEncryptionService>();
using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new()
{
Id = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow,
};
key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
await ScopedContextAsync(async db =>
{
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ key ]});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
});
// act
var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
JsonObjectAssert.Equal(key.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(
Func<Db, Task> action
)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Db>();
await action(db);
}
} }

View file

@ -1,24 +1,58 @@
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestPlatform.TestHost; using Microsoft.VisualStudio.TestPlatform.TestHost;
using Npgsql;
using Testcontainers.PostgreSql;
namespace IdentityShroud.Core.Tests.Fixtures; namespace IdentityShroud.Core.Tests.Fixtures;
public class ApplicationFactory : WebApplicationFactory<Program> public class ApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{ {
public IRealmService RealmService { get; } = Substitute.For<IRealmService>(); private readonly PostgreSqlContainer _postgresqlServer;
// public IRealmService RealmService { get; } = Substitute.For<IRealmService>();
public ApplicationFactory()
{
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
.WithName($"is-applicationFactory-{Guid.NewGuid():N}")
.Build();
}
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
base.ConfigureWebHost(builder); base.ConfigureWebHost(builder);
builder.ConfigureServices(services => builder.ConfigureAppConfiguration((context, configBuilder) =>
{ {
services.AddScoped<IRealmService>(c => RealmService); configBuilder.AddInMemoryCollection(
new Dictionary<string, string?>
{
["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(),
["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
});
}); });
// builder.ConfigureServices(services =>
// {
// services.AddScoped<IRealmService>(c => RealmService);
// });
builder.UseEnvironment("Development"); builder.UseEnvironment("Development");
} }
public async ValueTask InitializeAsync()
{
await _postgresqlServer.StartAsync();
}
public override async ValueTask DisposeAsync()
{
await _postgresqlServer.StopAsync();
await base.DisposeAsync();
}
} }

View file

@ -0,0 +1,41 @@
using System.Security.Cryptography;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyMapperTests
{
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new()
{
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
CreatedAt = DateTime.UtcNow,
Priority = 10,
};
key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey());
// Act
KeyMapper mapper = new(_encryptionService);
JsonWebKey jwk = mapper.KeyToJsonWebKey(key);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(key.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent));
Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus));
}
}

View file

@ -14,9 +14,11 @@ public class JsonWebKey
[JsonPropertyName("use")] [JsonPropertyName("use")]
public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
// Per standard this field is optional for now we will use RS256 // Per standard this field is optional, commented out for now as it seems not
[JsonPropertyName("alg")] // have any good use in an identity server. Anyone validating tokens should use
public string? Algorithm { get; set; } = "RS256"; // the algorithm specified in the header of the token.
// [JsonPropertyName("alg")]
// public string? Algorithm { get; set; } = "RS256";
[JsonPropertyName("kid")] [JsonPropertyName("kid")]
public required string KeyId { get; set; } public required string KeyId { get; set; }
@ -31,9 +33,9 @@ public class JsonWebKey
// Optional fields // Optional fields
[JsonPropertyName("x5c")] [JsonPropertyName("x5c")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> X509CertificateChain { get; set; } public List<string>? X509CertificateChain { get; set; }
[JsonPropertyName("x5t")] [JsonPropertyName("x5t")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string X509CertificateThumbprint { get; set; } public string? X509CertificateThumbprint { get; set; }
} }

View file

@ -0,0 +1,26 @@
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Api;
/// <summary>
/// Note the filter depends on the slug path parameter to be the first string argument on the context.
/// The endpoint handlers should place path arguments first and in order of the path to ensure this works
/// consistently.
/// </summary>
/// <param name="realmService"></param>
public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
string slug = context.Arguments.OfType<string>().First();
Realm? realm = await realmService.FindBySlug(slug);
if (realm is null)
{
return Results.NotFound();
}
context.HttpContext.Items["RealmEntity"] = realm;
return await next(context);
}
}

View file

@ -0,0 +1,34 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IEncryptionService encryptionService)
{
public JsonWebKey KeyToJsonWebKey(Key key)
{
using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService));
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
return new JsonWebKey()
{
KeyType = rsa.SignatureAlgorithm,
KeyId = key.Id.ToString(),
Use = "sig",
Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!),
Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!),
};
}
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<Key> keys)
{
return new JsonWebKeySet()
{
Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(),
};
}
}

View file

@ -1,13 +1,22 @@
using FluentResults; using FluentResults;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation; using IdentityShroud.Api.Validation;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api; namespace IdentityShroud.Api;
public static class HttpContextExtensions
{
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
}
public static class RealmApi public static class RealmApi
{ {
public static void MapRealmEndpoints(this IEndpointRouteBuilder app) public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
@ -18,7 +27,8 @@ public static class RealmApi
.WithName("Create Realm") .WithName("Create Realm")
.Produces(StatusCodes.Status201Created); .Produces(StatusCodes.Status201Created);
var realmSlugGroup = realmsGroup.MapGroup("{slug}"); var realmSlugGroup = realmsGroup.MapGroup("{slug}")
.AddEndpointFilter<SlugValidationFilter>();
realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet("", GetRealmInfo);
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
@ -39,9 +49,15 @@ public static class RealmApi
return TypedResults.InternalServerError(); return TypedResults.InternalServerError();
} }
private static Task OpenIdConnectJwks(HttpContext context) private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
string slug,
[FromServices]IRealmService realmService,
[FromServices]KeyMapper keyMapper,
HttpContext context)
{ {
throw new NotImplementedException(); Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
} }
private static Task OpenIdConnectToken(HttpContext context) private static Task OpenIdConnectToken(HttpContext context)
@ -54,17 +70,12 @@ public static class RealmApi
throw new NotImplementedException(); throw new NotImplementedException();
} }
private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest, NotFound>> GetOpenIdConfiguration( private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
string slug,
[FromServices]IRealmService realmService, [FromServices]IRealmService realmService,
HttpContext context, HttpContext context)
string slug)
{ {
if (string.IsNullOrEmpty(slug)) Realm realm = context.GetValidatedRealm();
return TypedResults.BadRequest();
var realm = await realmService.FindBySlug(slug);
if (realm is null)
return TypedResults.NotFound();
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{slug}"; var searchString = $"realms/{slug}";
@ -94,30 +105,4 @@ public static class RealmApi
} }
*/ */
} }
// [HttpGet("")]
// public ActionResult Index()
// {
// return new JsonResult("Hello world!");
// }
// [HttpGet("{slug}/.well-known/openid-configuration")]
// public ActionResult GetOpenIdConfiguration(
// string slug,
// [FromServices]LinkGenerator linkGenerator)
// {
// var s = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}";
// var searchString = $"realms/{slug}";
// int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
// string baseUri = s.Substring(0, index + searchString.Length);
//
// return new JsonResult(baseUri);
// }
// [HttpPost("{slug}/protocol/openid-connect/token")]
// public ActionResult GetOpenIdConnectToken(string slug)
//
// {
// return new JsonResult("Hello world!");
// }
} }

View file

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

View file

@ -1,9 +1,11 @@
using FluentValidation; using FluentValidation;
using IdentityShroud.Api; using IdentityShroud.Api;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation; using IdentityShroud.Api.Validation;
using IdentityShroud.Core; using IdentityShroud.Core;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Services;
using Serilog; using Serilog;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
@ -34,8 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder)
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
services.AddOpenApi(); services.AddOpenApi();
services.AddScoped<Db>(); services.AddScoped<Db>();
services.AddScoped<IRealmService, RealmService>();
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db")); services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>(); services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddSingleton<KeyMapper>();
services.AddSingleton<IEncryptionService>(c =>
{
var configuration = c.GetRequiredService<IConfiguration>();
return new EncryptionService(configuration.GetValue<string>("Secrets:Master"));
});
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>(); services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();

View file

@ -8,23 +8,13 @@ namespace IdentityShroud.Core.Tests.Fixtures;
public class DbFixture : IAsyncLifetime public class DbFixture : IAsyncLifetime
{ {
private readonly IContainer _postgresqlServer; private readonly PostgreSqlContainer _postgresqlServer;
private string ConnectionString =>
$"Host={_postgresqlServer.Hostname};" +
$"Port={DbPort};" +
$"Username={Username};Password={Password}";
private string Username => "postgres";
private string Password => "password";
private string DbHostname => _postgresqlServer.Hostname;
private int DbPort => _postgresqlServer.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort);
public Db CreateDbContext(string dbName = "testdb") public Db CreateDbContext(string dbName = "testdb")
{ {
var db = new Db(Options.Create<DbConfiguration>(new() var db = new Db(Options.Create<DbConfiguration>(new()
{ {
ConnectionString = ConnectionString + ";Database=" + dbName, ConnectionString = _postgresqlServer.GetConnectionString(),
LogSensitiveData = false, LogSensitiveData = false,
}), new NullLoggerFactory()); }), new NullLoggerFactory());
return db; return db;
@ -33,8 +23,7 @@ public class DbFixture : IAsyncLifetime
public DbFixture() public DbFixture()
{ {
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1") _postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
.WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D")) .WithName("is-dbfixture-" + Guid.NewGuid().ToString("D"))
.WithPassword(Password)
.Build(); .Build();
} }
@ -50,7 +39,7 @@ public class DbFixture : IAsyncLifetime
public NpgsqlConnection GetConnection(string dbname) public NpgsqlConnection GetConnection(string dbname)
{ {
string connString = ConnectionString string connString = _postgresqlServer.GetConnectionString()
+ $";Database={dbname}"; + $";Database={dbname}";
var connection = new NpgsqlConnection(connString); var connection = new NpgsqlConnection(connString);
connection.Open(); connection.Open();

View file

@ -25,6 +25,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" /> <ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" /> <ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -1,8 +1,7 @@
using FluentResults;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.Core.Tests.Substitutes; using IdentityShroud.TestUtils.Substitutes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Tests.Services; namespace IdentityShroud.Core.Tests.Services;

View file

@ -95,14 +95,5 @@ public static class RsaKeyLoader
string pemContent = System.IO.File.ReadAllText(filePath); string pemContent = System.IO.File.ReadAllText(filePath);
return LoadFromPem(pemContent); return LoadFromPem(pemContent);
} }
/// <summary>
/// Load RSA private key from PKCS#8 format
/// </summary>
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
return rsa;
}
} }

View file

@ -1,7 +1,11 @@
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
public class Client public class Client
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
} }

View file

@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
@ -19,4 +21,10 @@ public class Realm
public List<Client> Clients { get; init; } = []; public List<Client> Clients { get; init; } = [];
public List<Key> Keys { get; init; } = []; public List<Key> Keys { get; init; } = [];
}
/// <summary>
/// Can be overriden per client
/// </summary>
public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
}

View file

@ -7,14 +7,22 @@ public static class AesGcmHelper
public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key)
{ {
using var aes = new AesGcm(key); int tagSize = AesGcm.TagByteSizes.MaxSize;
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); using var aes = new AesGcm(key, tagSize);
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; Span<byte> nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize];
RandomNumberGenerator.Fill(nonce);
Span<byte> ciphertext = stackalloc byte[plaintext.Length];
Span<byte> tag = stackalloc byte[tagSize];
aes.Encrypt(nonce, plaintext, ciphertext, tag); aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Return concatenated nonce|ciphertext|tag (or store separately)
return nonce.Concat(ciphertext).Concat(tag).ToArray(); // Return concatenated nonce|ciphertext|tag
var result = new byte[nonce.Length + ciphertext.Length + tag.Length];
nonce.CopyTo(result.AsSpan(0, nonce.Length));
ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length));
tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length));
return result;
} }
// -------------------------------------------------------------------- // --------------------------------------------------------------------
@ -44,11 +52,10 @@ public static class AesGcmHelper
ReadOnlySpan<byte> nonce = new(payload, 0, nonceSize); ReadOnlySpan<byte> nonce = new(payload, 0, nonceSize);
ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize); ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize); ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
byte[] plaintext = new byte[ciphertext.Length]; byte[] plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key); using var aes = new AesGcm(key, tagSize);
try try
{ {
aes.Decrypt(nonce, ciphertext, tag, plaintext); aes.Decrypt(nonce, ciphertext, tag, plaintext);

View file

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

View file

@ -4,4 +4,13 @@ namespace IdentityShroud.Core.Security;
public static class RsaHelper public static class RsaHelper
{ {
/// <summary>
/// Load RSA private key from PKCS#8 format
/// </summary>
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
return rsa;
}
} }

View file

@ -8,4 +8,5 @@ public interface IRealmService
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default); Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default); Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm);
} }

View file

@ -15,7 +15,8 @@ public class RealmService(
{ {
public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default) public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{ {
return await db.Realms.SingleOrDefaultAsync(r => r.Slug == slug, ct); return await db.Realms
.SingleOrDefaultAsync(r => r.Slug == slug, ct);
} }
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default) public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
@ -35,6 +36,15 @@ public class RealmService(
realm.Id, realm.Slug, realm.Name); realm.Id, realm.Slug, realm.Name);
} }
public async Task LoadActiveKeys(Realm realm)
{
await db.Entry(realm).Collection(r => r.Keys)
.Query()
.Where(k => k.DeactivatedAt == null)
.LoadAsync();
}
private Key CreateKey() private Key CreateKey()
{ {
using RSA rsa = RSA.Create(2048); using RSA rsa = RSA.Create(2048);

View file

@ -12,4 +12,19 @@
<PackageReference Include="xunit.v3.assert" Version="3.2.2" /> <PackageReference Include="xunit.v3.assert" Version="3.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
<Using Include="NSubstitute"/>
</ItemGroup>
<ItemGroup>
<Reference Include="NSubstitute">
<HintPath>..\..\..\.nuget\packages\nsubstitute\5.3.0\lib\net6.0\NSubstitute.dll</HintPath>
</Reference>
</ItemGroup>
</Project> </Project>

View file

@ -1,6 +1,6 @@
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Core.Tests.Substitutes; namespace IdentityShroud.TestUtils.Substitutes;
public static class EncryptionServiceSubstitute public static class EncryptionServiceSubstitute
{ {

View file

@ -1,18 +1,27 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAesGcm_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F26fbd7ed219da834e9eaf78ad486d552132eb3c92bbfccff8c27249cdf5f6722_003FAesGcm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAesGcm_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F26fbd7ed219da834e9eaf78ad486d552132eb3c92bbfccff8c27249cdf5f6722_003FAesGcm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAesGcm_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2baadb96535b9acc4cb6c54e5379b87513f15ea119f8b153ed795a99ea3d340_003FAesGcm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACallInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F402b2077f38742cb9b381ab9e79e493229c00_003F81_003F75c3679f_003FCallInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACallInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F402b2077f38742cb9b381ab9e79e493229c00_003F81_003F75c3679f_003FCallInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADebugger_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff9d2f95d72fa884d8b6ddefc717c56da3657fbb2d5fb683656c3589eb6587_003FDebugger_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADeveloperExceptionPageMiddlewareImpl_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2b5a64a615692cae2c8f378e99676581abe4bc355bb3844bfc6c6db3d576853_003FDeveloperExceptionPageMiddlewareImpl_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGeneratedRouteBuilderExtensions_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F698a85dfa04f73158f8da37069798c22c467dfc_003FGeneratedRouteBuilderExtensions_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHealthCheckEndpointRouteBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6d0f079e13da4e98881aa3e6e169c6d34f08_003F0e_003Fc2b30661_003FHealthCheckEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHealthCheckEndpointRouteBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6d0f079e13da4e98881aa3e6e169c6d34f08_003F0e_003Fc2b30661_003FHealthCheckEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAsyncDisposable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F7d59f4f94af72f8d3797655412cdc64435acc6454985685e415ee5fe817f_003FIAsyncDisposable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKeySizes_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe6cebf5d2d92b49eb99f568415b3cd457a252cacf81d426ca4f3e94ff429daf7_003FKeySizes_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKeySizes_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe6cebf5d2d92b49eb99f568415b3cd457a252cacf81d426ca4f3e94ff429daf7_003FKeySizes_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANamingConventionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feacd26cff49d864d97bf44d3424fd383a26620b1d0c43fb1d6f115da85c655_003FNamingConventionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANamingConventionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feacd26cff49d864d97bf44d3424fd383a26620b1d0c43fb1d6f115da85c655_003FNamingConventionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOkOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe2a19de442f561af862af2dcad0852b7e62707a5cf194d266d1656f92bbb6d2_003FOkOfT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOkOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe2a19de442f561af862af2dcad0852b7e62707a5cf194d266d1656f92bbb6d2_003FOkOfT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlContainer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc82112acf224de1d157da0309437b227be6c1ef877865c23872f49eaf9d73c_003FPostgreSqlContainer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResultsOfT_002EGenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fff2e2c5ca93c7786ef8425ca6caf751702328924211687ce72e74fd1265e8_003FResultsOfT_002EGenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String> <s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=5468c5de_002Dd2de_002D4c6e_002D97d4_002Dbb5f43ed1090/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=6e5d049f_002D5af8_002D43d4_002D878d_002D591b09b1e74a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
@ -20,4 +29,5 @@
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
</wpf:ResourceDictionary> </wpf:ResourceDictionary>