Still working on getting client credential flow complete, most of the request works but still working on generating the JWT.
This commit is contained in:
parent
1a8c63808a
commit
8782ef39c6
80 changed files with 1331 additions and 414 deletions
|
|
@ -1,16 +1,26 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using IdentityShroud.Core;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentResults;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Tests;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace IdentityShroud.Api.Tests.Apis;
|
||||
|
||||
public class ClientApiTests : IClassFixture<ApplicationFactory>
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
TypeInfoResolver = AppJsonSerializerContext.Default,
|
||||
};
|
||||
private readonly ApplicationFactory _factory;
|
||||
|
||||
public ClientApiTests(ApplicationFactory factory)
|
||||
|
|
@ -32,7 +42,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|||
public async Task Create_Validation(string? clientId, bool succeeds, string fieldName)
|
||||
{
|
||||
// setup
|
||||
Realm realm = await CreateRealmAsync("test-realm", "Test Realm");
|
||||
var realm = await CreateRealmAsync("test-realm", "Test Realm");
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
|
|
@ -64,32 +74,56 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|||
[Fact]
|
||||
public async Task Create_Success_ReturnsCreatedWithLocation()
|
||||
{
|
||||
// setup
|
||||
Realm realm = await CreateRealmAsync("create-realm", "Create Realm");
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// act
|
||||
var response = await client.PostAsync(
|
||||
$"/api/v1/realms/{realm.Id}/clients",
|
||||
JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
#if DEBUG
|
||||
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
#endif
|
||||
var body = await DoCreateRequest("""
|
||||
{
|
||||
"clientId": "new-client",
|
||||
"name": "New Client"
|
||||
}
|
||||
""");
|
||||
|
||||
// verify
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<ClientCreateReponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("new-client", body.ClientId);
|
||||
Assert.True(body.Id > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Success_CreatesSecret()
|
||||
{
|
||||
// act
|
||||
var body = await DoCreateRequest("""
|
||||
{
|
||||
"clientId": "new-client",
|
||||
"name": "New Client",
|
||||
"confidential": true,
|
||||
"generateSecret": true
|
||||
}
|
||||
""");
|
||||
|
||||
// verify
|
||||
body.ShouldNotBeNull();
|
||||
body.Secret.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
private async Task<ClientRepresentation?> DoCreateRequest(
|
||||
string request)
|
||||
{
|
||||
var realm = await CreateRealmAsync("create-realm", "Create Realm");
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.PostAsync(
|
||||
$"/api/v1/realms/{realm.Id}/clients",
|
||||
//JsonContent.Create(request),
|
||||
new StringContent(request, Encoding.UTF8, "application/json"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(HttpStatusCode.Created == response.StatusCode, contents);
|
||||
|
||||
return JsonSerializer.Deserialize<ClientRepresentation>(contents, _jsonOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnknownRealm_ReturnsNotFound()
|
||||
{
|
||||
|
|
@ -107,7 +141,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|||
public async Task Get_Success()
|
||||
{
|
||||
// setup
|
||||
Realm realm = await CreateRealmAsync("get-realm", "Get Realm");
|
||||
var realm = await CreateRealmAsync("get-realm", "Get Realm");
|
||||
Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client");
|
||||
|
||||
var httpClient = _factory.CreateClient();
|
||||
|
|
@ -138,7 +172,7 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|||
public async Task Get_UnknownClient_ReturnsNotFound()
|
||||
{
|
||||
// setup
|
||||
Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
|
||||
var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
|
||||
|
||||
var httpClient = _factory.CreateClient();
|
||||
|
||||
|
|
@ -154,11 +188,11 @@ public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|||
private async Task<Realm> CreateRealmAsync(string slug, string name)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
||||
var realm = new Realm { Slug = slug, Name = name };
|
||||
db.Realms.Add(realm);
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return realm;
|
||||
var realmService = scope.ServiceProvider.GetRequiredService<IRealmService>();
|
||||
Result<Realm> result = await realmService.Create(
|
||||
new(null, slug, name),
|
||||
TestContext.Current.CancellationToken);
|
||||
return ResultAssert.Success(result);
|
||||
}
|
||||
|
||||
private async Task<Client> CreateClientAsync(Realm realm, string clientId, string? name = null)
|
||||
|
|
|
|||
123
IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs
Normal file
123
IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using IdentityShroud.Api.Apis;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace IdentityShroud.Api.Tests.Apis;
|
||||
|
||||
public class OpenIdApiTests : IClassFixture<ApplicationFactory>
|
||||
{
|
||||
private readonly ApplicationFactory _factory;
|
||||
|
||||
public OpenIdApiTests(ApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
||||
if (!db.Database.EnsureCreated())
|
||||
{
|
||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ClientCredentialsFlow(bool useAuthenticationHeader)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var createRealmResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
|
||||
{
|
||||
Slug = "foo",
|
||||
Name = "Test'",
|
||||
}),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
createRealmResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var realm = await createRealmResponse.Content.ReadFromJsonAsync<RealmRepresentation>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
realm.ShouldNotBeNull();
|
||||
realm.Id.ShouldNotBe(Guid.Empty);
|
||||
|
||||
var createClientResponse = await client.PostAsync(
|
||||
$"/api/v1/realms/{realm.Id}/clients",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
ClientId = "myclient",
|
||||
Name = "New Client",
|
||||
Confidential = true,
|
||||
AllowClientCredentialsFlow = true,
|
||||
GenerateSecret = true,
|
||||
}),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
createClientResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Act
|
||||
const string clientId = "myclient";
|
||||
|
||||
var data = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("client_id", clientId),
|
||||
new KeyValuePair<string, string>("client_secret", "secret"),
|
||||
new KeyValuePair<string, string>("response_type", "token"),
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
};
|
||||
|
||||
if (useAuthenticationHeader)
|
||||
{
|
||||
// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("basic",
|
||||
// Convert.ToBase64String($"{clientId}:{clientSecret}"))
|
||||
}
|
||||
|
||||
var content = new FormUrlEncodedContent(data);
|
||||
var response = await client.PostAsync(
|
||||
"/auth/realms/foo/openid-connect/token",
|
||||
content,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify
|
||||
// var responseJson = await response.Content.ReadAsStringAsync(
|
||||
// TestContext.Current.CancellationToken);
|
||||
// Console.WriteLine($"Response: {responseJson}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Cache-Control: no-store
|
||||
response.Headers.CacheControl.ShouldNotBeNull()
|
||||
.NoStore.ShouldBe(true);
|
||||
// Pragma: no-cache
|
||||
response.Headers.Pragma.ShouldNotBeNull()
|
||||
.ShouldContain(new NameValueHeaderValue("no-cache"));
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>();
|
||||
payload.ShouldNotBeNull();
|
||||
Assert.Multiple(
|
||||
() => payload.AccessToken.ShouldNotBeNull(),
|
||||
() => payload.TokenType.ShouldBe("bearer"),
|
||||
() => payload.ExpiresIn.ShouldBe(3600));
|
||||
|
||||
// - refresh_token OPTIONAL
|
||||
// - scope OPTIONAL when identical to request otherwise REQUIRED
|
||||
}
|
||||
|
||||
internal class TokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; set; }
|
||||
[JsonPropertyName("token_type")]
|
||||
public string? TokenType { get; set; }
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
using System.Buffers.Text;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Nodes;
|
||||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using IdentityShroud.TestUtils.Asserts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
|
|
@ -124,28 +123,16 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
|
|||
[Fact]
|
||||
public async Task GetJwks()
|
||||
{
|
||||
// setup
|
||||
IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService<IDekEncryptionService>();
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
RealmKey realmKey = new()
|
||||
var client = _factory.CreateClient();
|
||||
var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
KeyType = "RSA",
|
||||
Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await ScopedContextAsync(async db =>
|
||||
{
|
||||
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
});
|
||||
Slug = "foo",
|
||||
Name = "Test'",
|
||||
}),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
// act
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -153,9 +140,16 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
|
|||
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
|
||||
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
|
||||
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
|
||||
string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString();
|
||||
Assert.NotNull(kid);
|
||||
Assert.True(kid.Length >= 16);
|
||||
|
||||
//if (JsonObjectAssert.NavigateToPath(payload, "keys[0].kty")?.AsValue().ToString() == "RSA")
|
||||
|
||||
JsonObjectAssert.Equal("RSA", payload, "keys[0].kty");
|
||||
string? n = payload["keys"]?[0]?["n"]?.AsValue().ToString();
|
||||
string? e = payload["keys"]?[0]?["e"]?.AsValue().ToString();
|
||||
AssertRsaParams(n, e);
|
||||
}
|
||||
|
||||
private async Task ScopedContextAsync(
|
||||
|
|
@ -166,4 +160,22 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
|
|||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
||||
await action(db);
|
||||
}
|
||||
|
||||
private static void AssertRsaParams(string? n, string? e)
|
||||
{
|
||||
Assert.NotNull(n);
|
||||
Assert.NotNull(e);
|
||||
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportParameters(new RSAParameters
|
||||
{
|
||||
Modulus = Base64Url.DecodeFromChars(n),
|
||||
Exponent = Base64Url.DecodeFromChars(e)
|
||||
});
|
||||
|
||||
// If n and e are complete nonsense, this will throw
|
||||
var encrypted = rsa.Encrypt(new byte[] { 1, 2, 3 }, RSAEncryptionPadding.OaepSHA256);
|
||||
Assert.NotNull(encrypted);
|
||||
Assert.NotEmpty(encrypted);
|
||||
}
|
||||
}
|
||||
20
IdentityShroud.Api.Tests/HeaderHelpersTests.cs
Normal file
20
IdentityShroud.Api.Tests/HeaderHelpersTests.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using IdentityShroud.Api.Helpers;
|
||||
|
||||
namespace IdentityShroud.Api.Tests;
|
||||
|
||||
public class HeaderHelpersTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Basic dXNlcjpzZWNyZXQ=", true, "user", "secret")]
|
||||
[InlineData("baSIC dXNlcjpzZWNyZXQ=", true, "user", "secret")]
|
||||
[InlineData("Basic dXNlcnNlY3JldA==", false, null, null)] // no colon to seperate user and password
|
||||
[InlineData("Bearer dXNlcjpzZWNyZXQ=", false, null, null)]
|
||||
public void TryDecodeBasicAuth(string input, bool expectedResult, string? expectedUser, string? expectedPassword)
|
||||
{
|
||||
var result = HeaderHelpers.TryDecodeBasicAuth(input, out string? user, out string? password);
|
||||
|
||||
Assert.Equal(expectedResult, result);
|
||||
Assert.Equal(expectedUser, user);
|
||||
Assert.Equal(expectedPassword, password);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.TestUtils.Substitutes;
|
||||
|
||||
namespace IdentityShroud.Api.Tests.Mappers;
|
||||
|
||||
public class KeyServiceTests
|
||||
{
|
||||
private readonly NullDekEncryptionService _dekEncryptionService = new();
|
||||
|
||||
[Fact]
|
||||
public void Test()
|
||||
{
|
||||
// Setup
|
||||
using RSA rsa = RSA.Create(2048);
|
||||
|
||||
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
DekId kid = DekId.NewId();
|
||||
|
||||
RealmKey realmKey = new()
|
||||
{
|
||||
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
|
||||
KeyType = "RSA",
|
||||
Key = new(_dekEncryptionService.KeyId, rsa.ExportPkcs8PrivateKey()),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Priority = 10,
|
||||
};
|
||||
|
||||
// Act
|
||||
KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService());
|
||||
var jwk = sut.CreateJsonWebKey(realmKey);
|
||||
|
||||
Assert.NotNull(jwk);
|
||||
Assert.Equal("RSA", jwk.KeyType);
|
||||
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
|
||||
Assert.Equal("sig", jwk.Use);
|
||||
Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
|
||||
Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,7 @@ using IdentityShroud.Core.Model;
|
|||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
|
||||
|
||||
public record ClientCreateReponse(int Id, string ClientId);
|
||||
namespace IdentityShroud.Api.Apis;
|
||||
|
||||
/// <summary>
|
||||
/// The part of the api below realms/{slug}/clients
|
||||
|
|
@ -23,9 +19,9 @@ public static class ClientApi
|
|||
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
|
||||
|
||||
clientsGroup.MapPost("", ClientCreate)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Validate<ClientCreateRequest>()
|
||||
.WithName("ClientCreate")
|
||||
.Produces(StatusCodes.Status201Created);
|
||||
.WithName("ClientCreate");
|
||||
|
||||
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
|
||||
.AddEndpointFilter<ClientIdValidationFilter>();
|
||||
|
|
@ -43,11 +39,12 @@ public static class ClientApi
|
|||
return TypedResults.Ok(new ClientMapper().ToDto(client));
|
||||
}
|
||||
|
||||
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
|
||||
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>>
|
||||
ClientCreate(
|
||||
Guid realmId,
|
||||
ClientCreateRequest request,
|
||||
[FromServices] IClientService service,
|
||||
[FromServices] IDataEncryptionService cryptor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -60,9 +57,12 @@ public static class ClientApi
|
|||
}
|
||||
|
||||
Client client = result.Value;
|
||||
|
||||
ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client);
|
||||
var secret = SelectBestSecret(client.Secrets);
|
||||
if (secret is {} s)
|
||||
clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret);
|
||||
return TypedResults.CreatedAtRoute(
|
||||
new ClientCreateReponse(client.Id, client.ClientId),
|
||||
clientRepresentation,
|
||||
ClientGetRouteName,
|
||||
new RouteValueDictionary()
|
||||
{
|
||||
|
|
@ -70,4 +70,28 @@ public static class ClientApi
|
|||
["clientId"] = client.Id,
|
||||
});
|
||||
}
|
||||
|
||||
private static ClientSecret? SelectBestSecret(List<ClientSecret> clientSecrets)
|
||||
{
|
||||
ClientSecret? result = null;
|
||||
|
||||
foreach (var cs in clientSecrets)
|
||||
{
|
||||
if (cs.RevokedAt is null && (!cs.Expires.HasValue || cs.Expires.Value > DateTime.UtcNow))
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
result = cs;
|
||||
}
|
||||
else
|
||||
{
|
||||
int d = (cs.Expires ?? DateTime.MaxValue).CompareTo(result.Expires ?? DateTime.MaxValue);
|
||||
if (d > 0 || (d == 0 && cs.CreatedAt > result.CreatedAt))
|
||||
result = cs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,10 @@ public record ClientRepresentation
|
|||
|
||||
public string? SignatureAlgorithm { get; set; }
|
||||
|
||||
public bool Confidential { get; set; }
|
||||
public bool AllowClientCredentialsFlow { get; set; } = false;
|
||||
|
||||
public required DateTime CreatedAt { get; set; }
|
||||
|
||||
public string? Secret { get; set; }
|
||||
}
|
||||
3
IdentityShroud.Api/Apis/Dto/ErrorDto.cs
Normal file
3
IdentityShroud.Api/Apis/Dto/ErrorDto.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
namespace IdentityShroud.Api.Apis;
|
||||
|
||||
public record ErrorDto(string Error);
|
||||
6
IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
Normal file
6
IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace IdentityShroud.Api.Apis;
|
||||
|
||||
public record RealmRepresentation(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name);
|
||||
21
IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
Normal file
21
IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IdentityShroud.Core.DTO.OpenId;
|
||||
|
||||
public class TokenRequestBody
|
||||
{
|
||||
[JsonPropertyName("grant_type")]
|
||||
public GrantTypes GrantType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// In most cases required but not when basic auth header is used
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_id")]
|
||||
public string? ClientId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("client_secret")]
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
}
|
||||
|
|
@ -2,8 +2,9 @@ namespace IdentityShroud.Api;
|
|||
|
||||
public static class EndpointRouteBuilderExtensions
|
||||
{
|
||||
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
|
||||
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
|
||||
public static IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder)
|
||||
where TDto : class
|
||||
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
|
||||
|
||||
public static void MapApis(this IEndpointRouteBuilder erp)
|
||||
{
|
||||
|
|
|
|||
50
IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
Normal file
50
IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace IdentityShroud.Api.Helpers;
|
||||
|
||||
public static class HeaderHelpers
|
||||
{
|
||||
public static bool TryGetBasicAuth(
|
||||
HttpContext context,
|
||||
[NotNullWhen(true)] out string? user,
|
||||
[NotNullWhen(true)] out string? password)
|
||||
{
|
||||
var headers = context?.Request.Headers;
|
||||
if (headers is not null)
|
||||
{
|
||||
if (headers.TryGetValue("Authorization", out StringValues s))
|
||||
return TryDecodeBasicAuth(s.ToString(), out user, out password);
|
||||
}
|
||||
|
||||
user = password = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryDecodeBasicAuth(
|
||||
string authorizationHeader,
|
||||
[NotNullWhen(true)] out string? user,
|
||||
[NotNullWhen(true)] out string? password)
|
||||
{
|
||||
if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ReadOnlySpan<char> val = authorizationHeader.AsSpan(6); // basic + space
|
||||
Span<byte> b = new byte[(val.Length * 6 / 8) + 1];
|
||||
if (Convert.TryFromBase64Chars(val, b, out int written))
|
||||
{
|
||||
int sepIdx = b.IndexOf((byte)':');
|
||||
if (sepIdx > 0 && sepIdx < written - 1)
|
||||
{
|
||||
user = Encoding.UTF8.GetString(b.Slice(0, sepIdx));
|
||||
password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user = password = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
namespace IdentityShroud.Api.Apis.ISResults;
|
||||
|
||||
public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult
|
||||
{
|
||||
private readonly List<string> _wwwAuthenticateValues;
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UnauthorizedHttpResult"/> class.
|
||||
/// </summary>
|
||||
internal ISUnauthorizedHttpResult(List<string> wwwAuthenticateValues)
|
||||
{
|
||||
_wwwAuthenticateValues = wwwAuthenticateValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP status code: <see cref="StatusCodes.Status401Unauthorized"/>
|
||||
/// </summary>
|
||||
public int StatusCode => StatusCodes.Status401Unauthorized;
|
||||
|
||||
int? IStatusCodeHttpResult.StatusCode => StatusCode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
// Creating the logger with a string to preserve the category after the refactoring.
|
||||
// var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
|
||||
// var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult");
|
||||
// HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode);
|
||||
|
||||
|
||||
httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray());
|
||||
|
||||
httpContext.Response.StatusCode = StatusCode;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,28 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
|
||||
namespace IdentityShroud.Api.Mappers;
|
||||
|
||||
public class KeyMapper(IKeyService keyService)
|
||||
public class KeyMapper(IKeyProviderFactory keyProviderFactory)
|
||||
{
|
||||
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
|
||||
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmSigningKey> keys)
|
||||
{
|
||||
JsonWebKeySet wks = new();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
var wk = keyService.CreateJsonWebKey(k);
|
||||
if (wk is {})
|
||||
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType);
|
||||
if (provider.IsPublic)
|
||||
{
|
||||
wks.Keys.Add(wk);
|
||||
JsonWebKey jwk = new()
|
||||
{
|
||||
KeyId = k.Id.ToString(),
|
||||
KeyType = k.KeyType,
|
||||
Use = "sig",
|
||||
};
|
||||
|
||||
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
|
||||
wks.Keys.Add(jwk);
|
||||
}
|
||||
}
|
||||
return wks;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
using IdentityShroud.Api.Apis;
|
||||
using IdentityShroud.Api.Apis.ISResults;
|
||||
using IdentityShroud.Api.Helpers;
|
||||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services.OpenId;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
|
@ -11,8 +15,6 @@ public static class OpenIdEndpoints
|
|||
{
|
||||
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
|
||||
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
|
||||
|
||||
|
||||
public static void MapEndpoints(this IEndpointRouteBuilder erp)
|
||||
{
|
||||
var realmsGroup = erp.MapGroup("/auth/realms");
|
||||
|
|
@ -56,17 +58,79 @@ public static class OpenIdEndpoints
|
|||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
await realmService.LoadActiveKeys(realm);
|
||||
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
|
||||
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys));
|
||||
}
|
||||
|
||||
private static Task OpenIdConnectToken(HttpContext context)
|
||||
private static async Task<Results<
|
||||
Ok<TokenResponse>,
|
||||
BadRequest<ErrorDto>,
|
||||
ISUnauthorizedHttpResult
|
||||
>> OpenIdConnectToken(
|
||||
string realmSlug,
|
||||
[FromServices] IClientService clientService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
IFormCollection form = await context.Request.ReadFormAsync();
|
||||
|
||||
string grantType = form["grant_type"].ToString();
|
||||
string clientId = form["client_id"].ToString();
|
||||
string scope = form["scope"].ToString();
|
||||
|
||||
if (grantType == "client_credentials")
|
||||
{
|
||||
string? clientSecret = null;
|
||||
bool withAuthHeader = false;
|
||||
if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password))
|
||||
{
|
||||
withAuthHeader = true;
|
||||
clientId = user;
|
||||
clientSecret = password;
|
||||
}
|
||||
clientSecret ??= form["client_secret"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(clientId) ||
|
||||
string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
return CreateBadRequest("invalid_request");
|
||||
}
|
||||
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
Client? client = await clientService.GetByClientId(realm.Id, clientId, ct);
|
||||
if (client is null)
|
||||
{
|
||||
if (withAuthHeader)
|
||||
{
|
||||
return new ISUnauthorizedHttpResult([$"Basic realm=\"{realm.Slug}\""]);
|
||||
}
|
||||
return CreateBadRequest("invalid_client");
|
||||
}
|
||||
|
||||
if (!client.AllowClientCredentialsFlow)
|
||||
return CreateBadRequest("unauthorized_client");
|
||||
|
||||
}
|
||||
else
|
||||
return CreateBadRequest("unsupported_grant_type");
|
||||
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.Pragma = "no-cache";
|
||||
|
||||
return TypedResults.Ok(new TokenResponse()
|
||||
{
|
||||
AccessToken = "token",
|
||||
TokenType = "bearer",
|
||||
ExpiresIn = 3600,
|
||||
});
|
||||
}
|
||||
|
||||
private static BadRequest<ErrorDto> CreateBadRequest(string error) =>
|
||||
TypedResults.BadRequest(new ErrorDto(error));
|
||||
|
||||
|
||||
|
||||
private static Task OpenIdConnectAuth(HttpContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using IdentityShroud.Api.Apis;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
|
@ -19,31 +19,56 @@ public static class HttpContextExtensions
|
|||
|
||||
public static class RealmApi
|
||||
{
|
||||
public const string GetRealmRoute = "Get Realm";
|
||||
public const string CreateRealmRoute = "Create Realm";
|
||||
|
||||
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
|
||||
{
|
||||
var realmsGroup = erp.MapGroup("/api/v1/realms");
|
||||
|
||||
realmsGroup.MapPost("", RealmCreate)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Validate<RealmCreateRequest>()
|
||||
.WithName("Create Realm")
|
||||
.Produces(StatusCodes.Status201Created);
|
||||
.WithName(CreateRealmRoute);
|
||||
|
||||
|
||||
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
|
||||
.AddEndpointFilter<RealmIdValidationFilter>();
|
||||
|
||||
realmIdGroup.MapGet("", RealmGet)
|
||||
.WithName(GetRealmRoute);
|
||||
|
||||
ClientApi.MapEndpoints(realmIdGroup);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
|
||||
private static Ok<RealmRepresentation> RealmGet(
|
||||
Guid realmId,
|
||||
HttpContext context)
|
||||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
return TypedResults.Ok(MapToRepresentation(realm));
|
||||
}
|
||||
|
||||
private static async Task<Results<CreatedAtRoute<RealmRepresentation>, InternalServerError>>
|
||||
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
|
||||
{
|
||||
var response = await service.Create(request);
|
||||
if (response.IsSuccess)
|
||||
return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value);
|
||||
{
|
||||
var realm = response.Value;
|
||||
return TypedResults.CreatedAtRoute(
|
||||
MapToRepresentation(realm),
|
||||
GetRealmRoute,
|
||||
new { realmId = realm.Id });
|
||||
}
|
||||
|
||||
// TODO make helper to convert failure response to a proper HTTP result.
|
||||
return TypedResults.InternalServerError();
|
||||
}
|
||||
|
||||
private static RealmRepresentation MapToRepresentation(Realm realm)
|
||||
=> new(realm.Id, realm.Slug, realm.Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ namespace IdentityShroud.Api;
|
|||
public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest>
|
||||
{
|
||||
// most of standard ascii minus the control characters and space
|
||||
private const string ClientIdPattern = "^[\x21-\x7E]+";
|
||||
private const string ClientIdPattern = "^[a-zA-Z0-9_-]+";
|
||||
|
||||
private string[] AllowedAlgorithms = [ "RS256", "ES256" ];
|
||||
private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ];
|
||||
|
||||
public ClientCreateRequestValidator()
|
||||
{
|
||||
|
|
@ -16,7 +16,9 @@ public class ClientCreateRequestValidator : AbstractValidator<ClientCreateReques
|
|||
RuleFor(e => e.Name).MaximumLength(80);
|
||||
RuleFor(e => e.Description).MaximumLength(2048);
|
||||
RuleFor(e => e.SignatureAlgorithm)
|
||||
.Must(v => v is null || AllowedAlgorithms.Contains(v))
|
||||
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null");
|
||||
.Must(v => v is null || _allowedAlgorithms.Contains(v))
|
||||
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null");
|
||||
RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true);
|
||||
RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
using System.Text.Json.Serialization;
|
||||
using IdentityShroud.Api.Apis;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
[JsonSerializable(typeof(ClientRepresentation))]
|
||||
[JsonSerializable(typeof(ErrorDto))]
|
||||
[JsonSerializable(typeof(OpenIdConfiguration))]
|
||||
[JsonSerializable(typeof(RealmCreateRequest))]
|
||||
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
||||
public partial class AppJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
24
IdentityShroud.Api/GlobalExceptionHandler.cs
Normal file
24
IdentityShroud.Api/GlobalExceptionHandler.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
||||
=> _logger = logger;
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogError(exception, "Exception type: {Type}, Message: {Message}",
|
||||
exception.GetType().Name, exception.Message);
|
||||
|
||||
// Return false to let other handlers or the default handle it
|
||||
// Return true to mark it as handled
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
using FluentValidation;
|
||||
using IdentityShroud.Api;
|
||||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Services;
|
||||
|
|
@ -30,7 +30,7 @@ void ConfigureBuilder(WebApplicationBuilder builder)
|
|||
//services.AddControllers();
|
||||
services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
|
||||
});
|
||||
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
|
|
@ -52,6 +52,9 @@ void ConfigureBuilder(WebApplicationBuilder builder)
|
|||
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
services.AddProblemDetails();
|
||||
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.Enrich.FromLogContext()
|
||||
//.Enrich.With<UserEnricher>()
|
||||
|
|
@ -60,6 +63,7 @@ void ConfigureBuilder(WebApplicationBuilder builder)
|
|||
|
||||
void ConfigureApplication(WebApplication app)
|
||||
{
|
||||
app.UseExceptionHandler();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="jose-jwt" Version="5.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ public class JwtSignatureGeneratorTests
|
|||
]
|
||||
}
|
||||
""";
|
||||
|
||||
JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!;
|
||||
using RSA publicKey = LoadFromJwk(keySet.Keys[0]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
using IdentityShroud.Api;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using IdentityShroud.TestUtils.Substitutes;
|
||||
|
|
@ -7,6 +11,29 @@ using Microsoft.EntityFrameworkCore;
|
|||
|
||||
namespace IdentityShroud.Core.Tests.Services;
|
||||
|
||||
public static class RealmDekBuilder
|
||||
{
|
||||
public static RealmDek DefaultActive() =>
|
||||
new()
|
||||
{
|
||||
Id = DekId.NewId(),
|
||||
Active = true,
|
||||
Algorithm = KeyType.AES,
|
||||
KeyData = new EncryptedDek(KekId.NewId(),
|
||||
[
|
||||
0
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
public static class ClientCreateRequestBuilder
|
||||
{
|
||||
public static ClientCreateRequest Default() => new(
|
||||
"test-client",
|
||||
"Test Client",
|
||||
"A test client");
|
||||
}
|
||||
|
||||
public class ClientServiceTests : IClassFixture<DbFixture>
|
||||
{
|
||||
private readonly DbFixture _dbFixture;
|
||||
|
|
@ -34,15 +61,28 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
{
|
||||
if (!db.Realms.Any(r => r.Id == _realmId))
|
||||
{
|
||||
db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" });
|
||||
db.Realms.Add(new()
|
||||
{
|
||||
Id = _realmId,
|
||||
Slug = "test-realm",
|
||||
Name = "Test Realm",
|
||||
DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ],
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private ClientService CreateSut(Db db) => new(db,
|
||||
_dataEncryptionService,
|
||||
new ClientCreateRequestValidator(),
|
||||
_clock);
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Create(bool allowClientCredentialsFlow)
|
||||
public async Task Create(bool withSecret)
|
||||
{
|
||||
// Setup
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
|
@ -52,15 +92,13 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
await using (var db = _dbFixture.CreateDbContext())
|
||||
{
|
||||
// Act
|
||||
ClientService sut = new(db, _dataEncryptionService, _clock);
|
||||
ClientService sut = CreateSut(db);
|
||||
var response = await sut.Create(
|
||||
_realmId,
|
||||
new ClientCreateRequest
|
||||
ClientCreateRequestBuilder.Default() with
|
||||
{
|
||||
ClientId = "test-client",
|
||||
Name = "Test Client",
|
||||
Description = "A test client",
|
||||
AllowClientCredentialsFlow = allowClientCredentialsFlow,
|
||||
Confidential = withSecret,
|
||||
GenerateSecret = withSecret,
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -70,7 +108,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
Assert.Equal("test-client", val.ClientId);
|
||||
Assert.Equal("Test Client", val.Name);
|
||||
Assert.Equal("A test client", val.Description);
|
||||
Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow);
|
||||
Assert.Equal(withSecret, val.Confidential);
|
||||
Assert.Equal(now, val.CreatedAt);
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +118,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
.Include(e => e.Secrets)
|
||||
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
|
||||
|
||||
if (allowClientCredentialsFlow)
|
||||
if (withSecret)
|
||||
Assert.Single(dbRecord.Secrets);
|
||||
else
|
||||
Assert.Empty(dbRecord.Secrets);
|
||||
|
|
@ -108,7 +146,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
|
||||
await using var actContext = _dbFixture.CreateDbContext();
|
||||
// Act
|
||||
ClientService sut = new(actContext, _dataEncryptionService, _clock);
|
||||
ClientService sut = CreateSut(actContext);
|
||||
Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify
|
||||
|
|
@ -143,7 +181,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
|
|||
|
||||
await using var actContext = _dbFixture.CreateDbContext();
|
||||
// Act
|
||||
ClientService sut = new(actContext, _dataEncryptionService, _clock);
|
||||
ClientService sut = CreateSut(actContext);
|
||||
Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.TestUtils.Substitutes;
|
||||
|
||||
|
|
@ -9,23 +10,20 @@ namespace IdentityShroud.Core.Tests.Services;
|
|||
|
||||
public class DataEncryptionServiceTests
|
||||
{
|
||||
private readonly IRealmContext _realmContext = Substitute.For<IRealmContext>();
|
||||
// private readonly IRealmContext _realmContext = Substitute.For<IRealmContext>();
|
||||
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For<IDekEncryptionService>();
|
||||
|
||||
private readonly DekId _activeDekId = DekId.NewId();
|
||||
private readonly DekId _secondDekId = DekId.NewId();
|
||||
private DataEncryptionService CreateSut()
|
||||
=> new(_realmContext, _dekCryptor);
|
||||
=> new(_dekCryptor);
|
||||
|
||||
[Fact]
|
||||
public void Encrypt_UsesActiveKey()
|
||||
{
|
||||
_realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([
|
||||
CreateRealmDek(_secondDekId, false),
|
||||
CreateRealmDek(_activeDekId, true),
|
||||
]);
|
||||
var dek = CreateRealmDek(_activeDekId, true);
|
||||
|
||||
var cipher = CreateSut().Encrypt("Hello"u8);
|
||||
var cipher = CreateSut().Encrypt(dek, "Hello"u8);
|
||||
|
||||
Assert.Equal(_activeDekId, cipher.DekId);
|
||||
}
|
||||
|
|
@ -34,20 +32,18 @@ public class DataEncryptionServiceTests
|
|||
public void Decrypt_UsesCorrectKey()
|
||||
{
|
||||
var first = CreateRealmDek(_activeDekId, true);
|
||||
_realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([ first ]);
|
||||
|
||||
var sut = CreateSut();
|
||||
var cipher = sut.Encrypt("Hello"u8);
|
||||
var cipher = sut.Encrypt(first, "Hello"u8);
|
||||
|
||||
// Deactivate original key
|
||||
first.Active = false;
|
||||
// Make new active
|
||||
var second = CreateRealmDek(_secondDekId, true);
|
||||
// Return both
|
||||
_realmContext.GetDeks(Arg.Any<CancellationToken>()).Returns([ first, second ]);
|
||||
RealmDek[] list = [ first, second ];
|
||||
|
||||
|
||||
var decoded = sut.Decrypt(cipher);
|
||||
var decoded = sut.Decrypt(list, cipher);
|
||||
|
||||
Assert.Equal("Hello"u8, decoded);
|
||||
}
|
||||
|
|
@ -57,7 +53,7 @@ public class DataEncryptionServiceTests
|
|||
{
|
||||
Id = id,
|
||||
Active = active,
|
||||
Algorithm = "AES",
|
||||
Algorithm = KeyType.AES,
|
||||
KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)),
|
||||
RealmId = default,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Services;
|
||||
|
||||
namespace IdentityShroud.Core.Tests.Services;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
using FluentResults;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Services;
|
||||
using IdentityShroud.Core.Tests.Fixtures;
|
||||
using IdentityShroud.TestUtils.Substitutes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
|
||||
namespace IdentityShroud.Core.Tests.Services;
|
||||
|
||||
|
|
@ -12,6 +15,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
{
|
||||
private readonly DbFixture _dbFixture;
|
||||
private readonly IKeyService _keyService = Substitute.For<IKeyService>();
|
||||
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
|
||||
|
||||
public RealmServiceTests(DbFixture dbFixture)
|
||||
{
|
||||
|
|
@ -26,6 +30,9 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
||||
}
|
||||
|
||||
private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService());
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")]
|
||||
|
|
@ -36,20 +43,14 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
if (idString is not null)
|
||||
realmId = new(idString);
|
||||
|
||||
RealmCreateResponse? val;
|
||||
Realm? val;
|
||||
await using (var db = _dbFixture.CreateDbContext())
|
||||
{
|
||||
_keyService.CreateKey(Arg.Any<KeyPolicy>())
|
||||
.Returns(new RealmKey()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
KeyType = "TST",
|
||||
Key = new(KekId.NewId(), [21]),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
.Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21])));
|
||||
// Act
|
||||
RealmService sut = new(db, _keyService);
|
||||
var response = await sut.Create(
|
||||
RealmService sut = CreateSut(db);
|
||||
Result<Realm> response = await sut.Create(
|
||||
new(realmId, "slug", "New realm"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -60,8 +61,12 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
else
|
||||
Assert.NotEqual(Guid.Empty, val.Id);
|
||||
|
||||
Assert.Equal("slug", val.Slug);
|
||||
Assert.Equal("New realm", val.Name);
|
||||
Assert.Multiple(
|
||||
() => val.Slug.ShouldBe("slug"),
|
||||
() => val.Name.ShouldBe("New realm"),
|
||||
() => val.DataEncryptionKeys.ShouldContain(d => d.Active),
|
||||
() => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
|
||||
);
|
||||
|
||||
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
|
||||
}
|
||||
|
|
@ -69,9 +74,9 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
await using (var db = _dbFixture.CreateDbContext())
|
||||
{
|
||||
var dbRecord = await db.Realms
|
||||
.Include(e => e.Keys)
|
||||
.Include(e => e.TokenSigningKeys)
|
||||
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("TST", dbRecord.Keys[0].KeyType);
|
||||
Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +103,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
|
||||
await using var actContext = _dbFixture.CreateDbContext();
|
||||
// Act
|
||||
RealmService sut = new(actContext, _keyService);
|
||||
RealmService sut = CreateSut(actContext);
|
||||
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify
|
||||
|
|
@ -131,7 +136,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
|
|||
|
||||
await using var actContext = _dbFixture.CreateDbContext();
|
||||
// Act
|
||||
RealmService sut = new(actContext, _keyService);
|
||||
RealmService sut = CreateSut(actContext);
|
||||
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using IdentityShroud.Core.DTO;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace IdentityShroud.Core.Tests;
|
||||
|
||||
|
|
@ -74,10 +73,10 @@ public static class JwtReader
|
|||
return new JsonWebToken()
|
||||
{
|
||||
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
|
||||
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
|
||||
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!,
|
||||
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
|
||||
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!,
|
||||
Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
|
||||
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!,
|
||||
Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
using System.Text;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
|
||||
namespace IdentityShroud.Core.Contracts;
|
||||
|
||||
public interface IDataEncryptionService
|
||||
{
|
||||
EncryptedValue Encrypt(ReadOnlySpan<byte> plain);
|
||||
byte[] Decrypt(EncryptedValue input);
|
||||
EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain);
|
||||
byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input);
|
||||
}
|
||||
|
||||
public static class DataEncryptionServiceExtensions
|
||||
{
|
||||
public static string DecryptUtf8ToString(
|
||||
this IDataEncryptionService des,
|
||||
IReadOnlyList<RealmDek> deks,
|
||||
EncryptedValue input)
|
||||
{
|
||||
return Encoding.UTF8.GetString(des.Decrypt(deks, input));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
|
||||
namespace IdentityShroud.Core.Contracts;
|
||||
|
||||
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
|
||||
|
||||
public interface IKeyService
|
||||
{
|
||||
RealmKey CreateKey(KeyPolicy policy);
|
||||
|
||||
JsonWebKey? CreateJsonWebKey(RealmKey realmKey);
|
||||
CreateKeyResponse CreateKey(KeyPolicy policy);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
|
||||
namespace IdentityShroud.Core.Contracts;
|
||||
|
||||
|
|
@ -9,7 +8,7 @@ public interface IRealmService
|
|||
Task<Realm?> FindById(Guid id, CancellationToken ct = default);
|
||||
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
|
||||
|
||||
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
|
||||
Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default);
|
||||
Task LoadActiveKeys(Realm realm);
|
||||
Task LoadDeks(Realm realm);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
namespace IdentityShroud.Core.Contracts;
|
||||
|
||||
public class ClientCreateRequest
|
||||
{
|
||||
public required string ClientId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? SignatureAlgorithm { get; set; }
|
||||
public bool? AllowClientCredentialsFlow { get; set; }
|
||||
}
|
||||
public record ClientCreateRequest(
|
||||
string ClientId,
|
||||
string? Name = null,
|
||||
string? Description = null,
|
||||
string? SignatureAlgorithm = null,
|
||||
bool Confidential = false,
|
||||
bool AllowClientCredentialsFlow = false,
|
||||
bool GenerateSecret = false);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Text.Json.Serialization;
|
||||
using IdentityShroud.Core.Helpers;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
|
||||
namespace IdentityShroud.Core.Messages;
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ namespace IdentityShroud.Core.Messages;
|
|||
public class JsonWebKey
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string KeyType { get; set; } = "RSA";
|
||||
public required KeyType KeyType { get; set; }
|
||||
|
||||
// Common values sig(nature) enc(ryption)
|
||||
[JsonPropertyName("use")]
|
||||
|
|
|
|||
9
IdentityShroud.Core/DTO/OpenId/GrantTypes.cs
Normal file
9
IdentityShroud.Core/DTO/OpenId/GrantTypes.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IdentityShroud.Core.DTO.OpenId;
|
||||
|
||||
public enum GrantTypes
|
||||
{
|
||||
[JsonStringEnumMemberName("client_credentials")]
|
||||
ClientCredentials
|
||||
}
|
||||
19
IdentityShroud.Core/DTO/OpenId/TokenResponse.cs
Normal file
19
IdentityShroud.Core/DTO/OpenId/TokenResponse.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IdentityShroud.Core.Services.OpenId;
|
||||
|
||||
public class TokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public required string AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public required string TokenType { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; set; }
|
||||
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
namespace IdentityShroud.Core.Messages.Realm;
|
||||
|
||||
public record RealmCreateRequest(Guid? Id, string? Slug, string Name);
|
||||
public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null);
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
using IdentityShroud.Core.Security;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class DekIdConverter : ValueConverter<DekId, Guid>
|
||||
{
|
||||
public DekIdConverter()
|
||||
: base(id => id.Id, guid => new DekId(guid))
|
||||
{
|
||||
}
|
||||
}
|
||||
public class DekIdConverter() : ValueConverter<DekId, Guid>(id => id.Id, guid => new DekId(guid));
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class DictionaryToJsonConverter<TKey, TValue> : ValueConverter<Dictionary<TKey, TValue>, string>
|
||||
where TKey : notnull
|
||||
{
|
||||
public DictionaryToJsonConverter() : base(
|
||||
v => JsonSerializer.Serialize(v),
|
||||
v => JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(v) ?? new())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class JwtSigAlgNameConverter() : ValueConverter<JwtSigAlgName, string>(j => j.ToString(), s => new(s));
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using IdentityShroud.Core.Security;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class KekIdConverter : ValueConverter<KekId, Guid>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
using IdentityShroud.Core.Security.Keys;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class KeyTypeConverter() : ValueConverter<KeyType, string>(id => id.ToString(), s => new(s));
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using IdentityShroud.Core.Model;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class RealmSigningKeyIdConverter : ValueConverter<RealmSigningKeyId, Guid>
|
||||
{
|
||||
public RealmSigningKeyIdConverter()
|
||||
: base(id => id.Id, guid => new RealmSigningKeyId(guid))
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Linq.Expressions;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
namespace IdentityShroud.Core.EFCore;
|
||||
|
||||
public class DbConfiguration
|
||||
{
|
||||
|
|
@ -20,7 +20,7 @@ public class Db(
|
|||
{
|
||||
public virtual DbSet<Client> Clients { get; set; }
|
||||
public virtual DbSet<Realm> Realms { get; set; }
|
||||
public virtual DbSet<RealmKey> Keys { get; set; }
|
||||
public virtual DbSet<RealmSigningKey> Keys { get; set; }
|
||||
public virtual DbSet<RealmDek> Deks { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
|
|
@ -50,6 +50,10 @@ public class Db(
|
|||
base.ConfigureConventions(b);
|
||||
|
||||
b.Properties<DekId>().HaveConversion<DekIdConverter>();
|
||||
b.Properties<Dictionary<string, string>>().HaveConversion<DictionaryToJsonConverter<string, string>>();
|
||||
b.Properties<JwtSigAlgName>().HaveConversion<JwtSigAlgNameConverter>();
|
||||
b.Properties<KekId>().HaveConversion<KekIdConverter>();
|
||||
b.Properties<KeyType>().HaveConversion<KeyTypeConverter>();
|
||||
b.Properties<RealmSigningKeyId>().HaveConversion<RealmSigningKeyIdConverter>();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@
|
|||
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="jose-jwt" Version="5.2.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,16 @@ public class Client
|
|||
public string? Description { get; set; }
|
||||
|
||||
[MaxLength(20)]
|
||||
public string? SignatureAlgorithm { get; set; }
|
||||
public JwtSigAlgName? SignatureAlgorithm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables confidential flows
|
||||
/// </summary>
|
||||
public bool Confidential { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables the client credentials flow which required Confidential to be true too.
|
||||
/// </summary>
|
||||
public bool AllowClientCredentialsFlow { get; set; } = false;
|
||||
|
||||
public required DateTime CreatedAt { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
|
@ -14,8 +13,9 @@ public class ClientSecret
|
|||
public int Id { get; set; }
|
||||
public Guid ClientId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? Expires { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public EncryptedValue? Secret { get; set; }
|
||||
public required EncryptedValue Secret { get; set; }
|
||||
}
|
||||
|
||||
public class ClientSecretConfiguration : IEntityTypeConfiguration<ClientSecret>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using IdentityShroud.Core.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace IdentityShroud.Core.Model;
|
||||
|
||||
|
|
@ -20,12 +17,17 @@ public class Realm
|
|||
public string Name { get; set; } = "";
|
||||
public List<Client> Clients { get; init; } = [];
|
||||
|
||||
public List<RealmKey> Keys { get; init; } = [];
|
||||
|
||||
public List<RealmDek> Deks { get; init; } = [];
|
||||
/// <summary>
|
||||
/// Note multiple keys can be in use at the same time because different clients may be configured to use
|
||||
/// a different keytype depending on their clients requirements/capabilities.
|
||||
/// </summary>
|
||||
public List<RealmSigningKey> TokenSigningKeys { get; init; } = [];
|
||||
|
||||
public List<RealmDek> DataEncryptionKeys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Can be overriden per client
|
||||
/// </summary>
|
||||
public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
|
||||
public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256;
|
||||
}
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace IdentityShroud.Core.Model;
|
||||
|
||||
|
||||
public record RealmDek
|
||||
{
|
||||
public required DekId Id { get; init; }
|
||||
public required bool Active { get; set; }
|
||||
public required string Algorithm { get; init; }
|
||||
public required KeyType Algorithm { get; init; }
|
||||
public required EncryptedDek KeyData { get; init; }
|
||||
public required Guid RealmId { get; init; }
|
||||
public Guid RealmId { get; init; }
|
||||
}
|
||||
|
||||
public class RealmDekConfiguration : IEntityTypeConfiguration<RealmDek>
|
||||
|
|
@ -22,3 +24,4 @@ public class RealmDekConfiguration : IEntityTypeConfiguration<RealmDek>
|
|||
b.ComplexProperty(e => e.KeyData, e => e.IsRequired());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,34 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace IdentityShroud.Core.Model;
|
||||
|
||||
public record RealmKey
|
||||
public record RealmSigningKey
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string KeyType { get; init; }
|
||||
public required RealmSigningKeyId Id { get; init; }
|
||||
public required KeyType KeyType { get; init; }
|
||||
public required EncryptedDek Key { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key with highest priority will be used. While there is not really a use case for this I know some users
|
||||
/// are more comfortable replacing keys by using priority then directly deactivating the old key.
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 10;
|
||||
|
||||
public Dictionary<string, string>? PublicKeyParameters { get; set; }
|
||||
}
|
||||
|
||||
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmKey>
|
||||
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmSigningKey>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RealmKey> b)
|
||||
public void Configure(EntityTypeBuilder<RealmSigningKey> b)
|
||||
{
|
||||
b.ToTable("realm_key");
|
||||
b.HasKey(e => e.Id);
|
||||
|
||||
b.ComplexProperty(e => e.Key, e => e.IsRequired());
|
||||
b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb");
|
||||
}
|
||||
}
|
||||
24
IdentityShroud.Core/Model/RealmSigningKeyId.cs
Normal file
24
IdentityShroud.Core/Model/RealmSigningKeyId.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IdentityShroud.Core.Model;
|
||||
|
||||
[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))]
|
||||
public readonly record struct RealmSigningKeyId(Guid Id)
|
||||
{
|
||||
public override string ToString() => Id.ToString("N");
|
||||
|
||||
public static RealmSigningKeyId NewId()
|
||||
{
|
||||
return new(Guid.NewGuid());
|
||||
}
|
||||
}
|
||||
|
||||
public class RealmSigningKeyIdJsonConverter : JsonConverter<RealmSigningKeyId>
|
||||
{
|
||||
public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> new (reader.GetGuid());
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
namespace IdentityShroud.Core.Security;
|
||||
|
||||
public record struct DekId(Guid Id)
|
||||
public readonly record struct DekId(Guid Id)
|
||||
{
|
||||
public static DekId NewId() => new(Guid.NewGuid());
|
||||
|
||||
public override string ToString() => Id.ToString("N");
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IdentityShroud.Core.Security;
|
||||
|
||||
public record EncryptedDek(KekId KekId, byte[] Value);
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IdentityShroud.Core.Security;
|
||||
|
||||
public record EncryptedValue(DekId DekId, byte[] Value);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security;
|
|||
|
||||
public static class Encryption
|
||||
{
|
||||
private record struct AlgVersion(int Version, int NonceSize, int TagSize);
|
||||
private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize);
|
||||
|
||||
private static AlgVersion[] _versions =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
namespace IdentityShroud.Core.Security;
|
||||
|
||||
public static class JsonWebAlgorithm
|
||||
{
|
||||
public const string RS256 = "RS256";
|
||||
}
|
||||
26
IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs
Normal file
26
IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
|
||||
public interface IJwtSignatureProvider : IDisposable
|
||||
{
|
||||
/*
|
||||
Of the signature and MAC algorithms specified in JSON Web Algorithms
|
||||
[JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by
|
||||
conforming JWT implementations. It is RECOMMENDED that
|
||||
implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash
|
||||
algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256
|
||||
hash algorithm ("ES256"). Support for other algorithms and key sizes
|
||||
is OPTIONAL.
|
||||
*/
|
||||
|
||||
void WriteJwtHeaderFields(Utf8JsonWriter writer);
|
||||
/// <summary>
|
||||
/// Length of the binary signature in bytes.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
int GetSignatureLength();
|
||||
|
||||
void CalculateSignature(ReadOnlySpan<byte> jwt, Span<byte> signatureOut);
|
||||
|
||||
}
|
||||
23
IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs
Normal file
23
IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public readonly record struct JwtSigAlgName(string Name) : IEquatable<JwtSigAlgName>
|
||||
{
|
||||
// HMAC using SHA-???
|
||||
public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED
|
||||
public static JwtSigAlgName HS384 => new("HS384");
|
||||
public static JwtSigAlgName HS512 => new("HS512");
|
||||
|
||||
// RSASSA-PKCS1-v1_5 using SHA-???
|
||||
public static JwtSigAlgName RS256 => new("RS256");
|
||||
public static JwtSigAlgName RS384 => new("RS384");
|
||||
public static JwtSigAlgName RS512 => new("RS512");
|
||||
|
||||
public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256
|
||||
public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384
|
||||
public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
100
IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs
Normal file
100
IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
|
||||
public static class JwtSignatureGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a JWT signature using RS256 algorithm
|
||||
/// </summary>
|
||||
/// <param name="headerBase64Url">Base64Url encoded header</param>
|
||||
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
|
||||
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
|
||||
/// <returns>Base64Url encoded signature</returns>
|
||||
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
||||
{
|
||||
// Combine header and payload with a period
|
||||
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
|
||||
|
||||
// Convert to bytes
|
||||
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
|
||||
|
||||
// Sign the data using RSA-SHA256
|
||||
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Convert signature to Base64Url encoding
|
||||
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
||||
{
|
||||
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
|
||||
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class JwtCreator
|
||||
{
|
||||
|
||||
public static byte[] CreateEncodedJwt(ReadOnlySpan<byte> payloadUtf8, IJwtSignatureProvider signatureProvider)
|
||||
{
|
||||
MemoryStream memStream = new();
|
||||
Utf8JsonWriter writer = new(memStream);
|
||||
WriteJwtHeader(writer, signatureProvider);
|
||||
writer.Flush();
|
||||
memStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length);
|
||||
int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length);
|
||||
int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength());
|
||||
int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length;
|
||||
|
||||
var completeJwt = new byte[totalLength];
|
||||
|
||||
//
|
||||
var byteArray = new byte[memStream.Length];
|
||||
memStream.ReadExactly(byteArray, 0, (int)memStream.Length);
|
||||
int written = Base64Url.EncodeToUtf8(byteArray, completeJwt);
|
||||
|
||||
if (written != headerBase64Length)
|
||||
throw new Exception("expected header length did not match bytes written");
|
||||
|
||||
completeJwt[headerBase64Length] = (byte)'.';
|
||||
|
||||
written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length));
|
||||
|
||||
if (written != payloadBase64Length)
|
||||
throw new Exception("expected payload length did not match bytes written");
|
||||
|
||||
completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.';
|
||||
|
||||
|
||||
Span<byte> signature = stackalloc byte[signatureProvider.GetSignatureLength()];
|
||||
signatureProvider.CalculateSignature(
|
||||
completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length),
|
||||
signature);
|
||||
|
||||
written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan()
|
||||
.Slice(headerBase64Length + 1 + payloadBase64Length + 1));
|
||||
|
||||
if (written != signatureBase64Length)
|
||||
throw new Exception("expected signature length did not match bytes written");
|
||||
|
||||
return completeJwt;
|
||||
}
|
||||
|
||||
private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("typ"u8, "JWT"u8);
|
||||
signatureProvider.WriteJwtHeaderFields(writer);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
88
IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs
Normal file
88
IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
|
||||
public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services)
|
||||
{
|
||||
|
||||
public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData)
|
||||
{
|
||||
|
||||
|
||||
//realm.DefaultSignatureAlgorithm
|
||||
//realm.TokenSigningKeys
|
||||
|
||||
//IJwtSignatureProvider? sigProvider = services.GetKeyedService<IJwtSignatureProvider>();
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class RsaJwtSignatureProvider : IJwtSignatureProvider
|
||||
{
|
||||
private JwtSigAlgName _sigAlgName;
|
||||
private RealmSigningKeyId _keyId;
|
||||
private readonly RSA _rsa;
|
||||
|
||||
public RsaJwtSignatureProvider(DekEncryptionService dekCryptor,
|
||||
RealmSigningKey privateKey,
|
||||
JwtSigAlgName sigAlgName)
|
||||
{
|
||||
_sigAlgName = sigAlgName;
|
||||
_keyId = privateKey.Id;
|
||||
|
||||
byte[] key = dekCryptor.Decrypt(privateKey.Key);
|
||||
_rsa = RSA.Create();
|
||||
_rsa.ImportPkcs8PrivateKey(key, out int _);
|
||||
}
|
||||
|
||||
/*
|
||||
+-------------------+---------------------------------+
|
||||
| "alg" Param Value | Digital Signature Algorithm |
|
||||
+-------------------+---------------------------------+
|
||||
| RS256 | RSASSA-PKCS1-v1_5 using SHA-256 |
|
||||
| RS384 | RSASSA-PKCS1-v1_5 using SHA-384 |
|
||||
| RS512 | RSASSA-PKCS1-v1_5 using SHA-512 |
|
||||
+-------------------+---------------------------------+
|
||||
*/
|
||||
|
||||
|
||||
public void WriteJwtHeaderFields(Utf8JsonWriter writer)
|
||||
{
|
||||
writer.WriteString("alg"u8, _sigAlgName.ToString());
|
||||
writer.WriteString("kid"u8, _keyId.ToString());
|
||||
}
|
||||
|
||||
public int GetSignatureLength()
|
||||
{
|
||||
return _rsa.KeySize / 8;
|
||||
}
|
||||
|
||||
public void CalculateSignature(ReadOnlySpan<byte> jwt, Span<byte> sig)
|
||||
{
|
||||
_rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rsa.Dispose();
|
||||
}
|
||||
|
||||
private HashAlgorithmName GetHashAlgorithmName()
|
||||
=> _sigAlgName.Name switch
|
||||
{
|
||||
"RS256" => HashAlgorithmName.SHA256,
|
||||
"RS384" => HashAlgorithmName.SHA384,
|
||||
"RS512" => HashAlgorithmName.SHA512,
|
||||
_ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider")
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace IdentityShroud.Core;
|
||||
|
||||
public static class JwtSignatureGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a JWT signature using RS256 algorithm
|
||||
/// </summary>
|
||||
/// <param name="headerBase64Url">Base64Url encoded header</param>
|
||||
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
|
||||
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
|
||||
/// <returns>Base64Url encoded signature</returns>
|
||||
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
||||
{
|
||||
// Combine header and payload with a period
|
||||
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
|
||||
|
||||
// Convert to bytes
|
||||
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
|
||||
|
||||
// Sign the data using RSA-SHA256
|
||||
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Convert signature to Base64Url encoding
|
||||
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
||||
{
|
||||
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
|
||||
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
|
||||
}
|
||||
}
|
||||
10
IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs
Normal file
10
IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace IdentityShroud.Core.Security.Keys.Aes;
|
||||
|
||||
public class AesKeyPolicy : KeyPolicy
|
||||
{
|
||||
public AesKeyPolicy()
|
||||
{
|
||||
KeyType = KeyType.AES;
|
||||
KeySize = 256;
|
||||
}
|
||||
}
|
||||
19
IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs
Normal file
19
IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Security.Cryptography;
|
||||
using IdentityShroud.Core.Messages;
|
||||
|
||||
namespace IdentityShroud.Core.Security.Keys.Aes;
|
||||
|
||||
public class AesProvider : IKeyProvider
|
||||
{
|
||||
public bool IsPublic => false;
|
||||
public KeyData CreateKey(KeyPolicy policy)
|
||||
{
|
||||
return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8));
|
||||
}
|
||||
|
||||
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk)
|
||||
{
|
||||
// Can we use this for Jwe?
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,32 @@ using IdentityShroud.Core.Messages;
|
|||
|
||||
namespace IdentityShroud.Core.Security.Keys;
|
||||
|
||||
public abstract class KeyPolicy
|
||||
public class KeyPolicy
|
||||
{
|
||||
public abstract string KeyType { get; }
|
||||
public KeyType KeyType { get; protected init; }
|
||||
public int KeySize { get; protected init; }
|
||||
}
|
||||
|
||||
public record KeyData(byte[] PrivateKey, Dictionary<string, string>? PublicKeyParameters = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The data to be kept private, also used for symmetric keys
|
||||
/// </summary>
|
||||
public byte[] PrivateKey { get; set; } = PrivateKey;
|
||||
|
||||
public Dictionary<string, string>? PublicKeyParameters { get; set; } = PublicKeyParameters;
|
||||
}
|
||||
|
||||
|
||||
public interface IKeyProvider
|
||||
{
|
||||
byte[] CreateKey(KeyPolicy policy);
|
||||
/// <summary>
|
||||
/// Returns true when this key uses public key cryptography
|
||||
/// </summary>
|
||||
bool IsPublic { get; }
|
||||
KeyData CreateKey(KeyPolicy policy);
|
||||
|
||||
void SetJwkParameters(byte[] key, JsonWebKey jwk);
|
||||
void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys;
|
|||
|
||||
public interface IKeyProviderFactory
|
||||
{
|
||||
public IKeyProvider CreateProvider(string keyType);
|
||||
public IKeyProvider CreateProvider(KeyType keyType);
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
using IdentityShroud.Core.Security.Keys.Aes;
|
||||
using IdentityShroud.Core.Security.Keys.Rsa;
|
||||
|
||||
namespace IdentityShroud.Core.Security.Keys;
|
||||
|
||||
public class KeyProviderFactory : IKeyProviderFactory
|
||||
{
|
||||
public IKeyProvider CreateProvider(string keyType)
|
||||
public IKeyProvider CreateProvider(KeyType keyType)
|
||||
{
|
||||
switch (keyType)
|
||||
switch (keyType.Name)
|
||||
{
|
||||
case "RSA":
|
||||
return new RsaProvider();
|
||||
case "AES":
|
||||
return new AesProvider();
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
|
|
|||
21
IdentityShroud.Core/Security/Keys/KeyType.cs
Normal file
21
IdentityShroud.Core/Security/Keys/KeyType.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IdentityShroud.Core.Security.Keys;
|
||||
|
||||
[JsonConverter(typeof(KeyTypeJsonConverter))]
|
||||
public readonly record struct KeyType(string Name)
|
||||
{
|
||||
public static KeyType AES => new("AES");
|
||||
public static KeyType RSA => new("RSA");
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public class KeyTypeJsonConverter : JsonConverter<KeyType>
|
||||
{
|
||||
public override KeyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> new KeyType(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, KeyType value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
10
IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs
Normal file
10
IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace IdentityShroud.Core.Security.Keys.Rsa;
|
||||
|
||||
public class RsaKeyPolicy : KeyPolicy
|
||||
{
|
||||
public RsaKeyPolicy()
|
||||
{
|
||||
KeyType = KeyType.RSA;
|
||||
KeySize = 2048;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,32 +4,31 @@ using IdentityShroud.Core.Messages;
|
|||
|
||||
namespace IdentityShroud.Core.Security.Keys.Rsa;
|
||||
|
||||
public class RsaKeyPolicy : KeyPolicy
|
||||
{
|
||||
public override string KeyType => "RSA";
|
||||
public int KeySize { get; } = 2048;
|
||||
}
|
||||
|
||||
public class RsaProvider : IKeyProvider
|
||||
{
|
||||
public byte[] CreateKey(KeyPolicy policy)
|
||||
public bool IsPublic => true;
|
||||
|
||||
public KeyData CreateKey(KeyPolicy policy)
|
||||
{
|
||||
if (policy is RsaKeyPolicy p)
|
||||
{
|
||||
using var rsa = RSA.Create(p.KeySize);
|
||||
return rsa.ExportPkcs8PrivateKey();
|
||||
var publicParamaters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
return new KeyData(
|
||||
rsa.ExportPkcs8PrivateKey(),
|
||||
new()
|
||||
{
|
||||
["e"] = Base64Url.EncodeToString(publicParamaters.Exponent),
|
||||
["n"] = Base64Url.EncodeToString(publicParamaters.Modulus),
|
||||
});
|
||||
}
|
||||
|
||||
throw new ArgumentException("Incorrect policy type", nameof(policy));
|
||||
}
|
||||
|
||||
public void SetJwkParameters(byte[] key, JsonWebKey jwk)
|
||||
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportPkcs8PrivateKey(key, out _);
|
||||
var parameters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
|
||||
jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
|
||||
jwk.Exponent = parameters["e"];
|
||||
jwk.Modulus = parameters["n"];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Security.Cryptography;
|
||||
using FluentValidation;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Model;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -8,24 +10,35 @@ namespace IdentityShroud.Core.Services;
|
|||
public class ClientService(
|
||||
Db db,
|
||||
IDataEncryptionService cryptor,
|
||||
IValidator<ClientCreateRequest> clientCreateValidator,
|
||||
IClock clock) : IClientService
|
||||
{
|
||||
public async Task<Result<Client>> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default)
|
||||
{
|
||||
clientCreateValidator.ValidateAndThrow(request);
|
||||
|
||||
Realm realm = await db.Realms.FirstOrDefaultAsync(e => e.Id == realmId, ct)
|
||||
?? throw new InvalidOperationException("Require the id of an existing realm");
|
||||
|
||||
Client client = new()
|
||||
{
|
||||
RealmId = realmId,
|
||||
ClientId = request.ClientId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
SignatureAlgorithm = request.SignatureAlgorithm,
|
||||
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
|
||||
SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm),
|
||||
Confidential = request.Confidential,
|
||||
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow,
|
||||
CreatedAt = clock.UtcNow(),
|
||||
};
|
||||
|
||||
if (client.AllowClientCredentialsFlow)
|
||||
if (request.GenerateSecret is true)
|
||||
{
|
||||
client.Secrets.Add(CreateSecret());
|
||||
await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
|
||||
.Query()
|
||||
.LoadAsync(ct);
|
||||
|
||||
client.Secrets.Add(CreateSecret(realm));
|
||||
}
|
||||
|
||||
await db.AddAsync(client, ct);
|
||||
|
|
@ -50,15 +63,17 @@ public class ClientService(
|
|||
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct);
|
||||
}
|
||||
|
||||
private ClientSecret CreateSecret()
|
||||
private ClientSecret CreateSecret(Realm realm)
|
||||
{
|
||||
Span<byte> secret = stackalloc byte[24];
|
||||
RandomNumberGenerator.Fill(secret);
|
||||
|
||||
var dek = realm.DataEncryptionKeys.Single(k => k.Active);
|
||||
|
||||
return new ClientSecret()
|
||||
{
|
||||
CreatedAt = clock.UtcNow(),
|
||||
Secret = cryptor.Encrypt(secret.ToArray()),
|
||||
Secret = cryptor.Encrypt(dek, secret),
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,37 +5,23 @@ using IdentityShroud.Core.Security;
|
|||
namespace IdentityShroud.Core.Services;
|
||||
|
||||
public class DataEncryptionService(
|
||||
IRealmContext realmContext,
|
||||
IDekEncryptionService dekCryptor) : IDataEncryptionService
|
||||
{
|
||||
|
||||
// Note this array is expected to have one item in it most of the during key rotation it will have two
|
||||
// until it is ensured the old key can safely be removed. More then two will work but is not really expected.
|
||||
private IList<RealmDek>? _deks = null;
|
||||
|
||||
private IList<RealmDek> GetDeks()
|
||||
public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain)
|
||||
{
|
||||
if (_deks is null)
|
||||
_deks = realmContext.GetDeks().Result;
|
||||
|
||||
return _deks;
|
||||
}
|
||||
|
||||
private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active);
|
||||
private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id);
|
||||
|
||||
public byte[] Decrypt(EncryptedValue input)
|
||||
{
|
||||
var dek = GetKey(input.DekId);
|
||||
var key = dekCryptor.Decrypt(dek.KeyData);
|
||||
return Encryption.Decrypt(input.Value, key);
|
||||
}
|
||||
|
||||
public EncryptedValue Encrypt(ReadOnlySpan<byte> plain)
|
||||
{
|
||||
var dek = GetActiveDek();
|
||||
var key = dekCryptor.Decrypt(dek.KeyData);
|
||||
byte[] cipher = Encryption.Encrypt(plain, key);
|
||||
return new (dek.Id, cipher);
|
||||
}
|
||||
|
||||
public byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input)
|
||||
{
|
||||
// Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like
|
||||
// - Old key removed before migration completed (should not be possible)
|
||||
// - Wrong keyset because of programming error.
|
||||
var dek = deks.SingleOrDefault(d => d.Id == input.DekId)
|
||||
?? throw new InvalidOperationException("Required key not found");
|
||||
var key = dekCryptor.Decrypt(dek.KeyData);
|
||||
return Encryption.Decrypt(input.Value, key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,16 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
|
||||
namespace IdentityShroud.Core.Services;
|
||||
|
||||
public class KeyService(
|
||||
IDekEncryptionService cryptor,
|
||||
IKeyProviderFactory keyProviderFactory,
|
||||
IClock clock) : IKeyService
|
||||
IKeyProviderFactory keyProviderFactory) : IKeyService
|
||||
{
|
||||
public RealmKey CreateKey(KeyPolicy policy)
|
||||
public CreateKeyResponse CreateKey(KeyPolicy policy)
|
||||
{
|
||||
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
|
||||
var plainKey = provider.CreateKey(policy);
|
||||
KeyData plainKey = provider.CreateKey(policy);
|
||||
|
||||
return CreateKey(policy.KeyType, plainKey);
|
||||
return new CreateKeyResponse(policy.KeyType, plainKey);
|
||||
}
|
||||
|
||||
public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
|
||||
{
|
||||
JsonWebKey jwk = new()
|
||||
{
|
||||
KeyId = realmKey.Id.ToString(),
|
||||
KeyType = realmKey.KeyType,
|
||||
Use = "sig",
|
||||
};
|
||||
|
||||
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
|
||||
provider.SetJwkParameters(
|
||||
cryptor.Decrypt(realmKey.Key),
|
||||
jwk);
|
||||
|
||||
return jwk;
|
||||
}
|
||||
|
||||
private RealmKey CreateKey(string keyType, byte[] plainKey) =>
|
||||
new RealmKey()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
KeyType = keyType,
|
||||
Key = cryptor.Encrypt(plainKey),
|
||||
CreatedAt = clock.UtcNow(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
IdentityShroud.Core/Services/OpenId/TokenService.cs
Normal file
30
IdentityShroud.Core/Services/OpenId/TokenService.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace IdentityShroud.Core.Services.OpenId;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
Task<Result<TokenResponse>> Handle(
|
||||
Dictionary<string, string> form,
|
||||
string? basicAuthUser,
|
||||
string? basicAuthPassword,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
public async Task<Result<TokenResponse>> Handle(
|
||||
Dictionary<string, string> form,
|
||||
string? basicAuthUser,
|
||||
string? basicAuthPassword,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
|
||||
public async Task<Result<TokenResponse>> ClientCredentialsFlow(
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,11 +16,11 @@ public class RealmContext(
|
|||
public async Task<IList<RealmDek>> GetDeks(CancellationToken ct = default)
|
||||
{
|
||||
Realm realm = GetRealm();
|
||||
if (realm.Deks.Count == 0)
|
||||
if (realm.DataEncryptionKeys.Count == 0)
|
||||
{
|
||||
await realmService.LoadDeks(realm);
|
||||
}
|
||||
|
||||
return realm.Deks;
|
||||
return realm.DataEncryptionKeys;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,21 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using IdentityShroud.Core.Helpers;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Security.Keys;
|
||||
using IdentityShroud.Core.Security.Keys.Aes;
|
||||
using IdentityShroud.Core.Security.Keys.Rsa;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IdentityShroud.Core.Services;
|
||||
|
||||
public record RealmCreateResponse(Guid Id, string Slug, string Name);
|
||||
|
||||
public class RealmService(
|
||||
Db db,
|
||||
IKeyService keyService) : IRealmService
|
||||
IKeyService keyService,
|
||||
IDekEncryptionService dekCryptor,
|
||||
IClock clock) : IRealmService
|
||||
{
|
||||
public async Task<Realm?> FindById(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
|
|
@ -26,7 +29,7 @@ public class RealmService(
|
|||
.SingleOrDefaultAsync(r => r.Slug == slug, ct);
|
||||
}
|
||||
|
||||
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
|
||||
public async Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Realm realm = new()
|
||||
{
|
||||
|
|
@ -35,26 +38,52 @@ public class RealmService(
|
|||
Name = request.Name,
|
||||
};
|
||||
|
||||
realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
|
||||
realm.TokenSigningKeys.Add(CreateSigningKey(realm));
|
||||
realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm));
|
||||
|
||||
db.Add(realm);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new RealmCreateResponse(
|
||||
realm.Id, realm.Slug, realm.Name);
|
||||
return realm;
|
||||
}
|
||||
|
||||
private RealmSigningKey CreateSigningKey(Realm realm)
|
||||
{
|
||||
var k = keyService.CreateKey(GetSigningKeyPolicy(realm));
|
||||
return new RealmSigningKey
|
||||
{
|
||||
Id = RealmSigningKeyId.NewId(),
|
||||
KeyType = k.KeyType,
|
||||
Key = dekCryptor.Encrypt(k.Key.PrivateKey),
|
||||
PublicKeyParameters = k.Key.PublicKeyParameters,
|
||||
CreatedAt = clock.UtcNow(),
|
||||
};
|
||||
}
|
||||
|
||||
private RealmDek CreateDataEncryptionKey(Realm realm)
|
||||
{
|
||||
var k = keyService.CreateKey(GetDataKeyPolicy(realm));
|
||||
return new RealmDek()
|
||||
{
|
||||
Id = DekId.NewId(),
|
||||
Active = true,
|
||||
Algorithm = k.KeyType,
|
||||
KeyData = dekCryptor.Encrypt(k.Key.PrivateKey),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
|
||||
/// </summary>
|
||||
/// <param name="_"></param>
|
||||
/// <returns></returns>
|
||||
private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
|
||||
|
||||
private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy();
|
||||
private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy();
|
||||
|
||||
public async Task LoadActiveKeys(Realm realm)
|
||||
{
|
||||
await db.Entry(realm).Collection(r => r.Keys)
|
||||
await db.Entry(realm).Collection(r => r.TokenSigningKeys)
|
||||
.Query()
|
||||
.Where(k => k.RevokedAt == null)
|
||||
.LoadAsync();
|
||||
|
|
@ -62,7 +91,7 @@ public class RealmService(
|
|||
|
||||
public async Task LoadDeks(Realm realm)
|
||||
{
|
||||
await db.Entry(realm).Collection(r => r.Deks)
|
||||
await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
|
||||
.Query()
|
||||
.LoadAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.EFCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ public static class JsonObjectAssert
|
|||
return segments.ToArray();
|
||||
}
|
||||
|
||||
public static JsonNode? NavigateToPath(JsonObject jsonObject, string path)
|
||||
=> NavigateToPath(jsonObject, ParsePath(path));
|
||||
/// <summary>
|
||||
/// Navigates to a JsonNode at the specified path and returns it.
|
||||
/// Throws XunitException if the path doesn't exist or is invalid.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Security;
|
||||
|
||||
namespace IdentityShroud.TestUtils.Substitutes;
|
||||
|
|
@ -6,12 +7,12 @@ namespace IdentityShroud.TestUtils.Substitutes;
|
|||
public class NullDataEncryptionService : IDataEncryptionService
|
||||
{
|
||||
public DekId KeyId { get; } = DekId.NewId();
|
||||
public EncryptedValue Encrypt(ReadOnlySpan<byte> plain)
|
||||
public EncryptedValue Encrypt(RealmDek key, ReadOnlySpan<byte> plain)
|
||||
{
|
||||
return new(KeyId, plain.ToArray());
|
||||
}
|
||||
|
||||
public byte[] Decrypt(EncryptedValue input)
|
||||
public byte[] Decrypt(IReadOnlyList<RealmDek> keys, EncryptedValue input)
|
||||
{
|
||||
return input.Value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils",
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils.Tests", "IdentityShroud.TestUtils.Tests\IdentityShroud.TestUtils.Tests.csproj", "{35D33207-27A8-43E9-A8CA-A158A1E4448C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{980900AA-E052-498B-A41A-4F33A8678828}"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "08_Tests", "08_Tests", "{980900AA-E052-498B-A41A-4F33A8678828}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01", "01", "{07B08872-1141-4BE6-87E6-B85E52FE4341}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
|
@ -58,5 +60,7 @@ Global
|
|||
{DC887623-8680-4D3B-B23A-D54F7DA91891} = {980900AA-E052-498B-A41A-4F33A8678828}
|
||||
{35D33207-27A8-43E9-A8CA-A158A1E4448C} = {980900AA-E052-498B-A41A-4F33A8678828}
|
||||
{A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828}
|
||||
{D2B446A0-AB62-4555-9D79-33FF43D7CEF4} = {07B08872-1141-4BE6-87E6-B85E52FE4341}
|
||||
{8490BF59-B68A-4BE0-9F96-6CB262AF4850} = {07B08872-1141-4BE6-87E6-B85E52FE4341}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -1,35 +1,69 @@
|
|||
<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_003AAsyncTaskMethodBuilderT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe5ace92664429b8b34b68b587316fb211811b58aeb53422b43cecca2ad2bdaf3_003FAsyncTaskMethodBuilderT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACacheControlHeaderValue_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa87c7abf30652fed47612be683cd4159c298e621b79d2ec8922e5fdaee7c4_003FCacheControlHeaderValue_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_003AConfigurationSection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F55e3307e9c416bdbce02cdd9eabe8ac72fe3b3d981f3b2220e31ff9c916653c_003FConfigurationSection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConvert_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F25471aec3ccfbd13501ea8aa1ce226345f6755a29929e16ad6fca1e1d8a71766_003FConvert_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACreatedAtRouteOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd44ce9784c3c3a63c4946069699ed1b827ced8d3e9ddbf9116a111acc0c18e_003FCreatedAtRouteOfT_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_003ADictionary_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2def38d1ef1896bfc830e4dd3c369cad11b0bedfafaef9b3c45dbe20291fefa_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADisableCookieRedirectMetadata_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Faddebf6753a0f2d7c86275365ae3a8754f756558711618dc2786738c33198252_003FDisableCookieRedirectMetadata_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AECDsa_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb69681dc22e362c8b157b358e58abc4b44cb12b573c82fa37c483ad8807c8f_003FECDsa_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEncoding_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F44b11ddb443bd71b1c7a446a8749d234ceb135d9a36dc53c4f0887b197db4_003FEncoding_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointFilterExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9c2fa9803c78dd1f847c17efc8bd89542f327227fcc788209f2cd49793789_003FEndpointFilterExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa9ea5e63a02ac8d4c774259c5256a6a4558ffe31f67af7d37544f59f63fe21b3_003FEndpointMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnumerable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe1ad41a097d8420ea7c1e6ef67b81e74aa910_003F9e_003Fff7630f2_003FEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEqualityAsserts_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbe64729e1a794084a37dfa21421db326ae5bd6d0b425efeb6cac273f2b751b_003FEqualityAsserts_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd9c727cdfa9b5b574cd1cf7982619392db38b8e95c074a019c03b7feba5e6_003FExecutionContext_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_003F12ddbce31e3c210cde144d06a80cecd8921953f_003FGeneratedRouteBuilderExtensions_002Eg_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_003F3ece818cc5e11d67d929dc1da870274dbc3d6_003FGeneratedRouteBuilderExtensions_002Eg_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_003AGeneratedRouteBuilderExtensions_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9f95c1d38311d5248a1d1324797b98c2e56789a_003FGeneratedRouteBuilderExtensions_002Eg_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_003Fb32218626d29174fc2ad283f379841286bfbde8_003FGeneratedRouteBuilderExtensions_002Eg_002Ecs_002Fz_003A2_002D1/@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_003Fcb8492c9e846c0578d51c91f792dafe3872f7ff6_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_003AIAssertionException_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8bd9680bae73f114751c097b1235b5cd382cc262b5cc15a1cba29ac19c8c2d_003FIAssertionException_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_003AIEndpointMetadataProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F375a865e3a25e4d98cfdf8b893e61457bee3321eeb99c378ffcf91ba35e5319a_003FIEndpointMetadataProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHeaderDictionary_002EKeyed_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8051f2d3b5d2fb7d6528954ad3e6bfe33bdfda178290f4d4a8cb683561648_003FIHeaderDictionary_002EKeyed_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIncrementalHash_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9e2afb4284c6864f63c55a21bdec2e4de40652e4a7d39474b5b4e883524df8_003FIncrementalHash_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIValueHttpResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F62dfd3b242874e20bedeff6bfa7ecb104df38_003F62_003Fe3424f0c_003FIValueHttpResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonContent_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd09c29f06c4deff1a66ffe7fc0af312fe43993cb8c11bbcea9871f1aa296aba_003FJsonContent_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002ERead_002ESpan_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F633abf1af3395cc059497c7272332ba3f5eab815f034d4c9c2c8a952c578f9_003FJsonSerializer_002ERead_002ESpan_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_003AList_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd2753e160c1949ef9afa6a794019cfe8d908_003Fce_003Fba21ad0a_003FList_00601_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_003APropertiesConfigurationBuilder_0060_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9f551ff01b72ccce949a71772b935679856909462755bbfafc2b82ffda177ff_003FPropertiesConfigurationBuilder_0060_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlyMemory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc19b2538fdfabf70658aed8979dd83e9ca11e27f5b3df68950e8ecb4d879e_003FReadOnlyMemory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResultsCache_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcf5280d53ad6898975ddc3875c34ce0ffbad038daf5b03dc440124304fd61_003FResultsCache_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_003ARouteGroupBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd42b8f8feda3bfb3dc17f133a52ce45931ed5066c46a4d834c8ed46e0a6_003FRouteGroupBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARouteHandlerBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F14e6b1bd34413c4ba7e39853539f780b59af0a429241b10d3cbd85d43f0b159_003FRouteHandlerBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4580cd7e22d24cfa268c50f959a5df789095f3c9476023437a199090a5b3f0f4_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderKeyedServiceExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F5390573bbd69c67341f5f7d26b4e4a9649bebb8d5ef134b4b655a88b8da3585d_003FServiceProviderKeyedServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStringShouldBeTestExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F56ebc780bf855d5172b31bc42975fb8041a8728a89c2eb8f6a15f674bde20b8_003FStringShouldBeTestExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8433b9271c0f176fb5ceb7b1c3d62e1318fe8e62b4e5d7e882952dc543fec_003FThrowHelper_002ESerialization_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_003AUnauthorizedHttpResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb8f73c51525f94ab8d33d73e133157516a7fdb414a350e7b5841ed2b835937_003FUnauthorizedHttpResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnauthorizedResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F848c629dd035d9535248fad9944522c1b9cfaa290d4c1af25857955bc955ac8_003FUnauthorizedResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUTF8Encoding_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F47f5e4c98751367a86329ab1e0a76d86b39fd076a5b019284694f39d40fd9011_003FUTF8Encoding_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtf8JsonWriter_002EWriteValues_002EGuid_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F79cee790efff8e1673abf37fe722bbdc0885a1a3e539a1e12fac638fd35426f_003FUtf8JsonWriter_002EWriteValues_002EGuid_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebEncoders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce6b69dd397f614758bc5821136ec8af3fa22563dd657769e231f51be1fbbc_003FWebEncoders_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/=c1fa4888_002D88fd_002D4859_002D8288_002D37c12fb8ef13/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ead9ca22_002Dfc70_002D4ddf_002Db4c7_002D534498815537/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=d272111e_002De36e_002D4eab_002Dbfb4_002D3f0eed5a1a43/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
|
||||
|
|
@ -44,6 +78,14 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue