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:
parent
a80c133e2a
commit
ccb06b260c
24 changed files with 353 additions and 107 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
41
IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs
Normal file
41
IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
26
IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs
Normal file
26
IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
Normal file
34
IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!");
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
3
IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings
Normal file
3
IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings
Normal 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>
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
8
IdentityShroud.Core/Security/JsonWebAlgorithm.cs
Normal file
8
IdentityShroud.Core/Security/JsonWebAlgorithm.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Security;
|
||||||
|
|
||||||
|
public static class JsonWebAlgorithm
|
||||||
|
{
|
||||||
|
public const string RS256 = "RS256";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
@ -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"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=6e5d049f_002D5af8_002D43d4_002D878d_002D591b09b1e74a/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
<Solution />
|
<Solution />
|
||||||
</SessionState></s:String>
|
</SessionState></s:String>
|
||||||
|
|
||||||
|
|
||||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
<Solution />
|
<Solution />
|
||||||
</SessionState></s:String>
|
</SessionState></s:String>
|
||||||
|
|
@ -20,4 +29,5 @@
|
||||||
<Solution />
|
<Solution />
|
||||||
</SessionState></s:String>
|
</SessionState></s:String>
|
||||||
|
|
||||||
|
|
||||||
</wpf:ResourceDictionary>
|
</wpf:ResourceDictionary>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue