Compare commits

..

3 commits

136 changed files with 724 additions and 3693 deletions

View file

@ -1,2 +0,0 @@
[*.cs]
resharper_naming_rules.abbreviations = QL, DB

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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; }
}
}

View file

@ -1,13 +1,14 @@
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 System.Security.Cryptography;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using IdentityShroud.Core.EFCore; using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts; using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -113,7 +114,7 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
{ {
// act // act
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync($"/realms/{slug}/.well-known/openid-configuration", var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
// verify // verify
@ -123,16 +124,26 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
[Fact] [Fact]
public async Task GetJwks() public async Task GetJwks()
{ {
var client = _factory.CreateClient(); // setup
var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new IEncryptionService encryptionService = _factory.Services.GetRequiredService<IEncryptionService>();
using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new(
Guid.NewGuid(),
"RSA",
encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
DateTime.UtcNow);
await ScopedContextAsync(async db =>
{ {
Slug = "foo", db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
Name = "Test'", await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}), });
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
// act // act
var client = _factory.CreateClient();
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -140,16 +151,9 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken); JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload); Assert.NotNull(payload);
string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString(); JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
Assert.NotNull(kid); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
Assert.True(kid.Length >= 16); JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
//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( private async Task ScopedContextAsync(
@ -160,22 +164,4 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
var db = scope.ServiceProvider.GetRequiredService<Db>(); var db = scope.ServiceProvider.GetRequiredService<Db>();
await action(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);
}
} }

View file

@ -1,7 +1,11 @@
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.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestPlatform.TestHost;
using Npgsql;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
namespace IdentityShroud.Core.Tests.Fixtures; namespace IdentityShroud.Core.Tests.Fixtures;
@ -29,10 +33,7 @@ public class ApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
new Dictionary<string, string?> new Dictionary<string, string?>
{ {
["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(),
["secrets:master:0:Id"] = "94970f27-3d88-4223-9940-7dd57548f5b5", ["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
["secrets:master:0:Active"] = "true",
["secrets:master:0:Algorithm"] = "AES",
["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
}); });
}); });

View file

@ -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);
}
}

View file

@ -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,20 +8,20 @@
</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>

View file

@ -0,0 +1,17 @@
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Tests.Mappers;
// public class KeyMapperTests
// {
// private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
//
// [Fact]
// public void Test()
// {
// }
// }

View file

@ -0,0 +1,43 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.TestUtils.Substitutes;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyServiceTests
{
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
//private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For<IKeyProviderFactory>();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new(
new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
"RSA",
rsa.ExportPkcs8PrivateKey(),
DateTime.UtcNow)
{
Priority = 10,
};
// Act
KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService());
var jwk = sut.CreateJsonWebKey(realmKey);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
}
}

View file

@ -1,11 +1,15 @@
using FluentResults; using FluentResults;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; 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.Apis; namespace IdentityShroud.Api;
public record ClientCreateReponse(int Id, string ClientId);
/// <summary> /// <summary>
/// The part of the api below realms/{slug}/clients /// The part of the api below realms/{slug}/clients
@ -19,9 +23,9 @@ public static class ClientApi
RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate) clientsGroup.MapPost("", ClientCreate)
.Produces(StatusCodes.Status201Created)
.Validate<ClientCreateRequest>() .Validate<ClientCreateRequest>()
.WithName("ClientCreate"); .WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
var clientIdGroup = clientsGroup.MapGroup("{clientId}") var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>(); .AddEndpointFilter<ClientIdValidationFilter>();
@ -30,21 +34,15 @@ public static class ClientApi
.WithName(ClientGetRouteName); .WithName(ClientGetRouteName);
} }
private static Ok<ClientRepresentation> ClientGet( private static Task ClientGet(HttpContext context)
Guid realmId,
int clientId,
HttpContext context)
{ {
Client client = (Client)context.Items["ClientEntity"]!; throw new NotImplementedException();
return TypedResults.Ok(new ClientMapper().ToDto(client));
} }
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>> private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
ClientCreate( ClientCreate(
Guid realmId,
ClientCreateRequest request, ClientCreateRequest request,
[FromServices] IClientService service, [FromServices] IClientService service,
[FromServices] IDataEncryptionService cryptor,
HttpContext context, HttpContext context,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -57,41 +55,15 @@ public static class ClientApi
} }
Client client = result.Value; 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( return TypedResults.CreatedAtRoute(
clientRepresentation, new ClientCreateReponse(client.Id, client.ClientId),
ClientGetRouteName, ClientGetRouteName,
new RouteValueDictionary() new RouteValueDictionary()
{ {
["realmId"] = realm.Id, ["realmId"] = realm.Id,
["clientId"] = client.Id, ["clientId"] = client.Id,
}); });
} throw new NotImplementedException();
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;
} }
} }

View file

@ -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; }
}

View file

@ -1,3 +0,0 @@
namespace IdentityShroud.Api.Apis;
public record ErrorDto(string Error);

View file

@ -1,6 +0,0 @@
namespace IdentityShroud.Api.Apis;
public record RealmRepresentation(
Guid Id,
string Slug,
string Name);

View file

@ -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; }
}

View file

@ -2,9 +2,8 @@ namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions public static class EndpointRouteBuilderExtensions
{ {
public static IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder) public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
where TDto : class => builder.AddEndpointFilter<ValidateFilter<TDto>>();
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
public static void MapApis(this IEndpointRouteBuilder erp) public static void MapApis(this IEndpointRouteBuilder erp)
{ {

View file

@ -7,9 +7,8 @@ public class ClientIdValidationFilter(IClientService clientService) : IEndpointF
{ {
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{ {
Guid realmId = context.Arguments.OfType<Guid>().First();
int id = context.Arguments.OfType<int>().First(); int id = context.Arguments.OfType<int>().First();
Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted); Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted);
if (client is null) if (client is null)
{ {
return Results.NotFound(); return Results.NotFound();

View file

@ -1,5 +1,6 @@
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Api; namespace IdentityShroud.Api;

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -1,28 +1,23 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Mappers; namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IKeyProviderFactory keyProviderFactory) public class KeyMapper(IKeyService keyService)
{ {
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmSigningKey> keys) public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
{ {
JsonWebKeySet wks = new(); JsonWebKeySet wks = new();
foreach (var k in keys) foreach (var k in keys)
{ {
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType); var wk = keyService.CreateJsonWebKey(k);
if (provider.IsPublic) if (wk is {})
{ {
JsonWebKey jwk = new() wks.Keys.Add(wk);
{
KeyId = k.Id.ToString(),
KeyType = k.KeyType,
Use = "sig",
};
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
wks.Keys.Add(jwk);
} }
} }
return wks; return wks;

View file

@ -1,11 +1,7 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Api.Apis.ISResults;
using IdentityShroud.Api.Helpers;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services.OpenId;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -15,6 +11,8 @@ public static class OpenIdEndpoints
{ {
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration // openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
public static void MapEndpoints(this IEndpointRouteBuilder erp) public static void MapEndpoints(this IEndpointRouteBuilder erp)
{ {
var realmsGroup = erp.MapGroup("/auth/realms"); var realmsGroup = erp.MapGroup("/auth/realms");
@ -47,7 +45,7 @@ public static class OpenIdEndpoints
TokenEndpoint = baseUri + "/openid-connect/token", TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri, Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks", JwksUri = baseUri + "/openid-connect/jwks",
}); }, AppJsonSerializerContext.Default.OpenIdConfiguration);
} }
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks( private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
@ -58,79 +56,17 @@ public static class OpenIdEndpoints
{ {
Realm realm = context.GetValidatedRealm(); Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm); await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys)); return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
} }
private static async Task<Results< private static Task OpenIdConnectToken(HttpContext context)
Ok<TokenResponse>,
BadRequest<ErrorDto>,
ISUnauthorizedHttpResult
>> OpenIdConnectToken(
string realmSlug,
[FromServices] IClientService clientService,
HttpContext context,
CancellationToken ct)
{ {
IFormCollection form = await context.Request.ReadFormAsync(); throw new NotImplementedException();
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) private static Task OpenIdConnectAuth(HttpContext context)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }

View file

@ -1,7 +1,7 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; 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;
@ -19,56 +19,31 @@ public static class HttpContextExtensions
public static class RealmApi public static class RealmApi
{ {
public const string GetRealmRoute = "Get Realm";
public const string CreateRealmRoute = "Create Realm";
public static void MapRealmEndpoints(IEndpointRouteBuilder erp) public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
{ {
var realmsGroup = erp.MapGroup("/api/v1/realms"); var realmsGroup = erp.MapGroup("/api/v1/realms");
realmsGroup.MapPost("", RealmCreate) realmsGroup.MapPost("", RealmCreate)
.Produces(StatusCodes.Status201Created)
.Validate<RealmCreateRequest>() .Validate<RealmCreateRequest>()
.WithName(CreateRealmRoute); .WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
var realmIdGroup = realmsGroup.MapGroup("{realmId}") var realmIdGroup = realmsGroup.MapGroup("{realmId}")
.AddEndpointFilter<RealmIdValidationFilter>(); .AddEndpointFilter<RealmIdValidationFilter>();
realmIdGroup.MapGet("", RealmGet)
.WithName(GetRealmRoute);
ClientApi.MapEndpoints(realmIdGroup); 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)
=> new(realm.Id, realm.Slug, realm.Name);
} }

View file

@ -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);
}
}

View file

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm;
using Microsoft.Extensions.Diagnostics.HealthChecks;
[JsonSerializable(typeof(OpenIdConfiguration))]
[JsonSerializable(typeof(RealmCreateRequest))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

View file

@ -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;
}
}

View file

@ -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>

View file

@ -1,74 +1,70 @@
using FluentValidation; using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Mappers; using IdentityShroud.Api.Mappers;
using IdentityShroud.Core; using IdentityShroud.Core;
using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Contracts;
using IdentityShroud.GraphQL; using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
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()
namespace IdentityShroud.Api;
public class Program
{
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter()) .WriteTo.Console(new JsonFormatter())
.CreateLogger(); .CreateLogger();
var applicationBuilder = WebApplication.CreateSlimBuilder(args); var applicationBuilder = WebApplication.CreateSlimBuilder(args);
ConfigureBuilder(applicationBuilder); ConfigureBuilder(applicationBuilder);
var application = applicationBuilder.Build(); var application = applicationBuilder.Build();
ConfigureApplication(application); ConfigureApplication(application);
application.Run(); application.Run();
}
private static void ConfigureBuilder(WebApplicationBuilder builder) void ConfigureBuilder(WebApplicationBuilder builder)
{ {
var services = builder.Services; var services = builder.Services;
var configuration = builder.Configuration; var configuration = builder.Configuration;
//services.AddControllers();
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
services.AddOpenApi();
services.AddScoped<Db>();
services.AddScoped<IClientService, ClientService>();
services.AddSingleton<IClock, ClockService>();
services.AddSingleton<IEncryptionService, EncryptionService>();
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
services.AddScoped<IKeyService, KeyService>();
services.AddScoped<IRealmService, RealmService>();
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db")); services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
// services.ConfigureHttpJsonOptions(options =>
// {
// options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default);
// });
services.AddScoped<KeyMapper>(); services.AddScoped<KeyMapper>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>(); services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
services.AddHttpContextAccessor();
services.AddOpenApi();
services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails();
services
.AddCore()
.AddIdentityShroudGraphQL();
builder.Host.UseSerilog((context, services, configuration) => configuration builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext() .Enrich.FromLogContext()
//.Enrich.With<UserEnricher>() //.Enrich.With<UserEnricher>()
.ReadFrom.Configuration(context.Configuration)); .ReadFrom.Configuration(context.Configuration));
} }
private static void ConfigureApplication(WebApplication app) void ConfigureApplication(WebApplication app)
{ {
app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.MapApis(); app.MapApis();
app.MapIdentityShroudGraphQL();
// app.UseRouting(); // app.UseRouting();
// app.MapControllers(); // app.MapControllers();
}
} }
public partial class Program { }

View file

@ -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"

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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,19 +8,20 @@
</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>
@ -29,4 +30,8 @@
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" /> <ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Model\" />
</ItemGroup>
</Project> </Project>

View file

@ -49,6 +49,7 @@ public class JwtSignatureGeneratorTests
] ]
} }
"""; """;
JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!; JsonWebKeySet keySet = JsonSerializer.Deserialize<JsonWebKeySet>(keycloakKeySet)!;
using RSA publicKey = LoadFromJwk(keySet.Keys[0]); using RSA publicKey = LoadFromJwk(keySet.Keys[0]);
@ -71,8 +72,8 @@ public class JwtSignatureGeneratorTests
var rsa = RSA.Create(); var rsa = RSA.Create();
var parameters = new RSAParameters var parameters = new RSAParameters
{ {
Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus),
Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent)
}; };
rsa.ImportParameters(parameters); rsa.ImportParameters(parameters);

View 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));
}
}

View file

@ -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 inmemory 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);
}
}

View file

@ -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);
}
}

View file

@ -1,9 +1,5 @@
using IdentityShroud.Api;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
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.TestUtils.Substitutes;
@ -11,34 +7,10 @@ using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Tests.Services; 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> public class ClientServiceTests : IClassFixture<DbFixture>
{ {
private readonly DbFixture _dbFixture; private readonly DbFixture _dbFixture;
private readonly NullDataEncryptionService _dataEncryptionService = new(); private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
private readonly IClock _clock = Substitute.For<IClock>(); private readonly IClock _clock = Substitute.For<IClock>();
private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001");
@ -61,28 +33,15 @@ public class ClientServiceTests : IClassFixture<DbFixture>
{ {
if (!db.Realms.Any(r => r.Id == _realmId)) if (!db.Realms.Any(r => r.Id == _realmId))
{ {
db.Realms.Add(new() db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" });
{
Id = _realmId,
Slug = "test-realm",
Name = "Test Realm",
DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ],
});
db.SaveChanges(); db.SaveChanges();
} }
} }
private ClientService CreateSut(Db db) => new(db,
_dataEncryptionService,
new ClientCreateRequestValidator(),
_clock);
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
public async Task Create(bool withSecret) public async Task Create(bool allowClientCredentialsFlow)
{ {
// Setup // Setup
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
@ -92,13 +51,15 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
// Act // Act
ClientService sut = CreateSut(db); ClientService sut = new(db, _encryptionService, _clock);
var response = await sut.Create( var response = await sut.Create(
_realmId, _realmId,
ClientCreateRequestBuilder.Default() with new ClientCreateRequest
{ {
Confidential = withSecret, ClientId = "test-client",
GenerateSecret = withSecret, Name = "Test Client",
Description = "A test client",
AllowClientCredentialsFlow = allowClientCredentialsFlow,
}, },
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -108,7 +69,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
Assert.Equal("test-client", val.ClientId); Assert.Equal("test-client", val.ClientId);
Assert.Equal("Test Client", val.Name); Assert.Equal("Test Client", val.Name);
Assert.Equal("A test client", val.Description); Assert.Equal("A test client", val.Description);
Assert.Equal(withSecret, val.Confidential); Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow);
Assert.Equal(now, val.CreatedAt); Assert.Equal(now, val.CreatedAt);
} }
@ -118,7 +79,7 @@ public class ClientServiceTests : IClassFixture<DbFixture>
.Include(e => e.Secrets) .Include(e => e.Secrets)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
if (withSecret) if (allowClientCredentialsFlow)
Assert.Single(dbRecord.Secrets); Assert.Single(dbRecord.Secrets);
else else
Assert.Empty(dbRecord.Secrets); Assert.Empty(dbRecord.Secrets);
@ -146,8 +107,8 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
ClientService sut = CreateSut(actContext); ClientService sut = new(actContext, _encryptionService, _clock);
Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); Client? result = await sut.GetByClientId(clientId, TestContext.Current.CancellationToken);
// Verify // Verify
if (shouldFind) if (shouldFind)
@ -181,8 +142,8 @@ public class ClientServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
ClientService sut = CreateSut(actContext); ClientService sut = new(actContext, _encryptionService, _clock);
Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); Client? result = await sut.FindById(searchId, TestContext.Current.CancellationToken);
// Verify // Verify
if (shouldFind) if (shouldFind)

View file

@ -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,
};
}

View file

@ -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);
}
}

View file

@ -0,0 +1,26 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Tests.Services;
public class EncryptionServiceTests
{
[Fact]
public void RoundtripWorks()
{
// setup
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns(key);
EncryptionService sut = new(secretProvider);
byte[] input = RandomNumberGenerator.GetBytes(16);
// act
var cipher = sut.Encrypt(input);
var result = sut.Decrypt(cipher);
Assert.Equal(input, result);
}
}

View file

@ -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);
}
}

View file

@ -1,13 +1,10 @@
using FluentResults;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys; 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.TestUtils.Substitutes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace IdentityShroud.Core.Tests.Services; namespace IdentityShroud.Core.Tests.Services;
@ -15,7 +12,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
{ {
private readonly DbFixture _dbFixture; private readonly DbFixture _dbFixture;
private readonly IKeyService _keyService = Substitute.For<IKeyService>(); private readonly IKeyService _keyService = Substitute.For<IKeyService>();
private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();
public RealmServiceTests(DbFixture dbFixture) public RealmServiceTests(DbFixture dbFixture)
{ {
@ -30,9 +26,6 @@ public class RealmServiceTests : IClassFixture<DbFixture>
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")]
@ -43,14 +36,14 @@ public class RealmServiceTests : IClassFixture<DbFixture>
if (idString is not null) if (idString is not null)
realmId = new(idString); realmId = new(idString);
Realm? val; RealmCreateResponse? val;
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
_keyService.CreateKey(Arg.Any<KeyPolicy>()) _keyService.CreateKey(Arg.Any<KeyPolicy>())
.Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21]))); .Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow));
// Act // Act
RealmService sut = CreateSut(db); RealmService sut = new(db, _keyService);
Result<Realm> response = await sut.Create( var response = await sut.Create(
new(realmId, "slug", "New realm"), new(realmId, "slug", "New realm"),
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@ -61,12 +54,8 @@ public class RealmServiceTests : IClassFixture<DbFixture>
else else
Assert.NotEqual(Guid.Empty, val.Id); Assert.NotEqual(Guid.Empty, val.Id);
Assert.Multiple( Assert.Equal("slug", val.Slug);
() => val.Slug.ShouldBe("slug"), Assert.Equal("New realm", val.Name);
() => val.Name.ShouldBe("New realm"),
() => val.DataEncryptionKeys.ShouldContain(d => d.Active),
() => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue)
);
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>()); _keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
} }
@ -74,9 +63,9 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
var dbRecord = await db.Realms var dbRecord = await db.Realms
.Include(e => e.TokenSigningKeys) .Include(e => e.Keys)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType); Assert.Equal("TST", dbRecord.Keys[0].KeyType);
} }
} }
@ -103,7 +92,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
RealmService sut = CreateSut(actContext); RealmService sut = new(actContext, _keyService);
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);
// Verify // Verify
@ -136,7 +125,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using var actContext = _dbFixture.CreateDbContext(); await using var actContext = _dbFixture.CreateDbContext();
// Act // Act
RealmService sut = CreateSut(actContext); RealmService sut = new(actContext, _keyService);
Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken);
// Verify // Verify

View file

@ -1,7 +1,9 @@
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.DTO;
using IdentityShroud.Core.Messages;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Tests; namespace IdentityShroud.Core.Tests;
@ -34,6 +36,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 +52,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 +67,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))
}; };
} }
} }

View file

@ -2,6 +2,18 @@ using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
//public record CreateClientRequest(Guid RealmId, string ClientId, string? Description);
public class ClientCreateRequest
{
public required string ClientId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? SignatureAlgorithm { get; set; }
public bool? AllowClientCredentialsFlow { get; set; }
}
public interface IClientService public interface IClientService
{ {
Task<Result<Client>> Create( Task<Result<Client>> Create(
@ -9,6 +21,6 @@ public interface IClientService
ClientCreateRequest request, ClientCreateRequest request,
CancellationToken ct = default); CancellationToken ct = default);
Task<Client?> GetByClientId(Guid realmId, string clientId, CancellationToken ct = default); Task<Client?> GetByClientId(string clientId, CancellationToken ct = default);
Task<Client?> FindById(Guid realmId, int id, CancellationToken ct = default); Task<Client?> FindById(int id, CancellationToken ct = default);
} }

View file

@ -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));
}
}

View file

@ -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);
}

View file

@ -0,0 +1,7 @@
namespace IdentityShroud.Core.Contracts;
public interface IEncryptionService
{
byte[] Encrypt(byte[] plain);
byte[] Decrypt(ReadOnlyMemory<byte> cipher);
}

View file

@ -1,10 +1,12 @@
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
public interface IKeyService public interface IKeyService
{ {
CreateKeyResponse CreateKey(KeyPolicy policy); RealmKey CreateKey(KeyPolicy policy);
JsonWebKey? CreateJsonWebKey(RealmKey realmKey);
} }

View file

@ -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);
}

View file

@ -1,5 +1,6 @@
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model; using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Contracts;
@ -8,7 +9,6 @@ public interface IRealmService
Task<Realm?> FindById(Guid id, CancellationToken ct = default); Task<Realm?> FindById(Guid id, CancellationToken ct = default);
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default); Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default); Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm); Task LoadActiveKeys(Realm realm);
Task LoadDeks(Realm realm);
} }

View file

@ -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 GetSecret(string name);
/// <summary>
/// Should return one active key, might return inactive keys.
/// </summary>
/// <returns></returns>
KeyEncryptionKey[] GetKeys(string name);
} }

View file

@ -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;
}
}

View file

@ -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);

View file

@ -1,6 +1,7 @@
using System.Buffers;
using System.Buffers.Text;
using System.Text.Json;
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;
@ -10,7 +11,7 @@ namespace IdentityShroud.Core.Messages;
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) // Common values sig(nature) enc(ryption)
[JsonPropertyName("use")] [JsonPropertyName("use")]
@ -48,3 +49,25 @@ public class JsonWebKey
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
// public string? X509CertificateThumbprint { get; set; } // public string? X509CertificateThumbprint { get; set; }
} }
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);
}
}

View file

@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.DTO.OpenId;
public enum GrantTypes
{
[JsonStringEnumMemberName("client_credentials")]
ClientCredentials
}

View file

@ -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; }
}

View file

@ -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);

View file

@ -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
{ {
@ -20,8 +18,7 @@ public class Db(
{ {
public virtual DbSet<Client> Clients { get; set; } 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<RealmKey> Keys { get; set; }
public virtual DbSet<RealmDek> Deks { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@ -38,22 +35,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>();
} }
} }

View file

@ -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));

View file

@ -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())
{
}
}

View file

@ -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));

View file

@ -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))
{
}
}

View file

@ -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));

View file

@ -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))
{
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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,13 @@
</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="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Scrutor" />
<PackageReference Include="Shouldly" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -24,7 +21,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>

View file

@ -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>

View file

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
@ -19,16 +20,8 @@ public class Client
public string? Description { get; set; } public string? Description { get; set; }
[MaxLength(20)] [MaxLength(20)]
public JwtSigAlgName? SignatureAlgorithm { get; set; } public string? 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 bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; } public required DateTime CreatedAt { get; set; }

View file

@ -1,8 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
@ -13,17 +10,6 @@ public class ClientSecret
public int Id { get; set; } public int Id { get; set; }
public Guid ClientId { get; set; } public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? Expires { get; set; }
public DateTime? RevokedAt { get; set; } public DateTime? RevokedAt { get; set; }
public required EncryptedValue Secret { get; set; } public required byte[] SecretEncrypted { 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);
}
} }

View file

@ -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;
}

View file

@ -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.Security;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
[Table("realm")] [Table("realm")]
public class Realm public class Realm
{ {
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,11 @@ 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 List<RealmKey> Keys { get; init; } = [];
/// <summary>
/// Note multiple keys can be in use at the same time because different clients may be configured to use
/// a different keytype depending on their clients requirements/capabilities.
/// </summary>
public List<RealmSigningKey> TokenSigningKeys { get; init; } = [];
public List<RealmDek> DataEncryptionKeys { get; init; } = [];
/// <summary> /// <summary>
/// Can be overriden per client /// Can be overriden per client
/// </summary> /// </summary>
public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256; public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
} }

View file

@ -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());
}
}

View file

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace IdentityShroud.Core.Model;
[Table("realm_key")]
public record RealmKey(Guid Id, string KeyType, byte[] KeyDataEncrypted, DateTime CreatedAt)
{
public Guid Id { get; private set; } = Id;
public string KeyType { get; private set; } = KeyType;
public byte[] KeyDataEncrypted { get; private set; } = KeyDataEncrypted;
public DateTime CreatedAt { get; private set; } = CreatedAt;
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;
}

View file

@ -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");
}
}

View file

@ -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());
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,70 @@
using System.Security.Cryptography;
namespace IdentityShroud.Core.Security;
public static class AesGcmHelper
{
public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key)
{
int tagSize = AesGcm.TagByteSizes.MaxSize;
using var aes = new AesGcm(key, tagSize);
Span<byte> nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize];
RandomNumberGenerator.Fill(nonce);
Span<byte> ciphertext = stackalloc byte[plaintext.Length];
Span<byte> tag = stackalloc byte[tagSize];
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Return concatenated nonce|ciphertext|tag
var result = new byte[nonce.Length + ciphertext.Length + tag.Length];
nonce.CopyTo(result.AsSpan(0, nonce.Length));
ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length));
tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length));
return result;
}
// --------------------------------------------------------------------
// DecryptAesGcm
// • key 32byte (256bit) secret key (same key used for encryption)
// • payload byte[] containing nonce‖ciphertext‖tag
// • returns the original plaintext bytes
// --------------------------------------------------------------------
public static byte[] DecryptAesGcm(ReadOnlyMemory<byte> payload, byte[] key)
{
if (key == null) throw new ArgumentNullException(nameof(key));
if (key.Length != 32) // 256bit key
throw new ArgumentException("Key must be 256bits (32 bytes) for AES256GCM.", nameof(key));
// ----------------------------------------------------------------
// 1⃣ Extract the three components.
// ----------------------------------------------------------------
// AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length)
// AesGcm.TagByteSizes.MaxSize = 16 bytes (128bit 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 = payload.Span[..nonceSize];
ReadOnlySpan<byte> ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = payload.Span.Slice(payload.Length - tagSize, tagSize);
byte[] plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, tagSize);
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;
}
}

View file

@ -14,9 +14,4 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret
{ {
return secrets.GetValue<string>(name) ?? ""; return secrets.GetValue<string>(name) ?? "";
} }
public KeyEncryptionKey[] GetKeys(string name)
{
return secrets.GetSection(name).Get<KeyEncryptionKey[]>() ?? [];
}
} }

View file

@ -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");
}

View file

@ -1,3 +0,0 @@
namespace IdentityShroud.Core.Security;
public record EncryptedDek(KekId KekId, byte[] Value);

