Compare commits
No commits in common. "main" and "validation" have entirely different histories.
main
...
validation
143 changed files with 620 additions and 5202 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
[*.cs]
|
|
||||||
resharper_naming_rules.abbreviations = QL, DB
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
|
||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="HotChocolate.AspNetCore" Version="15.1.14" />
|
|
||||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
|
||||||
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
|
|
||||||
<PackageVersion Include="Scrutor" Version="7.0.0" />
|
|
||||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
|
||||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
|
||||||
<PackageVersion Include="Testcontainers" Version="4.10.0" />
|
|
||||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.10.0" />
|
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
|
||||||
<PackageVersion Include="xunit.v3.assert" Version="3.2.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using FluentResults;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.EFCore;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Tests;
|
|
||||||
using IdentityShroud.Core.Tests.Fixtures;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Shouldly;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Tests.Apis;
|
|
||||||
|
|
||||||
public class ClientApiTests : IClassFixture<ApplicationFactory>
|
|
||||||
{
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
|
||||||
|
|
||||||
private readonly ApplicationFactory _factory;
|
|
||||||
|
|
||||||
public ClientApiTests(ApplicationFactory factory)
|
|
||||||
{
|
|
||||||
_factory = factory;
|
|
||||||
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
|
||||||
if (!db.Database.EnsureCreated())
|
|
||||||
{
|
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null, false, "ClientId")]
|
|
||||||
[InlineData("", false, "ClientId")]
|
|
||||||
[InlineData("my-client", true, "")]
|
|
||||||
public async Task Create_Validation(string? clientId, bool succeeds, string fieldName)
|
|
||||||
{
|
|
||||||
// setup
|
|
||||||
var realm = await CreateRealmAsync("test-realm", "Test Realm");
|
|
||||||
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
// act
|
|
||||||
var response = await client.PostAsync(
|
|
||||||
$"/api/v1/realms/{realm.Id}/clients",
|
|
||||||
JsonContent.Create(new { ClientId = clientId }),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (succeeds)
|
|
||||||
{
|
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
||||||
var problemDetails =
|
|
||||||
await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Create_Success_ReturnsCreatedWithLocation()
|
|
||||||
{
|
|
||||||
// act
|
|
||||||
var body = await DoCreateRequest("""
|
|
||||||
{
|
|
||||||
"clientId": "new-client",
|
|
||||||
"name": "New Client"
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
// verify
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
var response = await client.PostAsync(
|
|
||||||
$"/api/v1/realms/{Guid.NewGuid()}/clients",
|
|
||||||
JsonContent.Create(new { ClientId = "some-client" }),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Get_Success()
|
|
||||||
{
|
|
||||||
// setup
|
|
||||||
var realm = await CreateRealmAsync("get-realm", "Get Realm");
|
|
||||||
Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client");
|
|
||||||
|
|
||||||
var httpClient = _factory.CreateClient();
|
|
||||||
|
|
||||||
// act
|
|
||||||
var response = await httpClient.GetAsync(
|
|
||||||
$"/api/v1/realms/{realm.Id}/clients/{dbClient.Id}",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<ClientRepresentation>(
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.NotNull(body);
|
|
||||||
Assert.Equal(dbClient.Id, body.Id);
|
|
||||||
Assert.Equal("get-client", body.ClientId);
|
|
||||||
Assert.Equal("Get Client", body.Name);
|
|
||||||
Assert.Equal(realm.Id, body.RealmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Get_UnknownClient_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// setup
|
|
||||||
var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
|
|
||||||
|
|
||||||
var httpClient = _factory.CreateClient();
|
|
||||||
|
|
||||||
// act
|
|
||||||
var response = await httpClient.GetAsync(
|
|
||||||
$"/api/v1/realms/{realm.Id}/clients/99999",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Realm> CreateRealmAsync(string slug, string name)
|
|
||||||
{
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var realmService = scope.ServiceProvider.GetRequiredService<IRealmService>();
|
|
||||||
Result<Realm> result = await realmService.Create(
|
|
||||||
new(null, slug, name),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
return ResultAssert.Success(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Client> CreateClientAsync(Realm realm, string clientId, string? name = null)
|
|
||||||
{
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
|
||||||
var client = new Client
|
|
||||||
{
|
|
||||||
RealmId = realm.Id,
|
|
||||||
ClientId = clientId,
|
|
||||||
Name = name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.Clients.Add(client);
|
|
||||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using IdentityShroud.Api.Apis;
|
|
||||||
using IdentityShroud.Core.EFCore;
|
|
||||||
using IdentityShroud.Core.Tests.Fixtures;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Shouldly;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Tests.Apis;
|
|
||||||
|
|
||||||
public class OpenIdApiTests : IClassFixture<ApplicationFactory>
|
|
||||||
{
|
|
||||||
private readonly ApplicationFactory _factory;
|
|
||||||
|
|
||||||
public OpenIdApiTests(ApplicationFactory factory)
|
|
||||||
{
|
|
||||||
_factory = factory;
|
|
||||||
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
|
||||||
if (!db.Database.EnsureCreated())
|
|
||||||
{
|
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(true)]
|
|
||||||
[InlineData(false)]
|
|
||||||
public async Task ClientCredentialsFlow(bool useAuthenticationHeader)
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
var createRealmResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
|
|
||||||
{
|
|
||||||
Slug = "foo",
|
|
||||||
Name = "Test'",
|
|
||||||
}),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
createRealmResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
|
||||||
|
|
||||||
var realm = await createRealmResponse.Content.ReadFromJsonAsync<RealmRepresentation>(
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
|
||||||
realm.ShouldNotBeNull();
|
|
||||||
realm.Id.ShouldNotBe(Guid.Empty);
|
|
||||||
|
|
||||||
var createClientResponse = await client.PostAsync(
|
|
||||||
$"/api/v1/realms/{realm.Id}/clients",
|
|
||||||
JsonContent.Create(new
|
|
||||||
{
|
|
||||||
ClientId = "myclient",
|
|
||||||
Name = "New Client",
|
|
||||||
Confidential = true,
|
|
||||||
AllowClientCredentialsFlow = true,
|
|
||||||
GenerateSecret = true,
|
|
||||||
}),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
createClientResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const string clientId = "myclient";
|
|
||||||
|
|
||||||
var data = new[]
|
|
||||||
{
|
|
||||||
new KeyValuePair<string, string>("client_id", clientId),
|
|
||||||
new KeyValuePair<string, string>("client_secret", "secret"),
|
|
||||||
new KeyValuePair<string, string>("response_type", "token"),
|
|
||||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (useAuthenticationHeader)
|
|
||||||
{
|
|
||||||
// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("basic",
|
|
||||||
// Convert.ToBase64String($"{clientId}:{clientSecret}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = new FormUrlEncodedContent(data);
|
|
||||||
var response = await client.PostAsync(
|
|
||||||
"/auth/realms/foo/openid-connect/token",
|
|
||||||
content,
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
// var responseJson = await response.Content.ReadAsStringAsync(
|
|
||||||
// TestContext.Current.CancellationToken);
|
|
||||||
// Console.WriteLine($"Response: {responseJson}");
|
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
||||||
|
|
||||||
// Cache-Control: no-store
|
|
||||||
response.Headers.CacheControl.ShouldNotBeNull()
|
|
||||||
.NoStore.ShouldBe(true);
|
|
||||||
// Pragma: no-cache
|
|
||||||
response.Headers.Pragma.ShouldNotBeNull()
|
|
||||||
.ShouldContain(new NameValueHeaderValue("no-cache"));
|
|
||||||
|
|
||||||
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>();
|
|
||||||
payload.ShouldNotBeNull();
|
|
||||||
Assert.Multiple(
|
|
||||||
() => payload.AccessToken.ShouldNotBeNull(),
|
|
||||||
() => payload.TokenType.ShouldBe("bearer"),
|
|
||||||
() => payload.ExpiresIn.ShouldBe(3600));
|
|
||||||
|
|
||||||
// - refresh_token OPTIONAL
|
|
||||||
// - scope OPTIONAL when identical to request otherwise REQUIRED
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class TokenResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("access_token")]
|
|
||||||
public string? AccessToken { get; set; }
|
|
||||||
[JsonPropertyName("token_type")]
|
|
||||||
public string? TokenType { get; set; }
|
|
||||||
[JsonPropertyName("expires_in")]
|
|
||||||
public int? ExpiresIn { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +1,16 @@
|
||||||
using System.Buffers.Text;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Security.Cryptography;
|
using FluentResults;
|
||||||
using System.Text.Json.Nodes;
|
using IdentityShroud.Core.Messages.Realm;
|
||||||
using IdentityShroud.Core.EFCore;
|
using IdentityShroud.Core.Services;
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Tests.Fixtures;
|
using IdentityShroud.Core.Tests.Fixtures;
|
||||||
using IdentityShroud.TestUtils.Asserts;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using NSubstitute.ClearExtensions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Tests.Apis;
|
namespace IdentityShroud.Api.Tests.Apis;
|
||||||
|
|
||||||
public class RealmApisTests : IClassFixture<ApplicationFactory>
|
public class RealmApisTests(ApplicationFactory factory) : IClassFixture<ApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly ApplicationFactory _factory;
|
|
||||||
|
|
||||||
public RealmApisTests(ApplicationFactory factory)
|
|
||||||
{
|
|
||||||
_factory = factory;
|
|
||||||
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Db>();
|
|
||||||
if (!db.Database.EnsureCreated())
|
|
||||||
{
|
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null, null, null, false, "Name")]
|
[InlineData(null, null, null, false, "Name")]
|
||||||
[InlineData(null, null, "Foo", true, "")]
|
[InlineData(null, null, "Foo", true, "")]
|
||||||
|
|
@ -40,12 +22,14 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
|
||||||
[InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")]
|
[InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")]
|
||||||
public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName)
|
public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
factory.RealmService.ClearSubstitute();
|
||||||
|
factory.RealmService.Create(Arg.Any<RealmCreateRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Ok(new RealmCreateResponse(Guid.NewGuid(), "foo", "Foo")));
|
||||||
|
|
||||||
Guid? inputId = id is null ? (Guid?)null : new Guid(id);
|
Guid? inputId = id is null ? (Guid?)null : new Guid(id);
|
||||||
|
var response = await client.PostAsync("/realms", JsonContent.Create(new
|
||||||
// act
|
|
||||||
var response = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
|
|
||||||
{
|
{
|
||||||
Id = inputId,
|
Id = inputId,
|
||||||
Slug = slug,
|
Slug = slug,
|
||||||
|
|
@ -59,123 +43,19 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
|
||||||
if (succeeds)
|
if (succeeds)
|
||||||
{
|
{
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
// await factory.RealmService.Received(1).Create(
|
await factory.RealmService.Received(1).Create(
|
||||||
// Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
|
Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
|
||||||
// Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
var problemDetails =
|
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
|
||||||
await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
|
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
|
||||||
// await factory.RealmService.DidNotReceive().Create(
|
await factory.RealmService.DidNotReceive().Create(
|
||||||
// Arg.Any<RealmCreateRequest>(),
|
Arg.Any<RealmCreateRequest>(),
|
||||||
// Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetOpenIdConfiguration_Success()
|
|
||||||
{
|
|
||||||
// setup
|
|
||||||
await ScopedContextAsync(async db =>
|
|
||||||
{
|
|
||||||
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo" });
|
|
||||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
// act
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
var response = await client.GetAsync("auth/realms/foo/.well-known/openid-configuration",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
#if DEBUG
|
|
||||||
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
#endif
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/auth", result, "authorization_endpoint");
|
|
||||||
JsonObjectAssert.Equal("http://localhost/auth/realms/foo", result, "issuer");
|
|
||||||
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/token", result, "token_endpoint");
|
|
||||||
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/jwks", result, "jwks_uri");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("bar")]
|
|
||||||
public async Task GetOpenIdConfiguration_NotFound(string slug)
|
|
||||||
{
|
|
||||||
// act
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
var response = await client.GetAsync($"/realms/{slug}/.well-known/openid-configuration",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetJwks()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
|
|
||||||
{
|
|
||||||
Slug = "foo",
|
|
||||||
Name = "Test'",
|
|
||||||
}),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
|
||||||
|
|
||||||
// act
|
|
||||||
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.NotNull(payload);
|
|
||||||
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(
|
|
||||||
Func<Db, Task> action
|
|
||||||
)
|
|
||||||
{
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,57 +1,24 @@
|
||||||
using IdentityShroud.Api;
|
using IdentityShroud.Core.Services;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Testcontainers.PostgreSql;
|
using Microsoft.VisualStudio.TestPlatform.TestHost;
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Fixtures;
|
namespace IdentityShroud.Core.Tests.Fixtures;
|
||||||
|
|
||||||
public class ApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
public class ApplicationFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer _postgresqlServer;
|
public IRealmService RealmService { get; } = Substitute.For<IRealmService>();
|
||||||
|
|
||||||
// public IRealmService RealmService { get; } = Substitute.For<IRealmService>();
|
|
||||||
|
|
||||||
public ApplicationFactory()
|
|
||||||
{
|
|
||||||
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
|
|
||||||
.WithName($"is-applicationFactory-{Guid.NewGuid():N}")
|
|
||||||
.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
base.ConfigureWebHost(builder);
|
base.ConfigureWebHost(builder);
|
||||||
|
|
||||||
builder.ConfigureAppConfiguration((context, configBuilder) =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
configBuilder.AddInMemoryCollection(
|
services.AddScoped<IRealmService>(c => RealmService);
|
||||||
new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(),
|
|
||||||
["secrets:master:0:Id"] = "94970f27-3d88-4223-9940-7dd57548f5b5",
|
|
||||||
["secrets:master:0:Active"] = "true",
|
|
||||||
["secrets:master:0:Algorithm"] = "AES",
|
|
||||||
["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// builder.ConfigureServices(services =>
|
|
||||||
// {
|
|
||||||
// services.AddScoped<IRealmService>(c => RealmService);
|
|
||||||
// });
|
|
||||||
|
|
||||||
builder.UseEnvironment("Development");
|
builder.UseEnvironment("Development");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
|
||||||
await _postgresqlServer.StartAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await _postgresqlServer.StopAsync();
|
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
using IdentityShroud.Api.Helpers;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Tests;
|
|
||||||
|
|
||||||
public class HeaderHelpersTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Basic dXNlcjpzZWNyZXQ=", true, "user", "secret")]
|
|
||||||
[InlineData("baSIC dXNlcjpzZWNyZXQ=", true, "user", "secret")]
|
|
||||||
[InlineData("Basic dXNlcnNlY3JldA==", false, null, null)] // no colon to seperate user and password
|
|
||||||
[InlineData("Bearer dXNlcjpzZWNyZXQ=", false, null, null)]
|
|
||||||
public void TryDecodeBasicAuth(string input, bool expectedResult, string? expectedUser, string? expectedPassword)
|
|
||||||
{
|
|
||||||
var result = HeaderHelpers.TryDecodeBasicAuth(input, out string? user, out string? password);
|
|
||||||
|
|
||||||
Assert.Equal(expectedResult, result);
|
|
||||||
Assert.Equal(expectedUser, user);
|
|
||||||
Assert.Equal(expectedPassword, password);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|
@ -8,25 +8,24 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
|
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Testcontainers" />
|
<PackageReference Include="Testcontainers" Version="4.10.0" />
|
||||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
<Using Include="NSubstitute" />
|
<Using Include="NSubstitute"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
|
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
|
||||||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
using FluentResults;
|
|
||||||
using IdentityShroud.Api.Mappers;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Apis;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The part of the api below realms/{slug}/clients
|
|
||||||
/// </summary>
|
|
||||||
public static class ClientApi
|
|
||||||
{
|
|
||||||
public const string ClientGetRouteName = "ClientGet";
|
|
||||||
|
|
||||||
public static void MapEndpoints(this IEndpointRouteBuilder erp)
|
|
||||||
{
|
|
||||||
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
|
|
||||||
|
|
||||||
clientsGroup.MapPost("", ClientCreate)
|
|
||||||
.Produces(StatusCodes.Status201Created)
|
|
||||||
.Validate<ClientCreateRequest>()
|
|
||||||
.WithName("ClientCreate");
|
|
||||||
|
|
||||||
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
|
|
||||||
.AddEndpointFilter<ClientIdValidationFilter>();
|
|
||||||
|
|
||||||
clientIdGroup.MapGet("", ClientGet)
|
|
||||||
.WithName(ClientGetRouteName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Ok<ClientRepresentation> ClientGet(
|
|
||||||
Guid realmId,
|
|
||||||
int clientId,
|
|
||||||
HttpContext context)
|
|
||||||
{
|
|
||||||
Client client = (Client)context.Items["ClientEntity"]!;
|
|
||||||
return TypedResults.Ok(new ClientMapper().ToDto(client));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>>
|
|
||||||
ClientCreate(
|
|
||||||
Guid realmId,
|
|
||||||
ClientCreateRequest request,
|
|
||||||
[FromServices] IClientService service,
|
|
||||||
[FromServices] IDataEncryptionService cryptor,
|
|
||||||
HttpContext context,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Realm realm = context.GetValidatedRealm();
|
|
||||||
Result<Client> result = await service.Create(realm.Id, request, cancellationToken);
|
|
||||||
|
|
||||||
if (result.IsFailed)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
Client client = result.Value;
|
|
||||||
ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client);
|
|
||||||
var secret = SelectBestSecret(client.Secrets);
|
|
||||||
if (secret is {} s)
|
|
||||||
clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret);
|
|
||||||
return TypedResults.CreatedAtRoute(
|
|
||||||
clientRepresentation,
|
|
||||||
ClientGetRouteName,
|
|
||||||
new RouteValueDictionary()
|
|
||||||
{
|
|
||||||
["realmId"] = realm.Id,
|
|
||||||
["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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public record ClientRepresentation
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public Guid RealmId { get; set; }
|
|
||||||
public required string ClientId { get; set; }
|
|
||||||
public string? Name { get; set; }
|
|
||||||
public string? Description { get; set; }
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
namespace IdentityShroud.Api.Apis;
|
|
||||||
|
|
||||||
public record ErrorDto(string Error);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace IdentityShroud.Api.Apis;
|
|
||||||
|
|
||||||
public record RealmRepresentation(
|
|
||||||
Guid Id,
|
|
||||||
string Slug,
|
|
||||||
string Name);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.DTO.OpenId;
|
|
||||||
|
|
||||||
public class TokenRequestBody
|
|
||||||
{
|
|
||||||
[JsonPropertyName("grant_type")]
|
|
||||||
public GrantTypes GrantType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// In most cases required but not when basic auth header is used
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("client_id")]
|
|
||||||
public string? ClientId { get; init; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("client_secret")]
|
|
||||||
public string? ClientSecret { get; init; }
|
|
||||||
|
|
||||||
[JsonPropertyName("scope")]
|
|
||||||
public string? Scope { get; init; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public static class EndpointRouteBuilderExtensions
|
|
||||||
{
|
|
||||||
public static IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder)
|
|
||||||
where TDto : class
|
|
||||||
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
|
|
||||||
|
|
||||||
public static void MapApis(this IEndpointRouteBuilder erp)
|
|
||||||
{
|
|
||||||
RealmApi.MapRealmEndpoints(erp);
|
|
||||||
|
|
||||||
OpenIdEndpoints.MapEndpoints(erp);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter
|
|
||||||
{
|
|
||||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
|
||||||
{
|
|
||||||
Guid realmId = context.Arguments.OfType<Guid>().First();
|
|
||||||
int id = context.Arguments.OfType<int>().First();
|
|
||||||
Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted);
|
|
||||||
if (client is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
context.HttpContext.Items["ClientEntity"] = client;
|
|
||||||
|
|
||||||
return await next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter
|
|
||||||
{
|
|
||||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
|
||||||
{
|
|
||||||
Guid id = context.Arguments.OfType<Guid>().First();
|
|
||||||
Realm? realm = await realmService.FindById(id, context.HttpContext.RequestAborted);
|
|
||||||
if (realm is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
context.HttpContext.Items["RealmEntity"] = realm;
|
|
||||||
|
|
||||||
return await next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Note the filter depends on the slug path parameter to be the first string argument on the context.
|
|
||||||
/// The endpoint handlers should place path arguments first and in order of the path to ensure this works
|
|
||||||
/// consistently.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="realmService"></param>
|
|
||||||
public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter
|
|
||||||
{
|
|
||||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
|
||||||
{
|
|
||||||
string realmSlug = context.Arguments.OfType<string>().FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException("Expected argument missing, ensure you include path parameters in your handlers signature even when you don't use them");
|
|
||||||
Realm? realm = await realmService.FindBySlug(realmSlug, context.HttpContext.RequestAborted);
|
|
||||||
if (realm is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
context.HttpContext.Items["RealmEntity"] = realm;
|
|
||||||
|
|
||||||
return await next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Helpers;
|
|
||||||
|
|
||||||
public static class HeaderHelpers
|
|
||||||
{
|
|
||||||
public static bool TryGetBasicAuth(
|
|
||||||
HttpContext context,
|
|
||||||
[NotNullWhen(true)] out string? user,
|
|
||||||
[NotNullWhen(true)] out string? password)
|
|
||||||
{
|
|
||||||
var headers = context?.Request.Headers;
|
|
||||||
if (headers is not null)
|
|
||||||
{
|
|
||||||
if (headers.TryGetValue("Authorization", out StringValues s))
|
|
||||||
return TryDecodeBasicAuth(s.ToString(), out user, out password);
|
|
||||||
}
|
|
||||||
|
|
||||||
user = password = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryDecodeBasicAuth(
|
|
||||||
string authorizationHeader,
|
|
||||||
[NotNullWhen(true)] out string? user,
|
|
||||||
[NotNullWhen(true)] out string? password)
|
|
||||||
{
|
|
||||||
if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
ReadOnlySpan<char> val = authorizationHeader.AsSpan(6); // basic + space
|
|
||||||
Span<byte> b = new byte[(val.Length * 6 / 8) + 1];
|
|
||||||
if (Convert.TryFromBase64Chars(val, b, out int written))
|
|
||||||
{
|
|
||||||
int sepIdx = b.IndexOf((byte)':');
|
|
||||||
if (sepIdx > 0 && sepIdx < written - 1)
|
|
||||||
{
|
|
||||||
user = Encoding.UTF8.GetString(b.Slice(0, sepIdx));
|
|
||||||
password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1)));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user = password = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
namespace IdentityShroud.Api.Apis.ISResults;
|
|
||||||
|
|
||||||
public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult
|
|
||||||
{
|
|
||||||
private readonly List<string> _wwwAuthenticateValues;
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="UnauthorizedHttpResult"/> class.
|
|
||||||
/// </summary>
|
|
||||||
internal ISUnauthorizedHttpResult(List<string> wwwAuthenticateValues)
|
|
||||||
{
|
|
||||||
_wwwAuthenticateValues = wwwAuthenticateValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the HTTP status code: <see cref="StatusCodes.Status401Unauthorized"/>
|
|
||||||
/// </summary>
|
|
||||||
public int StatusCode => StatusCodes.Status401Unauthorized;
|
|
||||||
|
|
||||||
int? IStatusCodeHttpResult.StatusCode => StatusCode;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task ExecuteAsync(HttpContext httpContext)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(httpContext);
|
|
||||||
|
|
||||||
// Creating the logger with a string to preserve the category after the refactoring.
|
|
||||||
// var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
|
|
||||||
// var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult");
|
|
||||||
// HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode);
|
|
||||||
|
|
||||||
|
|
||||||
httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray());
|
|
||||||
|
|
||||||
httpContext.Response.StatusCode = StatusCode;
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using Riok.Mapperly.Abstractions;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Mappers;
|
|
||||||
|
|
||||||
[Mapper]
|
|
||||||
public partial class ClientMapper
|
|
||||||
{
|
|
||||||
// skipping secret as we do not have the DEK
|
|
||||||
[MapperIgnoreSource(nameof(Client.Secrets))]
|
|
||||||
[MapperIgnoreTarget(nameof(ClientRepresentation.Secret))]
|
|
||||||
public partial ClientRepresentation ToDto(Client client);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
using IdentityShroud.Core.Messages;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api.Mappers;
|
|
||||||
|
|
||||||
public class KeyMapper(IKeyProviderFactory keyProviderFactory)
|
|
||||||
{
|
|
||||||
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmSigningKey> keys)
|
|
||||||
{
|
|
||||||
JsonWebKeySet wks = new();
|
|
||||||
foreach (var k in keys)
|
|
||||||
{
|
|
||||||
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType);
|
|
||||||
if (provider.IsPublic)
|
|
||||||
{
|
|
||||||
JsonWebKey jwk = new()
|
|
||||||
{
|
|
||||||
KeyId = k.Id.ToString(),
|
|
||||||
KeyType = k.KeyType,
|
|
||||||
Use = "sig",
|
|
||||||
};
|
|
||||||
|
|
||||||
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
|
|
||||||
wks.Keys.Add(jwk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return wks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}")
|
|
||||||
.AddEndpointFilter<RealmSlugValidationFilter>();
|
|
||||||
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
|
|
||||||
|
|
||||||
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
|
|
||||||
openidConnect.MapPost("auth", OpenIdConnectAuth);
|
|
||||||
openidConnect.MapPost("token", OpenIdConnectToken);
|
|
||||||
openidConnect.MapGet("jwks", OpenIdConnectJwks);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
|
|
||||||
string realmSlug,
|
|
||||||
[FromServices]IRealmService realmService,
|
|
||||||
HttpContext context)
|
|
||||||
{
|
|
||||||
Realm realm = context.GetValidatedRealm();
|
|
||||||
|
|
||||||
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
|
|
||||||
var searchString = $"realms/{realmSlug}";
|
|
||||||
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
|
|
||||||
string baseUri = s.Substring(0, index + searchString.Length);
|
|
||||||
|
|
||||||
return TypedResults.Json(new OpenIdConfiguration()
|
|
||||||
{
|
|
||||||
AuthorizationEndpoint = baseUri + "/openid-connect/auth",
|
|
||||||
TokenEndpoint = baseUri + "/openid-connect/token",
|
|
||||||
Issuer = baseUri,
|
|
||||||
JwksUri = baseUri + "/openid-connect/jwks",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
|
|
||||||
string realmSlug,
|
|
||||||
[FromServices]IRealmService realmService,
|
|
||||||
[FromServices]KeyMapper keyMapper,
|
|
||||||
HttpContext context)
|
|
||||||
{
|
|
||||||
Realm realm = context.GetValidatedRealm();
|
|
||||||
await realmService.LoadActiveKeys(realm);
|
|
||||||
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Results<
|
|
||||||
Ok<TokenResponse>,
|
|
||||||
BadRequest<ErrorDto>,
|
|
||||||
ISUnauthorizedHttpResult
|
|
||||||
>> OpenIdConnectToken(
|
|
||||||
string realmSlug,
|
|
||||||
[FromServices] IClientService clientService,
|
|
||||||
HttpContext context,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
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,74 +1,115 @@
|
||||||
using IdentityShroud.Api.Apis;
|
using FluentResults;
|
||||||
using IdentityShroud.Core.Contracts;
|
using IdentityShroud.Api.Validation;
|
||||||
|
using IdentityShroud.Core.Messages;
|
||||||
using IdentityShroud.Core.Messages.Realm;
|
using IdentityShroud.Core.Messages.Realm;
|
||||||
using IdentityShroud.Core.Model;
|
using IdentityShroud.Core.Services;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
namespace IdentityShroud.Api;
|
||||||
|
|
||||||
public static class HttpContextExtensions
|
|
||||||
{
|
|
||||||
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// api: api/v1/realms/{realmId}/....
|
|
||||||
// api: api/v1/realms/{realmId}/clients/{clientId}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static class RealmApi
|
public static class RealmApi
|
||||||
{
|
{
|
||||||
public const string GetRealmRoute = "Get Realm";
|
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
|
||||||
public const string CreateRealmRoute = "Create Realm";
|
|
||||||
|
|
||||||
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
|
|
||||||
{
|
{
|
||||||
var realmsGroup = erp.MapGroup("/api/v1/realms");
|
var realmsGroup = app.MapGroup("/realms");
|
||||||
|
|
||||||
realmsGroup.MapPost("", RealmCreate)
|
realmsGroup.MapPost("", RealmCreate)
|
||||||
.Produces(StatusCodes.Status201Created)
|
|
||||||
.Validate<RealmCreateRequest>()
|
.Validate<RealmCreateRequest>()
|
||||||
.WithName(CreateRealmRoute);
|
.WithName("Create Realm")
|
||||||
|
.Produces(StatusCodes.Status201Created);
|
||||||
|
|
||||||
|
var realmSlugGroup = app.MapGroup("{slug}");
|
||||||
|
realmSlugGroup.MapGet("", GetRealmInfo);
|
||||||
|
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
|
||||||
|
|
||||||
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
|
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
|
||||||
.AddEndpointFilter<RealmIdValidationFilter>();
|
openidConnect.MapPost("auth", OpenIdConnectAuth);
|
||||||
|
openidConnect.MapPost("token", OpenIdConnectToken);
|
||||||
realmIdGroup.MapGet("", RealmGet)
|
openidConnect.MapGet("jwks", OpenIdConnectJwks);
|
||||||
.WithName(GetRealmRoute);
|
|
||||||
|
|
||||||
ClientApi.MapEndpoints(realmIdGroup);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Ok<RealmRepresentation> RealmGet(
|
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
|
||||||
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)
|
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
|
||||||
{
|
{
|
||||||
var response = await service.Create(request);
|
var response = await service.Create(request);
|
||||||
if (response.IsSuccess)
|
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.
|
// TODO make helper to convert failure response to a proper HTTP result.
|
||||||
return TypedResults.InternalServerError();
|
return TypedResults.InternalServerError();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RealmRepresentation MapToRepresentation(Realm realm)
|
private static Task OpenIdConnectJwks(HttpContext context)
|
||||||
=> new(realm.Id, realm.Slug, realm.Name);
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task OpenIdConnectToken(HttpContext context)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task OpenIdConnectAuth(HttpContext context)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(slug))
|
||||||
|
return TypedResults.BadRequest();
|
||||||
|
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
|
||||||
|
var searchString = $"realms/{slug}";
|
||||||
|
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
|
||||||
|
string baseUri = s.Substring(0, index + searchString.Length);
|
||||||
|
|
||||||
|
return TypedResults.Json(new OpenIdConfiguration()
|
||||||
|
{
|
||||||
|
AuthorizationEndpoint = baseUri + "/openid-connect/auth",
|
||||||
|
TokenEndpoint = baseUri + "/openid-connect/token",
|
||||||
|
Issuer = baseUri,
|
||||||
|
JwksUri = baseUri + "/openid-connect/jwks",
|
||||||
|
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRealmInfo()
|
||||||
|
{
|
||||||
|
return "Hello World!";
|
||||||
|
|
||||||
|
/* keycloak returns this
|
||||||
|
{
|
||||||
|
"realm": "mpluskassa",
|
||||||
|
"public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1/qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ/UegS9ylobfIYXJsz0xmJK5ToFaSYa72D/Dyln7ROxudu8+zc70sz7bUKQ0/ktWRsiu76vY6Kr9+18PgaooPmb2QP8lS8IZEv+gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA+u7s/GrOLcLoLLgEnRRVwIDAQAB",
|
||||||
|
"token-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/protocol/openid-connect",
|
||||||
|
"account-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/account",
|
||||||
|
"tokens-not-before": 0
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
// [HttpGet("")]
|
||||||
|
// public ActionResult Index()
|
||||||
|
// {
|
||||||
|
// return new JsonResult("Hello world!");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [HttpGet("{slug}/.well-known/openid-configuration")]
|
||||||
|
// public ActionResult GetOpenIdConfiguration(
|
||||||
|
// string slug,
|
||||||
|
// [FromServices]LinkGenerator linkGenerator)
|
||||||
|
// {
|
||||||
|
// var s = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}";
|
||||||
|
// var searchString = $"realms/{slug}";
|
||||||
|
// int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
|
||||||
|
// string baseUri = s.Substring(0, index + searchString.Length);
|
||||||
|
//
|
||||||
|
// return new JsonResult(baseUri);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [HttpPost("{slug}/protocol/openid-connect/token")]
|
||||||
|
// public ActionResult GetOpenIdConnectToken(string slug)
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// return new JsonResult("Hello world!");
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
using FluentValidation;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest>
|
|
||||||
{
|
|
||||||
// most of standard ascii minus the control characters and space
|
|
||||||
private const string ClientIdPattern = "^[a-zA-Z0-9_-]+";
|
|
||||||
|
|
||||||
private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ];
|
|
||||||
|
|
||||||
public ClientCreateRequestValidator()
|
|
||||||
{
|
|
||||||
RuleFor(e => e.ClientId).NotEmpty().MaximumLength(40).Matches(ClientIdPattern);
|
|
||||||
RuleFor(e => e.Name).MaximumLength(80);
|
|
||||||
RuleFor(e => e.Description).MaximumLength(2048);
|
|
||||||
RuleFor(e => e.SignatureAlgorithm)
|
|
||||||
.Must(v => v is null || _allowedAlgorithms.Contains(v))
|
|
||||||
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null");
|
|
||||||
RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true);
|
|
||||||
RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
IdentityShroud.Api/AppJsonSerializerContext.cs
Normal file
8
IdentityShroud.Api/AppJsonSerializerContext.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using IdentityShroud.Core.Messages;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(OpenIdConfiguration))]
|
||||||
|
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
|
||||||
|
|
||||||
public class GlobalExceptionHandler : IExceptionHandler
|
|
||||||
{
|
|
||||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
|
||||||
|
|
||||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
|
||||||
=> _logger = logger;
|
|
||||||
|
|
||||||
public async ValueTask<bool> TryHandleAsync(
|
|
||||||
HttpContext httpContext,
|
|
||||||
Exception exception,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogError(exception, "Exception type: {Type}, Message: {Message}",
|
|
||||||
exception.GetType().Name, exception.Message);
|
|
||||||
|
|
||||||
// Return false to let other handlers or the default handle it
|
|
||||||
// Return true to mark it as handled
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<PublishAot>false</PublishAot>
|
<PublishAot>true</PublishAot>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
|
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
@ -15,17 +15,16 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
||||||
<PackageReference Include="Riok.Mapperly" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
||||||
<PackageReference Include="Serilog" />
|
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Expressions" />
|
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
||||||
<ProjectReference Include="..\IdentityShroud.GraphQL\IdentityShroud.GraphQL.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cdto/@EntryIndexedValue">True</s:Boolean>
|
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean>
|
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cvalidation/@EntryIndexedValue">True</s:Boolean>
|
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validation/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
|
||||||
|
|
@ -1,74 +1,60 @@
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using IdentityShroud.Api.Mappers;
|
using IdentityShroud.Api;
|
||||||
|
using IdentityShroud.Api.Validation;
|
||||||
using IdentityShroud.Core;
|
using IdentityShroud.Core;
|
||||||
using IdentityShroud.Core.EFCore;
|
using IdentityShroud.Core.Contracts;
|
||||||
using IdentityShroud.GraphQL;
|
using IdentityShroud.Core.Security;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Formatting.Json;
|
using Serilog.Formatting.Json;
|
||||||
|
|
||||||
|
|
||||||
// Initial logging until we can set it up from Configuration
|
// Initial logging until we can set it up from Configuration
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console(new JsonFormatter())
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
var applicationBuilder = WebApplication.CreateSlimBuilder(args);
|
||||||
|
ConfigureBuilder(applicationBuilder);
|
||||||
|
var application = applicationBuilder.Build();
|
||||||
|
ConfigureApplication(application);
|
||||||
|
application.Run();
|
||||||
|
|
||||||
public class Program
|
void ConfigureBuilder(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
var services = builder.Services;
|
||||||
|
var configuration = builder.Configuration;
|
||||||
|
|
||||||
|
//services.AddControllers();
|
||||||
|
services.ConfigureHttpJsonOptions(options =>
|
||||||
{
|
{
|
||||||
Log.Logger = new LoggerConfiguration()
|
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
|
||||||
.Enrich.FromLogContext()
|
});
|
||||||
.WriteTo.Console(new JsonFormatter())
|
|
||||||
.CreateLogger();
|
|
||||||
|
|
||||||
var applicationBuilder = WebApplication.CreateSlimBuilder(args);
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||||
ConfigureBuilder(applicationBuilder);
|
services.AddOpenApi();
|
||||||
var application = applicationBuilder.Build();
|
services.AddScoped<Db>();
|
||||||
ConfigureApplication(application);
|
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
|
||||||
application.Run();
|
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
|
||||||
}
|
|
||||||
|
|
||||||
private static void ConfigureBuilder(WebApplicationBuilder builder)
|
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
|
||||||
{
|
|
||||||
var services = builder.Services;
|
|
||||||
var configuration = builder.Configuration;
|
|
||||||
|
|
||||||
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
|
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||||
|
.Enrich.FromLogContext()
|
||||||
// services.ConfigureHttpJsonOptions(options =>
|
//.Enrich.With<UserEnricher>()
|
||||||
// {
|
.ReadFrom.Configuration(context.Configuration));
|
||||||
// options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
|
|
||||||
// });
|
|
||||||
|
|
||||||
services.AddScoped<KeyMapper>();
|
|
||||||
|
|
||||||
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
|
|
||||||
|
|
||||||
services.AddHttpContextAccessor();
|
|
||||||
services.AddOpenApi();
|
|
||||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
|
||||||
services.AddProblemDetails();
|
|
||||||
|
|
||||||
services
|
|
||||||
.AddCore()
|
|
||||||
.AddIdentityShroudGraphQL();
|
|
||||||
|
|
||||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
|
||||||
.Enrich.FromLogContext()
|
|
||||||
//.Enrich.With<UserEnricher>()
|
|
||||||
.ReadFrom.Configuration(context.Configuration));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ConfigureApplication(WebApplication app)
|
|
||||||
{
|
|
||||||
app.UseExceptionHandler();
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
app.UseSerilogRequestLogging();
|
|
||||||
app.MapApis();
|
|
||||||
app.MapIdentityShroudGraphQL();
|
|
||||||
|
|
||||||
// app.UseRouting();
|
|
||||||
// app.MapControllers();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConfigureApplication(WebApplication app)
|
||||||
|
{
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
app.MapRealmEndpoints();
|
||||||
|
// app.UseRouting();
|
||||||
|
// app.MapControllers();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Program { }
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "graphql",
|
"launchUrl": "todos",
|
||||||
"applicationUrl": "http://localhost:5249",
|
"applicationUrl": "http://localhost:5249",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace IdentityShroud.Api.Validation;
|
||||||
|
|
||||||
|
public static class EndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
|
||||||
|
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using IdentityShroud.Core.Messages.Realm;
|
using IdentityShroud.Core.Messages.Realm;
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
namespace IdentityShroud.Api.Validation;
|
||||||
|
|
||||||
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
|
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
|
||||||
{
|
{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace IdentityShroud.Api;
|
namespace IdentityShroud.Api.Validation;
|
||||||
|
|
||||||
public class ValidateFilter<T> : IEndpointFilter where T : class
|
public class ValidateFilter<T> : IEndpointFilter where T : class
|
||||||
{
|
{
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using IdentityShroud.Core.EFCore;
|
using DotNet.Testcontainers.Containers;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -8,13 +8,23 @@ namespace IdentityShroud.Core.Tests.Fixtures;
|
||||||
|
|
||||||
public class DbFixture : IAsyncLifetime
|
public class DbFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer _postgresqlServer;
|
private readonly IContainer _postgresqlServer;
|
||||||
|
|
||||||
public Db CreateDbContext(string dbName = "testdb")
|
private string ConnectionString =>
|
||||||
|
$"Host={_postgresqlServer.Hostname};" +
|
||||||
|
$"Port={DbPort};" +
|
||||||
|
$"Username={Username};Password={Password}";
|
||||||
|
|
||||||
|
private string Username => "postgres";
|
||||||
|
private string Password => "password";
|
||||||
|
private string DbHostname => _postgresqlServer.Hostname;
|
||||||
|
private int DbPort => _postgresqlServer.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort);
|
||||||
|
|
||||||
|
public Db CreateDbContext(string dbName)
|
||||||
{
|
{
|
||||||
var db = new Db(Options.Create<DbConfiguration>(new()
|
var db = new Db(Options.Create<DbConfiguration>(new()
|
||||||
{
|
{
|
||||||
ConnectionString = _postgresqlServer.GetConnectionString(),
|
ConnectionString = ConnectionString + ";Database=" + dbName,
|
||||||
LogSensitiveData = false,
|
LogSensitiveData = false,
|
||||||
}), new NullLoggerFactory());
|
}), new NullLoggerFactory());
|
||||||
return db;
|
return db;
|
||||||
|
|
@ -23,7 +33,8 @@ public class DbFixture : IAsyncLifetime
|
||||||
public DbFixture()
|
public DbFixture()
|
||||||
{
|
{
|
||||||
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
|
_postgresqlServer = new PostgreSqlBuilder("postgres:18.1")
|
||||||
.WithName("is-dbfixture-" + Guid.NewGuid().ToString("D"))
|
.WithName("KMS-Test-Infra-" + Guid.NewGuid().ToString("D"))
|
||||||
|
.WithPassword(Password)
|
||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +50,7 @@ public class DbFixture : IAsyncLifetime
|
||||||
|
|
||||||
public NpgsqlConnection GetConnection(string dbname)
|
public NpgsqlConnection GetConnection(string dbname)
|
||||||
{
|
{
|
||||||
string connString = _postgresqlServer.GetConnectionString()
|
string connString = ConnectionString
|
||||||
+ $";Database={dbname}";
|
+ $";Database={dbname}";
|
||||||
var connection = new NpgsqlConnection(connString);
|
var connection = new NpgsqlConnection(connString);
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using IdentityShroud.Core.Helpers;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Helpers;
|
|
||||||
|
|
||||||
public class Base64UrlConverterTests
|
|
||||||
{
|
|
||||||
internal class Data
|
|
||||||
{
|
|
||||||
[JsonConverter(typeof(Base64UrlConverter))]
|
|
||||||
public byte[]? X { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Serialize()
|
|
||||||
{
|
|
||||||
Data d = new() { X = ">>>???"u8.ToArray() };
|
|
||||||
string s = JsonSerializer.Serialize(d);
|
|
||||||
|
|
||||||
Assert.Contains("\"Pj4-Pz8_\"", s);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Deerialize()
|
|
||||||
{
|
|
||||||
var jsonstring = """
|
|
||||||
{ "X": "Pj4-Pz8_" }
|
|
||||||
""";
|
|
||||||
var d = JsonSerializer.Deserialize<Data>(jsonstring);
|
|
||||||
|
|
||||||
Assert.Equal(">>>???", Encoding.UTF8.GetString(d.X));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
using IdentityShroud.Core.Helpers;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Helpers;
|
|
||||||
|
|
||||||
public class SlugHelperTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData("", 40, "")]
|
|
||||||
[InlineData("test", 40, "test")]
|
|
||||||
[InlineData("Test", 40, "test")]
|
|
||||||
[InlineData("tést", 40, "test")]
|
|
||||||
[InlineData("foo_bar", 40, "foo-bar")]
|
|
||||||
[InlineData("foo bar", 40, "foo-bar")]
|
|
||||||
[InlineData("-foo", 40, "foo")]
|
|
||||||
[InlineData("foo-", 40, "foo")]
|
|
||||||
[InlineData("_foo", 40, "foo")]
|
|
||||||
[InlineData("foo_", 40, "foo")]
|
|
||||||
[InlineData("slug_would_be_too_long", 16, "slug-woul-frYeRw")] // not at word boundary
|
|
||||||
[InlineData("slug_would_be_too_long", 18, "slug-would-frYeRw")] // at word boundary
|
|
||||||
public void Test(string input, int max_length, string expected)
|
|
||||||
{
|
|
||||||
string result = SlugHelper.GenerateSlug(input, max_length);
|
|
||||||
|
|
||||||
Assert.Equal(expected, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|
@ -8,25 +8,24 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
|
<PackageReference Include="jose-jwt" Version="5.2.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
<PackageReference Include="Testcontainers" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
<PackageReference Include="Testcontainers" Version="4.10.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
<Using Include="NSubstitute" />
|
<Using Include="NSubstitute"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
|
|
||||||
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
||||||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using IdentityShroud.Core.Messages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests;
|
|
||||||
|
|
||||||
public class JwtSignatureGeneratorTests
|
|
||||||
{
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void VerifySignatureValid()
|
|
||||||
{
|
|
||||||
using var rsa = RSA.Create(2048);
|
|
||||||
|
|
||||||
string header = WebEncoders.Base64UrlEncode("fake header"u8.ToArray());
|
|
||||||
string payload = WebEncoders.Base64UrlEncode("fake payload"u8.ToArray());
|
|
||||||
var jwtString = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa);
|
|
||||||
|
|
||||||
Assert.True(ValidateJwtSignature(jwtString, rsa));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This test is to prove our signature verification code is correct. The inputs are
|
|
||||||
/// all from a production keycloak instance.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void ValidateKeycloakSignature()
|
|
||||||
{
|
|
||||||
string keycloakGeneratedJwt =
|
|
||||||
"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0.eyJleHAiOjE3NzEwNTQxMDksImlhdCI6MTc3MTA1MzgwOSwiYXV0aF90aW1lIjoxNzcxMDUzODA4LCJqdGkiOiI5MTEzZjEwNC03YzllLTQzNzItYmU4Yy03NDMwMmI1ZTU1NGUiLCJpc3MiOiJodHRwczovL2lhbS5rYXNzYWNsb3VkLm5sL2F1dGgvcmVhbG1zL21wbHVza2Fzc2EiLCJhdWQiOlsia2Fzc2EtbWFuYWdlbWVudC1zZXJ2aWNlIiwiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIiwiYWNjb3VudCJdLCJzdWIiOiIwOTNjY2YxNS1jNGE5LTRhYjQtOTcxZi1kNWEwMjIzNmQ4NWEiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJkZWFsZXJfc3VwcG9ydCIsInNpZCI6IjRiYjI0OGQ1LWVkNzktNGU0Yy05NWNjLTAwYzgzMzliZmZiMyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9tcGx1c2thc3NhLm9ubGluZSIsImh0dHBzOi8vd3d3Lm1wbHVza2Fzc2Euc3VwcG9ydCIsImh0dHBzOi8vbXBsdXNrYXNzYS5zdXBwb3J0IiwiaHR0cDovL2xvY2FsaG9zdDo0MDkwIiwiaHR0cHM6Ly93d3cubXBsdXNrYXNzYS5vbmxpbmUiLCJodHRwOi8vbG9jYWxob3N0IiwiLyoiLCJodHRwOi8vbG9jYWxob3N0OjQyMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbXBsdXNrYXNzYSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWFsZXItbWVkZXdlcmtlci1yb2xlIiwibXBsdXNrYXNzYS1tZWRld2Vya2VyLXJvbGUiXX0sInJlc291cmNlX2FjY2VzcyI6eyJhcGFjaGUyLWludHJhbmV0LWF1dGgiOnsicm9sZXMiOlsiaW50cmFuZXQiLCJyZWxlYXNlbm90ZXNfd3JpdGUiXX0sImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSI6eyJyb2xlcyI6WyJwb3NhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJkcmFmdF9saWNlbnNlX3dyaXRlIiwibGljZW5zZV9yZWFkIiwia25vd2xlZGdlSXRlbV9yZWFkIiwibWFpbGluZ19yZWFkIiwibXBsdXNhcGlfcmVhZCIsImRhdGFiYXNlX3VzZXJfd3JpdGUiLCJlbnZpcm9ubWVudF93cml0ZSIsImdrc19hdXRoY29kZV9yZWFkIiwiZW1wbG95ZWVfcmVhZCIsImRhdGFiYXNlX3VzZXJfcmVhZCIsImFwaWFjY291bnRfcGFzc3dvcmRyZXNldCIsIm1wbHVzYXBpX3dyaXRlIiwiZW52aXJvbm1lbnRfcmVhZCIsImtub3dsZWRnZUl0ZW1fd3JpdGUiLCJkYXRhYmFzZV91c2VyX3Bhc3N3b3JkX3JlYWQiLCJsaWNlbnNlX3dyaXRlIiwiY3VzdG9tZXJfd3JpdGUiLCJkZWFsZXJfcmVhZCIsImVtcGxveWVlX3dyaXRlIiwiZGF0YWJhc2VfY29uZmlndXJhdGlvbl93cml0ZSIsInJlbGF0aW9uc19yZWFkIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9tcGx1c19lbmNyeXB0ZWRfcmVhZCIsImRyYWZ0X2xpY2Vuc2VfcmVhZCIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fcmVhZCJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQga21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9.SHjVTWsFwiaKTxBX-0GZM1pK8rOodkYnEu_QJ4dlPpozai9j3RRJK3DswsuEbJC8PdQXI4-AI0-5JGBQi2gDXdFSVHhAblnmjva0sWCaY7lG2ASa65UKM_4RzH-6nvQ9EiZXdANzsWkLG350l-dLiqdt--Lpjpw2huK_GKAx20SKfauKBmm990rHzrl0Uii3wQ3fPHlAJ_8-WSnSBquOH8xsYJHa1LOsc2WqbEDnMA4hRnGvCoubwhkOANfWSx0OCwSIKBddrcts64ZAxFhmilZXGzWMqDkblY2fDU8_jrlysgYsymQlOVwwg7V5Ps-DJkGXWvmpncKfyYd3Vuwusg";
|
|
||||||
string keycloakKeySet = """
|
|
||||||
{
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"kid": "rmVwMNk3J5Xyf1hrKsUlEX7PMRn6vVJcHwSqX1EPFqI",
|
|
||||||
"kty": "RSA",
|
|
||||||
"alg": "RS256",
|
|
||||||
"use": "sig",
|
|
||||||
"n": "pYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1_qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ_UegS9ylobfIYXJsz0xmJK5ToFaSYa72D_Dyln7ROxudu8-zc70sz7bUKQ0_ktWRsiu76vY6Kr9-18PgaooPmb2QP8lS8IZEv-gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA-u7s_GrOLcLoLLgEnRRVw",
|
|
||||||
"e": "AQAB",
|
|
||||||
"x5c": [
|
|
||||||
"MIICozCCAYsCBgFwfLC07DANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptcGx1c2thc3NhMB4XDTIwMDIyNTE0MTAyMFoXDTMwMDIyNTE0MTIwMFowFTETMBEGA1UEAwwKbXBsdXNrYXNzYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKWGywHjiwxMMy+LRMLhNi34rDgV6EKlgPUXT96YeprsBdRK34YgUiAdiREyn+xdTrIhdf6p7oeLg359EzvGyt5Unx05SB1kocGif1HoEvcpaG3yGFybM9MZiSuU6BWkmGu9g/w8pZ+0TsbnbvPs3O9LM+21CkNP5LVkbIru+r2Oiq/ftfD4GqKD5m9kD/JUvCGRL/oFuUi6qDHNQ3w/JbIodbHZ0PFuuXAbDZ3p5FnPewBfXDEeq3Q9rWXywMRBymGqIQC/RLELC4nNz9n9lGqkUwDr4KsmBOYciUTrez8nsovGEsu81w9dodpnNJq0qAPru7Pxqzi3C6Cy4BJ0UVcCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAYcJVYv8HzZQIMrqhIyu7EVihPAx0w9NaZ1xzB9qCHrwie6ZLQdnMm8l0IdehyYuY+0HK7FjC8dAcT4nklOQDg4iCp7ZrM7vFNP+60pR0i7aIbf0cFXy9VTOPvUsXmu+p1LqRQLRJD0BjO29gupTe68KyTtyuX5A7JfmCq84j5i45Md8A9MAMZWnXMSHiaZLtlOS/4t4cdc371uq9fH7SKusnvUY0d14+BzcrHi/eurhKUUjJZ1xclUE2trOXFrE78fSMUmeGbIlpAV0MtrJW7OmXmaHH8Q1wTm78RH/dn4EmWSoFcigdVcDge941/MT2soDSIGLUnYiYrW8d6HE2Lg=="
|
|
||||||
],
|
|
||||||
"x5t": "rj9_q26MIdowvyJJbyHySeUl1y8",
|
|
||||||
"x5t#S256": "KNyQ8ngE925F__ZPJm-wCNUnGBJQGJbZGGjlCvmwBkM"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!;
|
|
||||||
using RSA publicKey = LoadFromJwk(keySet.Keys[0]);
|
|
||||||
|
|
||||||
Assert.True(ValidateJwtSignature(keycloakGeneratedJwt, publicKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ValidateJwtSignature(string jwtString, RSA publicKey)
|
|
||||||
{
|
|
||||||
int lastDotIndex = jwtString.LastIndexOf('.');
|
|
||||||
|
|
||||||
return publicKey.VerifyData(
|
|
||||||
Encoding.UTF8.GetBytes(jwtString, 0, lastDotIndex),
|
|
||||||
WebEncoders.Base64UrlDecode(jwtString, lastDotIndex + 1, jwtString.Length - (lastDotIndex + 1)),
|
|
||||||
HashAlgorithmName.SHA256,
|
|
||||||
RSASignaturePadding.Pkcs1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RSA LoadFromJwk(JsonWebKey jwk)
|
|
||||||
{
|
|
||||||
var rsa = RSA.Create();
|
|
||||||
var parameters = new RSAParameters
|
|
||||||
{
|
|
||||||
Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!),
|
|
||||||
Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!)
|
|
||||||
};
|
|
||||||
|
|
||||||
rsa.ImportParameters(parameters);
|
|
||||||
return rsa;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
51
IdentityShroud.Core.Tests/Model/RealmTests.cs
Normal file
51
IdentityShroud.Core.Tests/Model/RealmTests.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
using IdentityShroud.Core.Contracts;
|
||||||
|
using IdentityShroud.Core.Model;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Tests.Model;
|
||||||
|
|
||||||
|
public class RealmTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SetNewKey()
|
||||||
|
{
|
||||||
|
byte[] privateKey = [5, 6, 7, 8];
|
||||||
|
byte[] encryptedPrivateKey = [1, 2, 3, 4];
|
||||||
|
|
||||||
|
var encryptionService = Substitute.For<IEncryptionService>();
|
||||||
|
encryptionService
|
||||||
|
.Encrypt(Arg.Any<byte[]>())
|
||||||
|
.Returns(x => encryptedPrivateKey);
|
||||||
|
|
||||||
|
Realm realm = new();
|
||||||
|
realm.SetPrivateKey(encryptionService, privateKey);
|
||||||
|
|
||||||
|
// should be able to return original without calling decrypt
|
||||||
|
Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService));
|
||||||
|
Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted);
|
||||||
|
|
||||||
|
encryptionService.Received(1).Encrypt(privateKey);
|
||||||
|
encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDecryptedKey()
|
||||||
|
{
|
||||||
|
byte[] privateKey = [5, 6, 7, 8];
|
||||||
|
byte[] encryptedPrivateKey = [1, 2, 3, 4];
|
||||||
|
|
||||||
|
var encryptionService = Substitute.For<IEncryptionService>();
|
||||||
|
encryptionService
|
||||||
|
.Decrypt(encryptedPrivateKey)
|
||||||
|
.Returns(x => privateKey);
|
||||||
|
|
||||||
|
Realm realm = new();
|
||||||
|
realm.PrivateKeyEncrypted = encryptedPrivateKey;
|
||||||
|
|
||||||
|
// should be able to return original without calling decrypt
|
||||||
|
Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService));
|
||||||
|
Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted);
|
||||||
|
|
||||||
|
encryptionService.Received(1).Decrypt(encryptedPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
21
IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs
Normal file
21
IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using IdentityShroud.Core.Security;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Tests.Security;
|
||||||
|
|
||||||
|
public class AesGcmHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EncryptDecryptCycleWorks()
|
||||||
|
{
|
||||||
|
string input = "Hello, world!";
|
||||||
|
|
||||||
|
var encryptionKey = RandomNumberGenerator.GetBytes(32);
|
||||||
|
|
||||||
|
var cypher = AesGcmHelper.EncryptAesGcm(Encoding.UTF8.GetBytes(input), encryptionKey);
|
||||||
|
var output = AesGcmHelper.DecryptAesGcm(cypher, encryptionKey);
|
||||||
|
|
||||||
|
Assert.Equal(input, Encoding.UTF8.GetString(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
using System.Text;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Security;
|
|
||||||
|
|
||||||
public class ConfigurationSecretProviderTests
|
|
||||||
{
|
|
||||||
private static IConfiguration BuildConfigFromJson(string json)
|
|
||||||
{
|
|
||||||
// Convert the JSON string into a stream that the config builder can read.
|
|
||||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
|
||||||
using var stream = new MemoryStream(jsonBytes);
|
|
||||||
|
|
||||||
// Build the configuration just like the real app does, but from the stream.
|
|
||||||
var config = new ConfigurationBuilder()
|
|
||||||
.AddJsonStream(stream) // <-- reads from the in‑memory JSON
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Test()
|
|
||||||
{
|
|
||||||
string jsonConfig = """
|
|
||||||
{
|
|
||||||
"secrets": {
|
|
||||||
"master": [
|
|
||||||
{
|
|
||||||
"Id": "5676d159-5495-4945-aa84-59ee694aa8a2",
|
|
||||||
"Active": true,
|
|
||||||
"Algorithm": "AES",
|
|
||||||
"Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Id": "b82489e7-a05a-4d64-b9a5-58d2f2c0dc39",
|
|
||||||
"Active": false,
|
|
||||||
"Algorithm": "AES",
|
|
||||||
"Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
|
|
||||||
ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig));
|
|
||||||
|
|
||||||
// act
|
|
||||||
var keys = sut.GetKeys("master");
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal(2, keys.Length);
|
|
||||||
var active = keys.Single(k => k.Active);
|
|
||||||
Assert.Equal(new Guid("5676d159-5495-4945-aa84-59ee694aa8a2"), active.Id.Id);
|
|
||||||
Assert.Equal("AES", active.Algorithm);
|
|
||||||
Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key);
|
|
||||||
|
|
||||||
var inactive = keys.Single(k => !k.Active);
|
|
||||||
Assert.Equal(new Guid("b82489e7-a05a-4d64-b9a5-58d2f2c0dc39"), inactive.Id.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using IdentityShroud.Core.Services;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Security.Jwt;
|
|
||||||
|
|
||||||
public class RsaJwtSignerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test()
|
|
||||||
{
|
|
||||||
// ISecretProvider secretProvider = Substitute.For<ISecretProvider>();
|
|
||||||
// RealmSigningKey privateKey = new()
|
|
||||||
// {
|
|
||||||
// Id = default,
|
|
||||||
// KeyType = KeyType.RSA,
|
|
||||||
// Key = new EncryptedDek(KekId.NewId(), [1]),
|
|
||||||
// CreatedAt = default,
|
|
||||||
// RevokedAt = null,
|
|
||||||
// Priority = 0,
|
|
||||||
// PublicKeyParameters = null
|
|
||||||
// };
|
|
||||||
DecryptedSigningKey key = new();
|
|
||||||
byte[] jwt = [];
|
|
||||||
|
|
||||||
RsaJwtSigner provider = new();
|
|
||||||
provider.CalculateSignature(JwtSigAlgName.RS256, key, jwt);
|
|
||||||
//
|
|
||||||
// new DekEncryptionService(secretProvider), privateKey,
|
|
||||||
// JwtSigAlgName.RS256);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(1024)]
|
|
||||||
[InlineData(2048)]
|
|
||||||
[InlineData(4096)]
|
|
||||||
public void EstimateKeySizeTests(int keySizeBits)
|
|
||||||
{
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.KeySize = keySizeBits;
|
|
||||||
byte[] b = rsa.ExportPkcs8PrivateKey();
|
|
||||||
int estimate = DecryptedSigningKey.EstimatePkcs8ExportSize(keySizeBits);
|
|
||||||
Assert.True(b.Length < estimate - 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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;
|
|
||||||
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;
|
|
||||||
private readonly NullDataEncryptionService _dataEncryptionService = new();
|
|
||||||
|
|
||||||
private readonly IClock _clock = Substitute.For<IClock>();
|
|
||||||
private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001");
|
|
||||||
|
|
||||||
public ClientServiceTests(DbFixture dbFixture)
|
|
||||||
{
|
|
||||||
_dbFixture = dbFixture;
|
|
||||||
using Db db = dbFixture.CreateDbContext();
|
|
||||||
if (!db.Database.EnsureCreated())
|
|
||||||
TruncateTables(db);
|
|
||||||
EnsureRealm(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TruncateTables(Db db)
|
|
||||||
{
|
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE client CASCADE;");
|
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureRealm(Db db)
|
|
||||||
{
|
|
||||||
if (!db.Realms.Any(r => r.Id == _realmId))
|
|
||||||
{
|
|
||||||
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 withSecret)
|
|
||||||
{
|
|
||||||
// Setup
|
|
||||||
DateTime now = DateTime.UtcNow;
|
|
||||||
_clock.UtcNow().Returns(now);
|
|
||||||
|
|
||||||
Client val;
|
|
||||||
await using (var db = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
ClientService sut = CreateSut(db);
|
|
||||||
var response = await sut.Create(
|
|
||||||
_realmId,
|
|
||||||
ClientCreateRequestBuilder.Default() with
|
|
||||||
{
|
|
||||||
Confidential = withSecret,
|
|
||||||
GenerateSecret = withSecret,
|
|
||||||
},
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
val = ResultAssert.Success(response);
|
|
||||||
Assert.Equal(_realmId, val.RealmId);
|
|
||||||
Assert.Equal("test-client", val.ClientId);
|
|
||||||
Assert.Equal("Test Client", val.Name);
|
|
||||||
Assert.Equal("A test client", val.Description);
|
|
||||||
Assert.Equal(withSecret, val.Confidential);
|
|
||||||
Assert.Equal(now, val.CreatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using (var db = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
var dbRecord = await db.Clients
|
|
||||||
.Include(e => e.Secrets)
|
|
||||||
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
if (withSecret)
|
|
||||||
Assert.Single(dbRecord.Secrets);
|
|
||||||
else
|
|
||||||
Assert.Empty(dbRecord.Secrets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("existing-client", true)]
|
|
||||||
[InlineData("missing-client", false)]
|
|
||||||
public async Task GetByClientId(string clientId, bool shouldFind)
|
|
||||||
{
|
|
||||||
// Setup
|
|
||||||
_clock.UtcNow().Returns(DateTime.UtcNow);
|
|
||||||
await using (var setupContext = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
setupContext.Clients.Add(new()
|
|
||||||
{
|
|
||||||
RealmId = _realmId,
|
|
||||||
ClientId = "existing-client",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
|
|
||||||
await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var actContext = _dbFixture.CreateDbContext();
|
|
||||||
// Act
|
|
||||||
ClientService sut = CreateSut(actContext);
|
|
||||||
Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
if (shouldFind)
|
|
||||||
Assert.NotNull(result);
|
|
||||||
else
|
|
||||||
Assert.Null(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(true)]
|
|
||||||
[InlineData(false)]
|
|
||||||
public async Task FindById(bool shouldFind)
|
|
||||||
{
|
|
||||||
// Setup
|
|
||||||
_clock.UtcNow().Returns(DateTime.UtcNow);
|
|
||||||
int existingId;
|
|
||||||
await using (var setupContext = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
Client client = new()
|
|
||||||
{
|
|
||||||
RealmId = _realmId,
|
|
||||||
ClientId = "find-by-id-client",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
setupContext.Clients.Add(client);
|
|
||||||
await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
existingId = client.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
int searchId = shouldFind ? existingId : existingId + 9999;
|
|
||||||
|
|
||||||
await using var actContext = _dbFixture.CreateDbContext();
|
|
||||||
// Act
|
|
||||||
ClientService sut = CreateSut(actContext);
|
|
||||||
Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
if (shouldFind)
|
|
||||||
Assert.NotNull(result);
|
|
||||||
else
|
|
||||||
Assert.Null(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using IdentityShroud.Core.Services;
|
|
||||||
using IdentityShroud.TestUtils.Substitutes;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Services;
|
|
||||||
|
|
||||||
public class DataEncryptionServiceTests
|
|
||||||
{
|
|
||||||
// private readonly IRealmContext _realmContext = Substitute.For<IRealmContext>();
|
|
||||||
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For<IDekEncryptionService>();
|
|
||||||
|
|
||||||
private readonly DekId _activeDekId = DekId.NewId();
|
|
||||||
private readonly DekId _secondDekId = DekId.NewId();
|
|
||||||
private DataEncryptionService CreateSut()
|
|
||||||
=> new(_dekCryptor);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Encrypt_UsesActiveKey()
|
|
||||||
{
|
|
||||||
var dek = CreateRealmDek(_activeDekId, true);
|
|
||||||
|
|
||||||
var cipher = CreateSut().Encrypt(dek, "Hello"u8);
|
|
||||||
|
|
||||||
Assert.Equal(_activeDekId, cipher.DekId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Decrypt_UsesCorrectKey()
|
|
||||||
{
|
|
||||||
var first = CreateRealmDek(_activeDekId, true);
|
|
||||||
|
|
||||||
var sut = CreateSut();
|
|
||||||
var cipher = sut.Encrypt(first, "Hello"u8);
|
|
||||||
|
|
||||||
// Deactivate original key
|
|
||||||
first.Active = false;
|
|
||||||
// Make new active
|
|
||||||
var second = CreateRealmDek(_secondDekId, true);
|
|
||||||
// Return both
|
|
||||||
RealmDek[] list = [ first, second ];
|
|
||||||
|
|
||||||
var decoded = sut.Decrypt(list, cipher);
|
|
||||||
|
|
||||||
Assert.Equal("Hello"u8, decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
private RealmDek CreateRealmDek(DekId id, bool active)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Active = active,
|
|
||||||
Algorithm = KeyType.AES,
|
|
||||||
KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)),
|
|
||||||
RealmId = default,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Services;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Services;
|
|
||||||
|
|
||||||
public class DekEncryptionServiceTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void RoundtripWorks()
|
|
||||||
{
|
|
||||||
// Note this code will tend to only test the latest verion.
|
|
||||||
|
|
||||||
// setup
|
|
||||||
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
|
||||||
var secretProvider = Substitute.For<ISecretProvider>();
|
|
||||||
KeyEncryptionKey[] keys =
|
|
||||||
[
|
|
||||||
new KeyEncryptionKey(KekId.NewId(), true, "AES", keyValue)
|
|
||||||
];
|
|
||||||
secretProvider.GetKeys("master").Returns(keys);
|
|
||||||
|
|
||||||
|
|
||||||
ReadOnlySpan<byte> input = "Hello, World!"u8;
|
|
||||||
|
|
||||||
// act
|
|
||||||
DekEncryptionService sut = new(secretProvider);
|
|
||||||
|
|
||||||
EncryptedDek cipher = sut.Encrypt(input.ToArray());
|
|
||||||
int decryptedSize = sut.GetDecryptedSize(cipher);
|
|
||||||
Assert.Equal(input.Length, decryptedSize);
|
|
||||||
|
|
||||||
var result = new byte[decryptedSize];
|
|
||||||
sut.Decrypt(cipher, result);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal(input, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectsCorruptInput()
|
|
||||||
{
|
|
||||||
// When introducing a new version we need version specific tests to
|
|
||||||
// make sure decoding of legacy data still works.
|
|
||||||
KekId kid = KekId.NewId();
|
|
||||||
// setup
|
|
||||||
byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS
|
|
||||||
[
|
|
||||||
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
|
|
||||||
193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
|
|
||||||
];
|
|
||||||
EncryptedDek secret = new(kid, cipher);
|
|
||||||
|
|
||||||
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
|
||||||
var secretProvider = Substitute.For<ISecretProvider>();
|
|
||||||
KeyEncryptionKey[] keys =
|
|
||||||
[
|
|
||||||
new KeyEncryptionKey(kid, true, "AES", keyValue)
|
|
||||||
];
|
|
||||||
secretProvider.GetKeys("master").Returns(keys);
|
|
||||||
|
|
||||||
// act
|
|
||||||
DekEncryptionService sut = new(secretProvider);
|
|
||||||
int decryptedSize = sut.GetDecryptedSize(secret);
|
|
||||||
var result = new byte[decryptedSize];
|
|
||||||
Assert.Throws<InvalidOperationException>(
|
|
||||||
() => sut.Decrypt(secret, result),
|
|
||||||
ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DecodeSelectsRightKey()
|
|
||||||
{
|
|
||||||
// The key is marked inactive also it is the second key
|
|
||||||
|
|
||||||
// setup
|
|
||||||
KekId kid1 = KekId.NewId();
|
|
||||||
KekId kid2 = KekId.NewId();
|
|
||||||
|
|
||||||
byte[] cipher =
|
|
||||||
[
|
|
||||||
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
|
|
||||||
193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
|
|
||||||
];
|
|
||||||
EncryptedDek secret = new(kid1, cipher);
|
|
||||||
|
|
||||||
byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
|
||||||
byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw=");
|
|
||||||
var secretProvider = Substitute.For<ISecretProvider>();
|
|
||||||
KeyEncryptionKey[] keys =
|
|
||||||
[
|
|
||||||
new KeyEncryptionKey(kid2, true, "AES", keyValue2),
|
|
||||||
new KeyEncryptionKey(kid1, false, "AES", keyValue1),
|
|
||||||
];
|
|
||||||
secretProvider.GetKeys("master").Returns(keys);
|
|
||||||
|
|
||||||
// act
|
|
||||||
DekEncryptionService sut = new(secretProvider);
|
|
||||||
byte[] result = new byte[sut.GetDecryptedSize(secret)];
|
|
||||||
sut.Decrypt(secret, result);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal("Hello, World!"u8, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void EncryptionUsesActiveKey()
|
|
||||||
{
|
|
||||||
// setup
|
|
||||||
KekId kid1 = KekId.NewId();
|
|
||||||
KekId kid2 = KekId.NewId();
|
|
||||||
|
|
||||||
byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
|
||||||
byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw=");
|
|
||||||
var secretProvider = Substitute.For<ISecretProvider>();
|
|
||||||
KeyEncryptionKey[] keys =
|
|
||||||
[
|
|
||||||
new KeyEncryptionKey(kid1, false, "AES", keyValue1),
|
|
||||||
new KeyEncryptionKey(kid2, true, "AES", keyValue2),
|
|
||||||
];
|
|
||||||
secretProvider.GetKeys("master").Returns(keys);
|
|
||||||
|
|
||||||
ReadOnlySpan<byte> input = "Hello, World!"u8;
|
|
||||||
// act
|
|
||||||
DekEncryptionService sut = new(secretProvider);
|
|
||||||
EncryptedDek cipher = sut.Encrypt(input.ToArray());
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
Assert.Equal(kid2, cipher.KekId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs
Normal file
22
IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using IdentityShroud.Core.Services;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class EncryptionServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RoundtripWorks()
|
||||||
|
{
|
||||||
|
// setup
|
||||||
|
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
EncryptionService sut = new(key);
|
||||||
|
byte[] input = RandomNumberGenerator.GetBytes(16);
|
||||||
|
|
||||||
|
// act
|
||||||
|
var cipher = sut.Encrypt(input);
|
||||||
|
var result = sut.Decrypt(cipher);
|
||||||
|
|
||||||
|
Assert.Equal(input, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Services;
|
|
||||||
|
|
||||||
public class EncryptionTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void DecodeV1_Success()
|
|
||||||
{
|
|
||||||
// When introducing a new version we need version specific tests to
|
|
||||||
// make sure decoding of legacy data still works.
|
|
||||||
|
|
||||||
// setup
|
|
||||||
byte[] cipher =
|
|
||||||
[
|
|
||||||
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
|
|
||||||
193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
|
|
||||||
];
|
|
||||||
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
|
||||||
|
|
||||||
// act
|
|
||||||
byte[] result = new byte[Encryption.GetDecryptedLength(cipher)];
|
|
||||||
Encryption.Decrypt(cipher, keyValue, result);
|
|
||||||
|
|
||||||
// verify
|
|
||||||
Assert.Equal("Hello, World!"u8, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +1,53 @@
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.EFCore;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using IdentityShroud.Core.Services;
|
using IdentityShroud.Core.Services;
|
||||||
using IdentityShroud.Core.Tests.Fixtures;
|
using IdentityShroud.Core.Tests.Fixtures;
|
||||||
using IdentityShroud.TestUtils.Substitutes;
|
using IdentityShroud.Core.Tests.Substitutes;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Shouldly;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests.Services;
|
namespace IdentityShroud.Core.Tests.Services;
|
||||||
|
|
||||||
public class RealmServiceTests : IClassFixture<DbFixture>
|
public class RealmServiceTests : IClassFixture<DbFixture>
|
||||||
{
|
{
|
||||||
private readonly DbFixture _dbFixture;
|
private readonly Db _db;
|
||||||
private readonly IKeyService _keyService = Substitute.For<IKeyService>();
|
|
||||||
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
|
|
||||||
|
|
||||||
public RealmServiceTests(DbFixture dbFixture)
|
public RealmServiceTests(DbFixture dbFixture)
|
||||||
{
|
{
|
||||||
_dbFixture = dbFixture;
|
_db = dbFixture.CreateDbContext("realmservice");
|
||||||
using Db db = dbFixture.CreateDbContext();
|
|
||||||
if (!db.Database.EnsureCreated())
|
if (!_db.Database.EnsureCreated())
|
||||||
TruncateTables(db);
|
TruncateTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TruncateTables(Db db)
|
private void TruncateTables()
|
||||||
{
|
{
|
||||||
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
_db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
|
||||||
}
|
}
|
||||||
|
|
||||||
private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService());
|
|
||||||
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")]
|
[InlineData("a7c2a39c-3ed9-4790-826e-43bb2e5e480c")]
|
||||||
public async Task Create(string? idString)
|
public async Task Create(string? idString)
|
||||||
{
|
{
|
||||||
// Setup
|
|
||||||
Guid? realmId = null;
|
Guid? realmId = null;
|
||||||
if (idString is not null)
|
if (idString is not null)
|
||||||
realmId = new(idString);
|
realmId = new(idString);
|
||||||
|
|
||||||
Realm? val;
|
var encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
|
||||||
await using (var db = _dbFixture.CreateDbContext())
|
RealmService sut = new(_db, encryptionService);
|
||||||
{
|
|
||||||
_keyService.CreateKey(Arg.Any<KeyPolicy>())
|
|
||||||
.Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21])));
|
|
||||||
// Act
|
|
||||||
RealmService sut = CreateSut(db);
|
|
||||||
Result<Realm> response = await sut.Create(
|
|
||||||
new(realmId, "slug", "New realm"),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
var response = await sut.Create(
|
||||||
val = ResultAssert.Success(response);
|
new(realmId, "slug", "New realm"),
|
||||||
if (realmId.HasValue)
|
TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(realmId, val.Id);
|
|
||||||
else
|
|
||||||
Assert.NotEqual(Guid.Empty, val.Id);
|
|
||||||
|
|
||||||
Assert.Multiple(
|
RealmCreateResponse val = ResultAssert.Success(response);
|
||||||
() => val.Slug.ShouldBe("slug"),
|
if (realmId.HasValue)
|
||||||
() => val.Name.ShouldBe("New realm"),
|
Assert.Equal(realmId, val.Id);
|
||||||
() => val.DataEncryptionKeys.ShouldContain(d => d.Active),
|
|
||||||
() => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
|
|
||||||
);
|
|
||||||
|
|
||||||
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
|
|
||||||
}
|
|
||||||
|
|
||||||
await using (var db = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
var dbRecord = await db.Realms
|
|
||||||
.Include(e => e.TokenSigningKeys)
|
|
||||||
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("slug", null)]
|
|
||||||
[InlineData("foo", "Foo")]
|
|
||||||
public async Task FindBySlug(string slug, string? name)
|
|
||||||
{
|
|
||||||
await using (var setupContext = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
setupContext.Realms.Add(new()
|
|
||||||
{
|
|
||||||
Slug = "foo",
|
|
||||||
Name = "Foo",
|
|
||||||
});
|
|
||||||
setupContext.Realms.Add(new()
|
|
||||||
{
|
|
||||||
Slug = "bar",
|
|
||||||
Name = "Bar",
|
|
||||||
});
|
|
||||||
|
|
||||||
await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var actContext = _dbFixture.CreateDbContext();
|
|
||||||
// Act
|
|
||||||
RealmService sut = CreateSut(actContext);
|
|
||||||
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
Assert.Equal(name, result?.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("b0423bba-2411-497b-a5b6-c5adf404b862", true)]
|
|
||||||
[InlineData("65ac9dba-6d43-4fa4-b57f-133ed639fbcb", false)]
|
|
||||||
public async Task FindById(string idString, bool shouldFind)
|
|
||||||
{
|
|
||||||
Guid id = new(idString);
|
|
||||||
await using (var setupContext = _dbFixture.CreateDbContext())
|
|
||||||
{
|
|
||||||
setupContext.Realms.Add(new()
|
|
||||||
{
|
|
||||||
Id = new("b0423bba-2411-497b-a5b6-c5adf404b862"),
|
|
||||||
Slug = "foo",
|
|
||||||
Name = "Foo",
|
|
||||||
});
|
|
||||||
setupContext.Realms.Add(new()
|
|
||||||
{
|
|
||||||
Id = new("d4ffc7d0-7b2c-4f02-82b9-a74610435b0d"),
|
|
||||||
Slug = "bar",
|
|
||||||
Name = "Bar",
|
|
||||||
});
|
|
||||||
|
|
||||||
await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var actContext = _dbFixture.CreateDbContext();
|
|
||||||
// Act
|
|
||||||
RealmService sut = CreateSut(actContext);
|
|
||||||
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
if (shouldFind)
|
|
||||||
Assert.NotNull(result);
|
|
||||||
else
|
else
|
||||||
Assert.Null(result);
|
Assert.NotEqual(Guid.Empty, val.Id);
|
||||||
|
|
||||||
|
Assert.Equal("slug", val.Slug);
|
||||||
|
Assert.Equal("New realm", val.Name);
|
||||||
|
|
||||||
|
// TODO verify data has been stored!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
using IdentityShroud.Core.Contracts;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Tests.Substitutes;
|
||||||
|
|
||||||
|
public static class EncryptionServiceSubstitute
|
||||||
|
{
|
||||||
|
public static IEncryptionService CreatePassthrough()
|
||||||
|
{
|
||||||
|
var encryptionService = Substitute.For<IEncryptionService>();
|
||||||
|
encryptionService
|
||||||
|
.Encrypt(Arg.Any<byte[]>())
|
||||||
|
.Returns(x => x.ArgAt<byte[]>(0));
|
||||||
|
encryptionService
|
||||||
|
.Decrypt(Arg.Any<byte[]>())
|
||||||
|
.Returns(x => x.ArgAt<byte[]>(0));
|
||||||
|
return encryptionService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
using System.Buffers.Text;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using IdentityShroud.Core.DTO;
|
using IdentityShroud.Core.Messages;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Tests;
|
namespace IdentityShroud.Core.Tests;
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ public class UnitTest1
|
||||||
|
|
||||||
// Option 3: Generate a new key for testing
|
// Option 3: Generate a new key for testing
|
||||||
rsa.KeySize = 2048;
|
rsa.KeySize = 2048;
|
||||||
|
|
||||||
// Your already encoded header and payload
|
// Your already encoded header and payload
|
||||||
string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0";
|
string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0";
|
||||||
string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9";
|
string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9";
|
||||||
|
|
@ -49,15 +51,6 @@ public class UnitTest1
|
||||||
// Or generate complete JWT
|
// Or generate complete JWT
|
||||||
// string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa);
|
// string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa);
|
||||||
// Console.WriteLine($"Complete JWT: {completeJwt}");
|
// Console.WriteLine($"Complete JWT: {completeJwt}");
|
||||||
|
|
||||||
rsa.ExportRSAPublicKey(); // PKCS#1
|
|
||||||
}
|
|
||||||
|
|
||||||
using (ECDsa dsa = ECDsa.Create())
|
|
||||||
{
|
|
||||||
dsa.ExportPkcs8PrivateKey();
|
|
||||||
|
|
||||||
dsa.ExportSubjectPublicKeyInfo(); // x509
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,10 +66,10 @@ public static class JwtReader
|
||||||
return new JsonWebToken()
|
return new JsonWebToken()
|
||||||
{
|
{
|
||||||
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
|
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
|
||||||
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!,
|
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))),
|
||||||
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
|
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
|
||||||
Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!,
|
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))),
|
||||||
Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))),
|
Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,4 +95,13 @@ public static class RsaKeyLoader
|
||||||
return LoadFromPem(pemContent);
|
return LoadFromPem(pemContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load RSA private key from PKCS#8 format
|
||||||
|
/// </summary>
|
||||||
|
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
|
||||||
|
{
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
|
||||||
|
return rsa;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IClientService
|
|
||||||
{
|
|
||||||
Task<Result<Client>> Create(
|
|
||||||
Guid realmId,
|
|
||||||
ClientCreateRequest request,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<Client?> GetByClientId(Guid realmId, string clientId, CancellationToken ct = default);
|
|
||||||
Task<Client?> FindById(Guid realmId, int id, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IClock
|
|
||||||
{
|
|
||||||
DateTime UtcNow();
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
using System.Text;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IDataEncryptionService
|
|
||||||
{
|
|
||||||
EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain);
|
|
||||||
byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class DataEncryptionServiceExtensions
|
|
||||||
{
|
|
||||||
public static string DecryptUtf8ToString(
|
|
||||||
this IDataEncryptionService des,
|
|
||||||
IReadOnlyList<RealmDek> deks,
|
|
||||||
EncryptedValue input)
|
|
||||||
{
|
|
||||||
return Encoding.UTF8.GetString(des.Decrypt(deks, input));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public interface IDekEncryptionService
|
|
||||||
{
|
|
||||||
EncryptedDek Encrypt(ReadOnlySpan<byte> plain);
|
|
||||||
|
|
||||||
void Decrypt(EncryptedDek input, Span<byte> output);
|
|
||||||
int GetDecryptedSize(EncryptedDek input);
|
|
||||||
}
|
|
||||||
7
IdentityShroud.Core/Contracts/IEncryptionService.cs
Normal file
7
IdentityShroud.Core/Contracts/IEncryptionService.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace IdentityShroud.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IEncryptionService
|
||||||
|
{
|
||||||
|
byte[] Encrypt(byte[] plain);
|
||||||
|
byte[] Decrypt(byte[] cipher);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
|
|
||||||
|
|
||||||
public interface IKeyService
|
|
||||||
{
|
|
||||||
CreateKeyResponse CreateKey(KeyPolicy policy);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IRealmContext
|
|
||||||
{
|
|
||||||
public Realm GetRealm();
|
|
||||||
Task<IList<RealmDek>> GetDeks(CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
using IdentityShroud.Core.Messages.Realm;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IRealmService
|
|
||||||
{
|
|
||||||
Task<Realm?> FindById(Guid id, CancellationToken ct = default);
|
|
||||||
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default);
|
|
||||||
Task LoadActiveKeys(Realm realm);
|
|
||||||
Task LoadDeks(Realm realm);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Contracts;
|
namespace IdentityShroud.Core.Contracts;
|
||||||
|
|
||||||
public interface ISecretProvider
|
public interface ISecretProvider
|
||||||
{
|
{
|
||||||
string GetSecret(string name);
|
string GetSecretAsync(string name);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Should return one active key, might return inactive keys.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
KeyEncryptionKey[] GetKeys(string name);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.EFCore;
|
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using IdentityShroud.Core.Services;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public static class CoreServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddCore(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddScoped<Db>();
|
|
||||||
|
|
||||||
services.Scan(scan => scan
|
|
||||||
.FromAssemblyOf<RealmService>()
|
|
||||||
.AddClasses(classes => classes.AssignableTo<IJwtSigner>())
|
|
||||||
.AsImplementedInterfaces()
|
|
||||||
.WithSingletonLifetime());
|
|
||||||
services.AddSingleton<IJwtSignerFactory, JwtSignerFactory>();
|
|
||||||
|
|
||||||
services.AddSingleton<IClock, ClockService>();
|
|
||||||
services.AddSingleton<IDekEncryptionService, DekEncryptionService>();
|
|
||||||
services.AddScoped<IDataEncryptionService, DataEncryptionService>();
|
|
||||||
services.AddScoped<IRealmContext, RealmContext>();
|
|
||||||
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
|
|
||||||
services.AddScoped<IKeyService, KeyService>();
|
|
||||||
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
|
|
||||||
|
|
||||||
|
|
||||||
services.AddScoped<IClientService, ClientService>();
|
|
||||||
services.AddScoped<IRealmService, RealmService>();
|
|
||||||
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace IdentityShroud.Core.Contracts;
|
|
||||||
|
|
||||||
public record ClientCreateRequest(
|
|
||||||
string ClientId,
|
|
||||||
string? Name = null,
|
|
||||||
string? Description = null,
|
|
||||||
string? SignatureAlgorithm = null,
|
|
||||||
bool Confidential = false,
|
|
||||||
bool AllowClientCredentialsFlow = false,
|
|
||||||
bool GenerateSecret = false);
|
|
||||||
|
|
@ -1,50 +1,34 @@
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using IdentityShroud.Core.Helpers;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Messages;
|
namespace IdentityShroud.Core.Messages;
|
||||||
|
|
||||||
// https://www.rfc-editor.org/rfc/rfc7517.html
|
|
||||||
|
|
||||||
|
|
||||||
public class JsonWebKey
|
public class JsonWebKey
|
||||||
{
|
{
|
||||||
[JsonPropertyName("kty")]
|
[JsonPropertyName("kty")]
|
||||||
public required KeyType KeyType { get; set; }
|
public string KeyType { get; set; } = "RSA";
|
||||||
|
|
||||||
// Common values sig(nature) enc(ryption)
|
|
||||||
[JsonPropertyName("use")]
|
[JsonPropertyName("use")]
|
||||||
public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
|
public string Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
|
||||||
|
|
||||||
// Per standard this field is optional, commented out for now as it seems not
|
[JsonPropertyName("alg")]
|
||||||
// have any good use in an identity server. Anyone validating tokens should use
|
public string Algorithm { get; set; } = "RS256";
|
||||||
// the algorithm specified in the header of the token.
|
|
||||||
// [JsonPropertyName("alg")]
|
|
||||||
// public string? Algorithm { get; set; } = "RS256";
|
|
||||||
|
|
||||||
[JsonPropertyName("kid")]
|
[JsonPropertyName("kid")]
|
||||||
public required string KeyId { get; set; }
|
public string KeyId { get; set; }
|
||||||
|
|
||||||
// RSA Public Key Components
|
// RSA Public Key Components
|
||||||
[JsonPropertyName("n")]
|
[JsonPropertyName("n")]
|
||||||
public string? Modulus { get; set; }
|
public string Modulus { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("e")]
|
[JsonPropertyName("e")]
|
||||||
public string? Exponent { get; set; }
|
public string Exponent { get; set; }
|
||||||
|
|
||||||
// ECdsa
|
|
||||||
public string? Curve { get; set; }
|
|
||||||
[JsonConverter(typeof(Base64UrlConverter))]
|
|
||||||
public byte[]? X { get; set; }
|
|
||||||
[JsonConverter(typeof(Base64UrlConverter))]
|
|
||||||
public byte[]? Y { get; set; }
|
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
// [JsonPropertyName("x5c")]
|
[JsonPropertyName("x5c")]
|
||||||
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
// public List<string>? X509CertificateChain { get; set; }
|
public List<string> X509CertificateChain { get; set; }
|
||||||
//
|
|
||||||
// [JsonPropertyName("x5t")]
|
[JsonPropertyName("x5t")]
|
||||||
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
// public string? X509CertificateThumbprint { get; set; }
|
public string X509CertificateThumbprint { get; set; }
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace IdentityShroud.Core.DTO;
|
namespace IdentityShroud.Core.Messages;
|
||||||
|
|
||||||
public class JsonWebTokenHeader
|
public class JsonWebTokenHeader
|
||||||
{
|
{
|
||||||
|
|
@ -9,32 +9,31 @@ public class JsonWebTokenHeader
|
||||||
[JsonPropertyName("typ")]
|
[JsonPropertyName("typ")]
|
||||||
public string Type { get; set; } = "JWT";
|
public string Type { get; set; } = "JWT";
|
||||||
[JsonPropertyName("kid")]
|
[JsonPropertyName("kid")]
|
||||||
public required string KeyId { get; set; }
|
public string KeyId { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
public class JsonWebTokenPayload
|
public class JsonWebTokenPayload
|
||||||
{
|
{
|
||||||
[JsonPropertyName("iss")]
|
[JsonPropertyName("iss")]
|
||||||
public string? Issuer { get; set; }
|
public string Issuer { get; set; }
|
||||||
[JsonPropertyName("aud")]
|
[JsonPropertyName("aud")]
|
||||||
public string[]? Audience { get; set; }
|
public string[] Audience { get; set; }
|
||||||
[JsonPropertyName("sub")]
|
[JsonPropertyName("sub")]
|
||||||
public string? Subject { get; set; }
|
public string Subject { get; set; }
|
||||||
[JsonPropertyName("exp")]
|
[JsonPropertyName("exp")]
|
||||||
public long? Expires { get; set; }
|
public long Expires { get; set; }
|
||||||
[JsonPropertyName("iat")]
|
[JsonPropertyName("iat")]
|
||||||
public long? IssuedAt { get; set; }
|
public long IssuedAt { get; set; }
|
||||||
[JsonPropertyName("nbf")]
|
[JsonPropertyName("nbf")]
|
||||||
public long? NotBefore { get; set; }
|
public long NotBefore { get; set; }
|
||||||
[JsonPropertyName("jti")]
|
[JsonPropertyName("jti")]
|
||||||
public Guid? JwtId { get; set; }
|
public Guid JwtId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class JsonWebToken
|
public class JsonWebToken
|
||||||
{
|
{
|
||||||
public required JsonWebTokenHeader Header { get; set; }
|
public JsonWebTokenHeader Header { get; set; } = new();
|
||||||
public required JsonWebTokenPayload Payload { get; set; }
|
public JsonWebTokenPayload Payload { get; set; } = new();
|
||||||
public required byte[] Signature { get; set; } = [];
|
public byte[] Signature { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.DTO.OpenId;
|
|
||||||
|
|
||||||
public enum GrantTypes
|
|
||||||
{
|
|
||||||
[JsonStringEnumMemberName("client_credentials")]
|
|
||||||
ClientCredentials
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Services.OpenId;
|
|
||||||
|
|
||||||
public class TokenResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("access_token")]
|
|
||||||
public required string AccessToken { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("token_type")]
|
|
||||||
public required string TokenType { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("expires_in")]
|
|
||||||
public int? ExpiresIn { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("refresh_token")]
|
|
||||||
public string? RefreshToken { get; set; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
namespace IdentityShroud.Core.Messages.Realm;
|
namespace IdentityShroud.Core.Messages.Realm;
|
||||||
|
|
||||||
public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null);
|
public record RealmCreateRequest(Guid? Id, string? Slug, string Name);
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
using IdentityShroud.Core.Model;
|
using IdentityShroud.Core.Model;
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
namespace IdentityShroud.Core;
|
||||||
|
|
||||||
public class DbConfiguration
|
public class DbConfiguration
|
||||||
{
|
{
|
||||||
|
|
@ -18,10 +16,7 @@ public class Db(
|
||||||
ILoggerFactory? loggerFactory)
|
ILoggerFactory? loggerFactory)
|
||||||
: DbContext
|
: DbContext
|
||||||
{
|
{
|
||||||
public virtual DbSet<Client> Clients { get; set; }
|
|
||||||
public virtual DbSet<Realm> Realms { get; set; }
|
public virtual DbSet<Realm> Realms { get; set; }
|
||||||
public virtual DbSet<RealmSigningKey> Keys { get; set; }
|
|
||||||
public virtual DbSet<RealmDek> Deks { get; set; }
|
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
|
@ -38,22 +33,6 @@ public class Db(
|
||||||
{
|
{
|
||||||
optionsBuilder.UseLoggerFactory(loggerFactory);
|
optionsBuilder.UseLoggerFactory(loggerFactory);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(Db).Assembly);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConfigureConventions(ModelConfigurationBuilder b)
|
|
||||||
{
|
|
||||||
base.ConfigureConventions(b);
|
|
||||||
|
|
||||||
b.Properties<DekId>().HaveConversion<DekIdConverter>();
|
|
||||||
b.Properties<Dictionary<string, string>>().HaveConversion<DictionaryToJsonConverter<string, string>>();
|
|
||||||
b.Properties<JwtSigAlgName>().HaveConversion<JwtSigAlgNameConverter>();
|
|
||||||
b.Properties<KekId>().HaveConversion<KekIdConverter>();
|
|
||||||
b.Properties<KeyType>().HaveConversion<KeyTypeConverter>();
|
|
||||||
b.Properties<RealmSigningKeyId>().HaveConversion<RealmSigningKeyIdConverter>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class DekIdConverter() : ValueConverter<DekId, Guid>(id => id.Id, guid => new DekId(guid));
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class DictionaryToJsonConverter<TKey, TValue> : ValueConverter<Dictionary<TKey, TValue>, string>
|
|
||||||
where TKey : notnull
|
|
||||||
{
|
|
||||||
public DictionaryToJsonConverter() : base(
|
|
||||||
v => JsonSerializer.Serialize(v),
|
|
||||||
v => JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(v) ?? new())
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class JwtSigAlgNameConverter() : ValueConverter<JwtSigAlgName, string>(j => j.ToString(), s => new(s));
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class KekIdConverter : ValueConverter<KekId, Guid>
|
|
||||||
{
|
|
||||||
public KekIdConverter()
|
|
||||||
: base(id => id.Id, guid => new KekId(guid))
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class KeyTypeConverter() : ValueConverter<KeyType, string>(id => id.ToString(), s => new(s));
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.EFCore;
|
|
||||||
|
|
||||||
public class RealmSigningKeyIdConverter : ValueConverter<RealmSigningKeyId, Guid>
|
|
||||||
{
|
|
||||||
public RealmSigningKeyIdConverter()
|
|
||||||
: base(id => id.Id, guid => new RealmSigningKeyId(guid))
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
using System.Buffers;
|
|
||||||
using System.Buffers.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Helpers;
|
|
||||||
|
|
||||||
public class Base64UrlConverter : JsonConverter<byte[]>
|
|
||||||
{
|
|
||||||
public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
// GetValueSpan gives you the raw UTF-8 bytes of the JSON string value
|
|
||||||
if (reader.HasValueSequence)
|
|
||||||
{
|
|
||||||
var valueSequence = reader.ValueSequence.ToArray();
|
|
||||||
return Base64Url.DecodeFromUtf8(valueSequence);
|
|
||||||
}
|
|
||||||
return Base64Url.DecodeFromUtf8(reader.ValueSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
int encodedLength = Base64Url.GetEncodedLength(value.Length);
|
|
||||||
Span<byte> buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength];
|
|
||||||
Base64Url.EncodeToUtf8(value, buffer);
|
|
||||||
writer.WriteStringValue(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
@ -72,9 +73,9 @@ public static class SlugHelper
|
||||||
|
|
||||||
private static string GenerateHashSuffix(string text)
|
private static string GenerateHashSuffix(string text)
|
||||||
{
|
{
|
||||||
using (var md5 = MD5.Create())
|
using (var sha256 = SHA256.Create())
|
||||||
{
|
{
|
||||||
byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(text));
|
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(text));
|
||||||
|
|
||||||
// Take first 4 bytes (will become ~5-6 base64url chars)
|
// Take first 4 bytes (will become ~5-6 base64url chars)
|
||||||
string base64Url = WebEncoders.Base64UrlEncode(hash, 0, 4);
|
string base64Url = WebEncoders.Base64UrlEncode(hash, 0, 4);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|
@ -7,16 +7,12 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EFCore.NamingConventions" />
|
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageReference Include="LanguageExt.Core" />
|
<PackageReference Include="jose-jwt" Version="5.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
|
||||||
<PackageReference Include="Scrutor" />
|
|
||||||
<PackageReference Include="Shouldly" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -24,7 +20,9 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\IdentityShroud.PluginSupport\IdentityShroud.PluginSupport.csproj" />
|
<Reference Include="Microsoft.AspNetCore.WebUtilities">
|
||||||
|
<HintPath>..\..\..\.nuget\packages\microsoft.aspnetcore.webutilities\10.0.2\lib\net10.0\Microsoft.AspNetCore.WebUtilities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=efcore_005Cconverters/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
|
||||||
|
|
@ -1,37 +1,7 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
namespace IdentityShroud.Core.Model;
|
||||||
|
|
||||||
[Table("client")]
|
|
||||||
[Index(nameof(ClientId), IsUnique = true)]
|
|
||||||
public class Client
|
public class Client
|
||||||
{
|
{
|
||||||
[Key]
|
public Guid Id { get; set; }
|
||||||
public int Id { get; set; }
|
public string Name { get; set; }
|
||||||
public Guid RealmId { get; set; }
|
|
||||||
[MaxLength(40)]
|
|
||||||
public required string ClientId { get; set; }
|
|
||||||
[MaxLength(80)]
|
|
||||||
public string? Name { get; set; }
|
|
||||||
[MaxLength(2048)]
|
|
||||||
public string? Description { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(20)]
|
|
||||||
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; }
|
|
||||||
|
|
||||||
public List<ClientSecret> Secrets { get; set; } = [];
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
||||||
[Table("client_secret")]
|
|
||||||
public class ClientSecret
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public int Id { get; set; }
|
|
||||||
public Guid ClientId { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? Expires { get; set; }
|
|
||||||
public DateTime? RevokedAt { get; set; }
|
|
||||||
public required EncryptedValue Secret { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ClientSecretConfiguration : IEntityTypeConfiguration<ClientSecret>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<ClientSecret> b)
|
|
||||||
{
|
|
||||||
b.ToTable("client_secret");
|
|
||||||
b.HasKey(e => e.Id);
|
|
||||||
b.ComplexProperty(e => e.Secret);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using IdentityShroud.Core.Contracts;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
public sealed class DecryptedSigningKey : IDisposable
|
|
||||||
{
|
|
||||||
private readonly byte[] _keyData;
|
|
||||||
private readonly int _keyLength;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
public RealmSigningKeyId Id { get; }
|
|
||||||
public KeyType KeyType { get; }
|
|
||||||
public ReadOnlySpan<byte> KeyData => _disposed
|
|
||||||
? throw new ObjectDisposedException(nameof(DecryptedSigningKey))
|
|
||||||
: _keyData.AsSpan(0, _keyLength);
|
|
||||||
|
|
||||||
public DecryptedSigningKey(RealmSigningKey realmSigningKey, IDekEncryptionService encryptionService)
|
|
||||||
{
|
|
||||||
Id = realmSigningKey.Id;
|
|
||||||
KeyType = realmSigningKey.KeyType;
|
|
||||||
int keySize = encryptionService.GetDecryptedSize(realmSigningKey.Key);
|
|
||||||
_keyData = GC.AllocateArray<byte>(keySize, pinned: true);
|
|
||||||
_keyLength = keySize;
|
|
||||||
encryptionService.Decrypt(realmSigningKey.Key, _keyData);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DecryptedSigningKey()
|
|
||||||
{
|
|
||||||
Id = RealmSigningKeyId.NewId();
|
|
||||||
KeyType = KeyType.RSA;
|
|
||||||
const int keySize = 2048;
|
|
||||||
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.KeySize = keySize;
|
|
||||||
int estimatedSize = EstimatePkcs8ExportSize(keySize);
|
|
||||||
|
|
||||||
Span<byte> temp = stackalloc byte[estimatedSize * 2];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!rsa.TryExportPkcs8PrivateKey(temp, out int bytesWritten))
|
|
||||||
throw new CryptographicException("Unable to export RSA private key.");
|
|
||||||
|
|
||||||
_keyData = GC.AllocateArray<byte>(bytesWritten, pinned: true);
|
|
||||||
_keyLength = bytesWritten;
|
|
||||||
temp[..bytesWritten].CopyTo(_keyData);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(temp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
CryptographicOperations.ZeroMemory(_keyData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note actual accurate coefficients would be *0.566 and +57.4
|
|
||||||
public static int EstimatePkcs8ExportSize(int keySizeBits)
|
|
||||||
=> ((keySizeBits * 6) / 10) + 150;
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using IdentityShroud.Core.Contracts;
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
namespace IdentityShroud.Core.Model;
|
||||||
|
|
||||||
[Table("realm")]
|
[Table("realm")]
|
||||||
public class Realm
|
public class Realm
|
||||||
{
|
{
|
||||||
|
private byte[] _privateKeyDecrypted = [];
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Note this is part of the url we should encourage users to keep it short but we do not want to limit them too much
|
/// Note this is part of the url we should encourage users to keep it short but we do not want to limit them too much
|
||||||
|
|
@ -17,17 +20,26 @@ public class Realm
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public List<Client> Clients { get; init; } = [];
|
public List<Client> Clients { get; init; } = [];
|
||||||
|
|
||||||
|
public byte[] PrivateKeyEncrypted
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
field = value;
|
||||||
|
_privateKeyDecrypted = [];
|
||||||
|
}
|
||||||
|
} = [];
|
||||||
|
|
||||||
/// <summary>
|
public byte[] GetPrivateKey(IEncryptionService encryptionService)
|
||||||
/// 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.
|
if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0)
|
||||||
/// </summary>
|
_privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted);
|
||||||
public List<RealmSigningKey> TokenSigningKeys { get; init; } = [];
|
return _privateKeyDecrypted;
|
||||||
|
}
|
||||||
|
|
||||||
public List<RealmDek> DataEncryptionKeys { get; init; } = [];
|
public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey)
|
||||||
|
{
|
||||||
/// <summary>
|
PrivateKeyEncrypted = encryptionService.Encrypt(privateKey);
|
||||||
/// Can be overriden per client
|
_privateKeyDecrypted = privateKey;
|
||||||
/// </summary>
|
}
|
||||||
public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256;
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
|
|
||||||
public record RealmDek
|
|
||||||
{
|
|
||||||
public required DekId Id { get; init; }
|
|
||||||
public required bool Active { get; set; }
|
|
||||||
public required KeyType Algorithm { get; init; }
|
|
||||||
public required EncryptedDek KeyData { get; init; }
|
|
||||||
public Guid RealmId { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RealmDekConfiguration : IEntityTypeConfiguration<RealmDek>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<RealmDek> b)
|
|
||||||
{
|
|
||||||
b.ToTable("realm_dek");
|
|
||||||
b.HasKey(e => e.Id);
|
|
||||||
b.ComplexProperty(e => e.KeyData, e => e.IsRequired());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
using IdentityShroud.Core.Security;
|
|
||||||
using IdentityShroud.Core.Security.Keys;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
public record RealmSigningKey
|
|
||||||
{
|
|
||||||
public required RealmSigningKeyId Id { get; init; }
|
|
||||||
public required KeyType KeyType { get; init; }
|
|
||||||
public required EncryptedDek Key { get; init; }
|
|
||||||
public required DateTime CreatedAt { get; init; }
|
|
||||||
public DateTime? RevokedAt { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Key with highest priority will be used. While there is not really a use case for this I know some users
|
|
||||||
/// are more comfortable replacing keys by using priority then directly deactivating the old key.
|
|
||||||
/// </summary>
|
|
||||||
public int Priority { get; set; } = 10;
|
|
||||||
|
|
||||||
public Dictionary<string, string>? PublicKeyParameters { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmSigningKey>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<RealmSigningKey> b)
|
|
||||||
{
|
|
||||||
b.ToTable("realm_key");
|
|
||||||
b.HasKey(e => e.Id);
|
|
||||||
|
|
||||||
b.ComplexProperty(e => e.Key, e => e.IsRequired());
|
|
||||||
b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))]
|
|
||||||
public readonly record struct RealmSigningKeyId(Guid Id)
|
|
||||||
{
|
|
||||||
public override string ToString() => Id.ToString("N");
|
|
||||||
|
|
||||||
public static RealmSigningKeyId NewId()
|
|
||||||
{
|
|
||||||
return new(Guid.NewGuid());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RealmSigningKeyIdJsonConverter : JsonConverter<RealmSigningKeyId>
|
|
||||||
{
|
|
||||||
public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
||||||
=> new (reader.GetGuid());
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options)
|
|
||||||
=> writer.WriteStringValue(value.ToString());
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.Loader;
|
|
||||||
using IdentityShroud.PluginSupport;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Plugins;
|
|
||||||
|
|
||||||
public static class PluginLoader
|
|
||||||
{
|
|
||||||
public static IEnumerable<IPlugin> LoadPlugins(string pluginsFolder)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(pluginsFolder))
|
|
||||||
yield break;
|
|
||||||
|
|
||||||
foreach (var dll in Directory.EnumerateFiles(pluginsFolder, "*.dll"))
|
|
||||||
{
|
|
||||||
foreach (var plugin in LoadPluginDll(dll)) yield return plugin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<IPlugin> LoadPluginDll(string dll)
|
|
||||||
{
|
|
||||||
Assembly asm;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(dll));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
IEnumerable<Type> pluginTypes;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
pluginTypes = asm.GetTypes()
|
|
||||||
.Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false });
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var t in pluginTypes)
|
|
||||||
{
|
|
||||||
IPlugin? instance = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
instance = (IPlugin?)Activator.CreateInstance(t);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore bad plugin types
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance != null)
|
|
||||||
yield return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using IdentityShroud.PluginSupport;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Plugins;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Note
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TPlugin"></typeparam>
|
|
||||||
public class PluginRegistry<TPlugin> where TPlugin : IPlugin
|
|
||||||
{
|
|
||||||
private ReadOnlyDictionary<string, TPlugin> _plugins;
|
|
||||||
|
|
||||||
public PluginRegistry(ReadOnlyDictionary<string, TPlugin> plugins)
|
|
||||||
{
|
|
||||||
_plugins = plugins;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
64
IdentityShroud.Core/Security/AesGcmHelper.cs
Normal file
64
IdentityShroud.Core/Security/AesGcmHelper.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core.Security;
|
||||||
|
|
||||||
|
public static class AesGcmHelper
|
||||||
|
{
|
||||||
|
|
||||||
|
public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key)
|
||||||
|
{
|
||||||
|
using var aes = new AesGcm(key);
|
||||||
|
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
|
||||||
|
byte[] ciphertext = new byte[plaintext.Length];
|
||||||
|
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
|
||||||
|
|
||||||
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||||
|
// Return concatenated nonce|ciphertext|tag (or store separately)
|
||||||
|
return nonce.Concat(ciphertext).Concat(tag).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// DecryptAesGcm
|
||||||
|
// • key – 32‑byte (256‑bit) secret key (same key used for encryption)
|
||||||
|
// • payload – byte[] containing nonce‖ciphertext‖tag
|
||||||
|
// • returns – the original plaintext bytes
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
public static byte[] DecryptAesGcm(byte[] payload, byte[] key)
|
||||||
|
{
|
||||||
|
if (payload == null) throw new ArgumentNullException(nameof(payload));
|
||||||
|
if (key == null) throw new ArgumentNullException(nameof(key));
|
||||||
|
if (key.Length != 32) // 256‑bit key
|
||||||
|
throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 1️⃣ Extract the three components.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length)
|
||||||
|
// AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag)
|
||||||
|
int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12
|
||||||
|
int tagSize = AesGcm.TagByteSizes.MaxSize; // 16
|
||||||
|
|
||||||
|
if (payload.Length < nonceSize + tagSize)
|
||||||
|
throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload));
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> nonce = new(payload, 0, nonceSize);
|
||||||
|
ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
|
||||||
|
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
|
||||||
|
|
||||||
|
|
||||||
|
byte[] plaintext = new byte[ciphertext.Length];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(key);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
// Tag verification failed → tampering or wrong key/nonce.
|
||||||
|
throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,13 +10,8 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret
|
||||||
{
|
{
|
||||||
private readonly IConfigurationSection secrets = configuration.GetSection("secrets");
|
private readonly IConfigurationSection secrets = configuration.GetSection("secrets");
|
||||||
|
|
||||||
public string GetSecret(string name)
|
public string GetSecretAsync(string name)
|
||||||
{
|
{
|
||||||
return secrets.GetValue<string>(name) ?? "";
|
return secrets.GetValue<string>(name) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public KeyEncryptionKey[] GetKeys(string name)
|
|
||||||
{
|
|
||||||
return secrets.GetSection(name).Get<KeyEncryptionKey[]>() ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
public readonly record struct DekId(Guid Id)
|
|
||||||
{
|
|
||||||
public static DekId NewId() => new(Guid.NewGuid());
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString("N");
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
namespace IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
public record EncryptedDek(KekId KekId, byte[] Value);
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
namespace IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
public record EncryptedValue(DekId DekId, byte[] Value);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core.Security;
|
|
||||||
|
|
||||||
public static class Encryption
|
|
||||||
{
|
|
||||||
private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize);
|
|
||||||
|
|
||||||
private static AlgVersion[] _versions =
|
|
||||||
[
|
|
||||||
new(0, 0, 0), // version 0 does not realy exist
|
|
||||||
new(1, 12, 16), // version 1
|
|
||||||
];
|
|
||||||
|
|
||||||
public static byte[] Encrypt(ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> key)
|
|
||||||
{
|
|
||||||
const int versionNumber = 1;
|
|
||||||
AlgVersion versionParams = _versions[versionNumber];
|
|
||||||
|
|
||||||
int resultSize = 1 + versionParams.NonceSize + versionParams.TagSize + plaintext.Length;
|
|
||||||
// allocate buffer for complete response
|
|
||||||
var result = new byte[resultSize];
|
|
||||||
|
|
||||||
result[0] = (byte)versionParams.Version;
|
|
||||||
|
|
||||||
// make the spans that point to the parts of the result where their data is located
|
|
||||||
var nonce = result.AsSpan(1, versionParams.NonceSize);
|
|
||||||
var tag = result.AsSpan(1 + versionParams.NonceSize, versionParams.TagSize);
|
|
||||||
var cipher = result.AsSpan(1 + versionParams.NonceSize + versionParams.TagSize);
|
|
||||||
|
|
||||||
// use the spans to place the data directly in its place
|
|
||||||
RandomNumberGenerator.Fill(nonce);
|
|
||||||
using var aes = new AesGcm(key, versionParams.TagSize);
|
|
||||||
aes.Encrypt(nonce, plaintext, cipher, tag);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Decrypt(ReadOnlyMemory<byte> input, ReadOnlySpan<byte> key, Span<byte> output)
|
|
||||||
{
|
|
||||||
AlgVersion versionParams = GetVersionParams(input);
|
|
||||||
if (input.Length < 1 + versionParams.NonceSize + versionParams.TagSize)
|
|
||||||
throw new ArgumentException("Cypher data is too short to be valid.", nameof(input));
|
|
||||||
|
|
||||||
var payload = input.Span;
|
|
||||||
ReadOnlySpan<byte> nonce = payload.Slice(1, versionParams.NonceSize);
|
|
||||||
ReadOnlySpan<byte> tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize);
|
|
||||||
ReadOnlySpan<byte> cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize);
|
|
||||||
|
|
||||||
using var aes = new AesGcm(key, versionParams.TagSize);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
aes.Decrypt(nonce, cipher, tag, output);
|
|
||||||
}
|
|
||||||
catch (CryptographicException ex)
|
|
||||||
{
|
|
||||||
// Tag verification failed → tampering or wrong key/nonce.
|
|
||||||
throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int GetDecryptedLength(ReadOnlyMemory<byte> input)
|
|
||||||
{
|
|
||||||
AlgVersion versionParams = GetVersionParams(input);
|
|
||||||
int length = input.Length - (1 + versionParams.NonceSize + versionParams.TagSize);
|
|
||||||
if (length < 0)
|
|
||||||
throw new ArgumentException("Cypher data is too short to be valid.", nameof(input));
|
|
||||||
|
|
||||||
return length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AlgVersion GetVersionParams(ReadOnlyMemory<byte> input)
|
|
||||||
{
|
|
||||||
var versionNumber = (int)input.Span[0];
|
|
||||||
if (versionNumber != 1)
|
|
||||||
throw new ArgumentException("Invalid payload");
|
|
||||||
|
|
||||||
return _versions[versionNumber];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
using System.Text.Json;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public interface IJwtSigner
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
Of the signature and MAC algorithms specified in JSON Web Algorithms
|
|
||||||
[JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by
|
|
||||||
conforming JWT implementations. It is RECOMMENDED that
|
|
||||||
implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash
|
|
||||||
algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256
|
|
||||||
hash algorithm ("ES256"). Support for other algorithms and key sizes
|
|
||||||
is OPTIONAL.
|
|
||||||
*/
|
|
||||||
IReadOnlyList<JwtSigAlgName> Algorithms { get; }
|
|
||||||
|
|
||||||
byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan<byte> jwt);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public interface IJwtSignerFactory
|
|
||||||
{
|
|
||||||
IJwtSigner Create(JwtSigAlgName algorithm);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
|
||||||
public readonly record struct JwtSigAlgName(string Name) : IEquatable<JwtSigAlgName>
|
|
||||||
{
|
|
||||||
// HMAC using SHA-???
|
|
||||||
public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED
|
|
||||||
public static JwtSigAlgName HS384 => new("HS384");
|
|
||||||
public static JwtSigAlgName HS512 => new("HS512");
|
|
||||||
|
|
||||||
// RSASSA-PKCS1-v1_5 using SHA-???
|
|
||||||
public static JwtSigAlgName RS256 => new("RS256");
|
|
||||||
public static JwtSigAlgName RS384 => new("RS384");
|
|
||||||
public static JwtSigAlgName RS512 => new("RS512");
|
|
||||||
|
|
||||||
public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256
|
|
||||||
public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384
|
|
||||||
public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512
|
|
||||||
|
|
||||||
public override string ToString() => Name;
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
using System.Buffers.Text;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public static class JwtSignatureGenerator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a JWT signature using RS256 algorithm
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="headerBase64Url">Base64Url encoded header</param>
|
|
||||||
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
|
|
||||||
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
|
|
||||||
/// <returns>Base64Url encoded signature</returns>
|
|
||||||
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
|
||||||
{
|
|
||||||
// Combine header and payload with a period
|
|
||||||
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
|
|
||||||
|
|
||||||
// Convert to bytes
|
|
||||||
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
|
|
||||||
|
|
||||||
// Sign the data using RSA-SHA256
|
|
||||||
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
|
|
||||||
// Convert signature to Base64Url encoding
|
|
||||||
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
|
|
||||||
|
|
||||||
return signature;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
|
|
||||||
{
|
|
||||||
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
|
|
||||||
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public class JwtService(IJwtSignerFactory signerFactory)
|
|
||||||
{
|
|
||||||
|
|
||||||
public byte[] CreateEncodedJwt(ReadOnlySpan<byte> payloadUtf8, JwtSigAlgName algName, DecryptedSigningKey key)
|
|
||||||
{
|
|
||||||
// LATER might be able to improve performance using ArrayPool
|
|
||||||
|
|
||||||
IJwtSigner signer = signerFactory.Create(algName);
|
|
||||||
MemoryStream headerMemStream = new();
|
|
||||||
Utf8JsonWriter headerWriter = new(headerMemStream);
|
|
||||||
WriteJwtHeader(headerWriter, algName, key.Id.ToString());
|
|
||||||
headerWriter.Flush();
|
|
||||||
headerMemStream.Seek(0, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
int headerBase64Length = Base64Url.GetEncodedLength((int)headerMemStream.Length);
|
|
||||||
int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length);
|
|
||||||
var jwtData = new byte[headerBase64Length + payloadBase64Length + 1];
|
|
||||||
|
|
||||||
//
|
|
||||||
var byteArray = new byte[headerMemStream.Length];
|
|
||||||
headerMemStream.ReadExactly(byteArray, 0, (int)headerMemStream.Length);
|
|
||||||
int written = Base64Url.EncodeToUtf8(byteArray, jwtData);
|
|
||||||
|
|
||||||
if (written != headerBase64Length)
|
|
||||||
throw new Exception("expected header length did not match bytes written");
|
|
||||||
|
|
||||||
jwtData[headerBase64Length] = (byte)'.';
|
|
||||||
|
|
||||||
written = Base64Url.EncodeToUtf8(payloadUtf8, jwtData.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length));
|
|
||||||
|
|
||||||
if (written != payloadBase64Length)
|
|
||||||
throw new Exception("expected payload length did not match bytes written");
|
|
||||||
|
|
||||||
byte[] signature = signer.CalculateSignature(algName, key, jwtData.AsSpan());
|
|
||||||
|
|
||||||
int signatureBase64Length = Base64Url.GetEncodedLength(signature.Length);
|
|
||||||
|
|
||||||
var completeJwt = new byte[jwtData.Length + 1 + signatureBase64Length];
|
|
||||||
Array.Copy(jwtData, completeJwt, jwtData.Length);
|
|
||||||
completeJwt[jwtData.Length] = (byte)'.';
|
|
||||||
|
|
||||||
written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan().Slice(jwtData.Length + 1, signatureBase64Length));
|
|
||||||
|
|
||||||
if (written != signatureBase64Length)
|
|
||||||
throw new Exception("expected signature length did not match bytes written");
|
|
||||||
|
|
||||||
return completeJwt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteJwtHeader(Utf8JsonWriter writer, JwtSigAlgName algName, string keyId)
|
|
||||||
{
|
|
||||||
writer.WriteStartObject();
|
|
||||||
writer.WriteString("typ"u8, "JWT"u8);
|
|
||||||
writer.WriteString("alg"u8, algName.ToString());
|
|
||||||
writer.WriteString("kid"u8, keyId);
|
|
||||||
writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public class JwtSignerFactory(IEnumerable<IJwtSigner> signers) : IJwtSignerFactory
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyDictionary<JwtSigAlgName, IJwtSigner> _signers = signers
|
|
||||||
.SelectMany(s => s.Algorithms.Select(alg => (alg, signer: s)))
|
|
||||||
.ToDictionary(x => x.alg, x => x.signer);
|
|
||||||
|
|
||||||
public IJwtSigner Create(JwtSigAlgName algorithm)
|
|
||||||
{
|
|
||||||
if (_signers.TryGetValue(algorithm, out var signer))
|
|
||||||
return signer;
|
|
||||||
|
|
||||||
throw new NotSupportedException($"JWT signing algorithm '{algorithm}' is not registered.");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using IdentityShroud.Core.Model;
|
|
||||||
|
|
||||||
namespace IdentityShroud.Core;
|
|
||||||
|
|
||||||
public class RsaJwtSigner : IJwtSigner
|
|
||||||
{
|
|
||||||
public IReadOnlyList<JwtSigAlgName> Algorithms => [JwtSigAlgName.RS256, JwtSigAlgName.RS384, JwtSigAlgName.RS512];
|
|
||||||
|
|
||||||
// +-------------------+---------------------------------+
|
|
||||||
// | "alg" Param Value | Digital Signature Algorithm |
|
|
||||||
// +-------------------+---------------------------------+
|
|
||||||
// | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 |
|
|
||||||
// | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 |
|
|
||||||
// | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 |
|
|
||||||
// +-------------------+---------------------------------+
|
|
||||||
|
|
||||||
public byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan<byte> jwt)
|
|
||||||
{
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportPkcs8PrivateKey(key.KeyData, out int _);
|
|
||||||
var sig = new byte[rsa.KeySize / 8];
|
|
||||||
rsa.SignData(jwt, sig, GetHashAlgorithmName(algName), RSASignaturePadding.Pkcs1);
|
|
||||||
return sig;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HashAlgorithmName GetHashAlgorithmName(JwtSigAlgName algName)
|
|
||||||
=> algName.Name switch
|
|
||||||
{
|
|
||||||
"RS256" => HashAlgorithmName.SHA256,
|
|
||||||
"RS384" => HashAlgorithmName.SHA384,
|
|
||||||
"RS512" => HashAlgorithmName.SHA512,
|
|
||||||
_ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider")
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
38
IdentityShroud.Core/Security/JwtSignatureGenerator.cs
Normal file
38
IdentityShroud.Core/Security/JwtSignatureGenerator.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace IdentityShroud.Core;
|
||||||
|
|
||||||
|
public 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue