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.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentResults;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using IdentityShroud.TestUtils.Asserts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute.ClearExtensions;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
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]
|
||||
[InlineData(null, null, null, false, "Name")]
|
||||
[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")]
|
||||
public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName)
|
||||
{
|
||||
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")));
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
Guid? inputId = id is null ? (Guid?)null : new Guid(id);
|
||||
var response = await client.PostAsync("/realms", JsonContent.Create(new
|
||||
|
|
@ -46,9 +58,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
if (succeeds)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
await factory.RealmService.Received(1).Create(
|
||||
Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
|
||||
Arg.Any<CancellationToken>());
|
||||
// await factory.RealmService.Received(1).Create(
|
||||
// Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
|
||||
// Arg.Any<CancellationToken>());
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -58,9 +70,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
|
||||
await factory.RealmService.DidNotReceive().Create(
|
||||
Arg.Any<RealmCreateRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
// await factory.RealmService.DidNotReceive().Create(
|
||||
// Arg.Any<RealmCreateRequest>(),
|
||||
// Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,11 +80,14 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
public async Task GetOpenIdConfiguration_Success()
|
||||
{
|
||||
// setup
|
||||
factory.RealmService.FindBySlug(Arg.Is<string>("foo"), Arg.Any<CancellationToken>())
|
||||
.Returns(new Realm());
|
||||
|
||||
await ScopedContextAsync(async db =>
|
||||
{
|
||||
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo" });
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
});
|
||||
|
||||
// act
|
||||
var client = factory.CreateClient();
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -91,11 +106,56 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
public async Task GetOpenIdConfiguration_NotFound(string slug)
|
||||
{
|
||||
// act
|
||||
var client = factory.CreateClient();
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// verify
|
||||
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 Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.VisualStudio.TestPlatform.TestHost;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
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")]
|
||||
public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
|
||||
|
||||
// Per standard this field is optional for now we will use RS256
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; } = "RS256";
|
||||
// Per standard this field is optional, commented out for now as it seems not
|
||||
// have any good use in an identity server. Anyone validating tokens should use
|
||||
// the algorithm specified in the header of the token.
|
||||
// [JsonPropertyName("alg")]
|
||||
// public string? Algorithm { get; set; } = "RS256";
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public required string KeyId { get; set; }
|
||||
|
|
@ -31,9 +33,9 @@ public class JsonWebKey
|
|||
// Optional fields
|
||||
[JsonPropertyName("x5c")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<string> X509CertificateChain { get; set; }
|
||||
public List<string>? X509CertificateChain { get; set; }
|
||||
|
||||
[JsonPropertyName("x5t")]
|
||||
[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 IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Api.Validation;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static class RealmApi
|
||||
{
|
||||
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
|
||||
|
|
@ -18,7 +27,8 @@ public static class RealmApi
|
|||
.WithName("Create Realm")
|
||||
.Produces(StatusCodes.Status201Created);
|
||||
|
||||
var realmSlugGroup = realmsGroup.MapGroup("{slug}");
|
||||
var realmSlugGroup = realmsGroup.MapGroup("{slug}")
|
||||
.AddEndpointFilter<SlugValidationFilter>();
|
||||
realmSlugGroup.MapGet("", GetRealmInfo);
|
||||
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
|
||||
|
||||
|
|
@ -39,9 +49,15 @@ public static class RealmApi
|
|||
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)
|
||||
|
|
@ -54,17 +70,12 @@ public static class RealmApi
|
|||
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,
|
||||
HttpContext context,
|
||||
string slug)
|
||||
HttpContext context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(slug))
|
||||
return TypedResults.BadRequest();
|
||||
|
||||
var realm = await realmService.FindBySlug(slug);
|
||||
if (realm is null)
|
||||
return TypedResults.NotFound();
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
|
||||
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
|
||||
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 IdentityShroud.Api;
|
||||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Api.Validation;
|
||||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Services;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Json;
|
||||
|
||||
|
|
@ -34,8 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder)
|
|||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
services.AddOpenApi();
|
||||
services.AddScoped<Db>();
|
||||
services.AddScoped<IRealmService, RealmService>();
|
||||
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
|
||||
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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,23 +8,13 @@ namespace IdentityShroud.Core.Tests.Fixtures;
|
|||
|
||||
public class DbFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly IContainer _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);
|
||||
private readonly PostgreSqlContainer _postgresqlServer;
|
||||
|
||||
public Db CreateDbContext(string dbName = "testdb")
|
||||
{
|
||||
var db = new Db(Options.Create<DbConfiguration>(new()
|
||||
{
|
||||
ConnectionString = ConnectionString + ";Database=" + dbName,
|
||||
ConnectionString = _postgresqlServer.GetConnectionString(),
|
||||
LogSensitiveData = false,
|
||||
}), new NullLoggerFactory());
|
||||
return db;
|
||||
|
|
@ -33,8 +23,7 @@ public class DbFixture : IAsyncLifetime
|
|||
public DbFixture()
|
||||
{
|
||||
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
|
||||
.WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D"))
|
||||
.WithPassword(Password)
|
||||
.WithName("is-dbfixture-" + Guid.NewGuid().ToString("D"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +39,7 @@ public class DbFixture : IAsyncLifetime
|
|||
|
||||
public NpgsqlConnection GetConnection(string dbname)
|
||||
{
|
||||
string connString = ConnectionString
|
||||
string connString = _postgresqlServer.GetConnectionString()
|
||||
+ $";Database={dbname}";
|
||||
var connection = new NpgsqlConnection(connString);
|
||||
connection.Open();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
|
||||
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
||||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
using FluentResults;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using IdentityShroud.Core.Tests.Substitutes;
|
||||
using IdentityShroud.TestUtils.Substitutes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IdentityShroud.Core.Tests.Services;
|
||||
|
|
|
|||
|
|
@ -95,14 +95,5 @@ public static class RsaKeyLoader
|
|||
string pemContent = System.IO.File.ReadAllText(filePath);
|
||||
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;
|
||||
|
||||
public class Client
|
||||
{
|
||||
public Guid Id { 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.Schema;
|
||||
using IdentityShroud.Core.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IdentityShroud.Core.Model;
|
||||
|
||||
|
|
@ -19,4 +21,10 @@ public class Realm
|
|||
public List<Client> Clients { 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)
|
||||
{
|
||||
using var aes = new AesGcm(key);
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
|
||||
byte[] ciphertext = new byte[plaintext.Length];
|
||||
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
|
||||
int tagSize = AesGcm.TagByteSizes.MaxSize;
|
||||
using var aes = new AesGcm(key, tagSize);
|
||||
|
||||
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);
|
||||
// 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> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
|
||||
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
|
||||
|
||||
|
||||
byte[] plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(key);
|
||||
using var aes = new AesGcm(key, tagSize);
|
||||
try
|
||||
{
|
||||
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
|
||||
{
|
||||
/// <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<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)
|
||||
{
|
||||
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)
|
||||
|
|
@ -35,6 +36,15 @@ public class RealmService(
|
|||
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()
|
||||
{
|
||||
using RSA rsa = RSA.Create(2048);
|
||||
|
|
|
|||
|
|
@ -12,4 +12,19 @@
|
|||
<PackageReference Include="xunit.v3.assert" Version="3.2.2" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
|
||||
namespace IdentityShroud.Core.Tests.Substitutes;
|
||||
namespace IdentityShroud.TestUtils.Substitutes;
|
||||
|
||||
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">
|
||||
<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_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_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_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_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/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/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 />
|
||||
</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">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
|
|
@ -20,4 +29,5 @@
|
|||
<Solution />
|
||||
</SessionState></s:String>
|
||||
|
||||
|
||||
</wpf:ResourceDictionary>
|
||||
Loading…
Add table
Add a link
Reference in a new issue