View file

@ -1,5 +0,0 @@
namespace IdentityShroud.Core.Security;
public record EncryptedValue(DekId DekId, byte[] Value);

View file

@ -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];
}
}

View file

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

View file

@ -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);
}

View file

@ -1,6 +0,0 @@
namespace IdentityShroud.Core;
public interface IJwtSignerFactory
{
IJwtSigner Create(JwtSigAlgName algorithm);
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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.");
}
}

View file

@ -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")
};
}

View file

@ -0,0 +1,38 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core;
public static class JwtSignatureGenerator
{
/// <summary>
/// Generates a JWT signature using RS256 algorithm
/// </summary>
/// <param name="headerBase64Url">Base64Url encoded header</param>
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
/// <returns>Base64Url encoded signature</returns>
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
// Combine header and payload with a period
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
// Convert to bytes
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
// Sign the data using RSA-SHA256
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Convert signature to Base64Url encoding
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
return signature;
}
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
}
}

View file

@ -1,41 +0,0 @@
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Security;
[JsonConverter(typeof(KekIdJsonConverter))]
[TypeConverter(typeof(KekIdTypeConverter))]
public readonly record struct KekId
{
public Guid Id { get; }
public KekId(Guid id)
{
Id = id;
}
public static KekId NewId()
{
return new KekId(Guid.NewGuid());
}
}
public class KekIdJsonConverter : JsonConverter<KekId>
{
public override KekId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new KekId(reader.GetGuid());
public override void Write(Utf8JsonWriter writer, KekId value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Id);
}
public class KekIdTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
=> sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
=> value is string s ? new KekId(Guid.Parse(s)) : base.ConvertFrom(context, culture, value);
}

View file

@ -1,10 +0,0 @@
namespace IdentityShroud.Core.Security;
/// <summary>
/// Contains a KEK and associated relevant data. This structure
/// </summary>
/// <param name="Id"></param>
/// <param name="Active"></param>
/// <param name="Algorithm"></param>
/// <param name="Key"></param>
public record KeyEncryptionKey(KekId Id, bool Active, string Algorithm, byte[] Key);

View file

@ -1,10 +0,0 @@
namespace IdentityShroud.Core.Security.Keys.Aes;
public class AesKeyPolicy : KeyPolicy
{
public AesKeyPolicy()
{
KeyType = KeyType.AES;
KeySize = 256;
}
}

View file

@ -1,19 +0,0 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys.Aes;
public class AesProvider : IKeyProvider
{
public bool IsPublic => false;
public KeyData CreateKey(KeyPolicy policy)
{
return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8));
}
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk)
{
// Can we use this for Jwe?
throw new NotImplementedException();
}
}

View file

@ -1,33 +1,19 @@
using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Security.Keys;
public class KeyPolicy public abstract class KeyPolicy
{ {
public KeyType KeyType { get; protected init; } public abstract string KeyType { get; }
public int KeySize { get; protected init; }
}
public record KeyData(byte[] PrivateKey, Dictionary<string, string>? PublicKeyParameters = null)
{
/// <summary>
/// The data to be kept private, also used for symmetric keys
/// </summary>
public byte[] PrivateKey { get; set; } = PrivateKey;
public Dictionary<string, string>? PublicKeyParameters { get; set; } = PublicKeyParameters;
} }
public interface IKeyProvider public interface IKeyProvider
{ {
/// <summary> byte[] CreateKey(KeyPolicy policy);
/// Returns true when this key uses public key cryptography
/// </summary>
bool IsPublic { get; }
KeyData CreateKey(KeyPolicy policy);
void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk); void SetJwkParameters(byte[] key, JsonWebKey jwk);
} }

View file

@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys;
public interface IKeyProviderFactory public interface IKeyProviderFactory
{ {
public IKeyProvider CreateProvider(KeyType keyType); public IKeyProvider CreateProvider(string keyType);
} }

Some files were not shown because too many files have changed in this diff Show more