Miscelanious trials

This commit is contained in:
eelke 2026-02-06 19:58:01 +01:00
commit f99c97f392
33 changed files with 881 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

15
.idea/.idea.IdentityShroud/.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/contentModel.xml
/.idea.IdentityShroud.iml
/projectSettingsUpdater.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -0,0 +1,21 @@
# This is a generated file. Not intended for manual editing.
services:
identityshroud.api:
build:
context: "/home/eelke/RiderProjects/IdentityShroud"
dockerfile: "IdentityShroud.Api/Dockerfile"
target: "base"
command: []
entrypoint:
- "dotnet"
- "/app/bin/Debug/net10.0/IdentityShroud.Api.dll"
environment:
ASPNETCORE_ENVIRONMENT: "Development"
DOTNET_USE_POLLING_FILE_WATCHER: "true"
image: "identityshroud.api:dev"
ports: []
volumes:
- "/home/eelke/RiderProjects/IdentityShroud/IdentityShroud.Api:/app:rw"
- "/home/eelke/RiderProjects/IdentityShroud:/src:rw"
- "/home/eelke/.nuget/packages:/home/app/.nuget/packages"
working_dir: "/app"

View file

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

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,11 @@
@IdentityShroud_HostAddress = http://localhost:5249
GET {{IdentityShroud_HostAddress}}/todos/
Accept: application/json
###
GET {{IdentityShroud_HostAddress}}/todos/1
Accept: application/json
###

View file

@ -0,0 +1,50 @@
using IdentityShroud.Api;
using IdentityShroud.Core;
using Serilog;
using Serilog.Formatting.Json;
// Initial logging until we can set it up from Configuration
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter())
.CreateLogger();
var applicationBuilder = WebApplication.CreateSlimBuilder(args);
ConfigureBuilder(applicationBuilder);
var application = applicationBuilder.Build();
ConfigureApplication(application);
application.Run();
void ConfigureBuilder(WebApplicationBuilder builder)
{
var services = builder.Services;
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.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
//.Enrich.With<UserEnricher>()
.ReadFrom.Configuration(context.Configuration));
}
void ConfigureApplication(WebApplication app)
{
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseSerilogRequestLogging();
app.MapRealmEndpoints();
// app.UseRouting();
// app.MapControllers();
}

View file

@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "todos",
"applicationUrl": "http://localhost:5249",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,94 @@
using IdentityShroud.Core.Messages;
using Microsoft.AspNetCore.Http.HttpResults;
namespace IdentityShroud.Api;
public static class RealmController
{
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
{
var realm = app.MapGroup("/realms/{slug}");
realm.MapGet("", GetRoot);
realm.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var openidConnect = realm.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken);
openidConnect.MapGet("jwks", OpenIdConnectJwks);
}
private static Task OpenIdConnectJwks(HttpContext context)
{
throw new NotImplementedException();
}
private static Task OpenIdConnectToken(HttpContext context)
{
throw new NotImplementedException();
}
private static Task OpenIdConnectAuth(HttpContext context)
{
throw new NotImplementedException();
}
private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context)
{
if (string.IsNullOrEmpty(slug))
return TypedResults.BadRequest();
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{slug}";
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
string baseUri = s.Substring(0, index + searchString.Length);
return TypedResults.Json(new OpenIdConfiguration()
{
AuthorizationEndpoint = baseUri + "/openid-connect/auth",
TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks",
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
private static string GetRoot()
{
return "Hello World!";
/* keycloak returns this
{
"realm": "mpluskassa",
"public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1/qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ/UegS9ylobfIYXJsz0xmJK5ToFaSYa72D/Dyln7ROxudu8+zc70sz7bUKQ0/ktWRsiu76vY6Kr9+18PgaooPmb2QP8lS8IZEv+gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA+u7s/GrOLcLoLLgEnRRVwIDAQAB",
"token-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/protocol/openid-connect",
"account-service": "https://iam.kassacloud.nl/auth/realms/mpluskassa/account",
"tokens-not-before": 0
}
*/
}
// [HttpGet("")]
// public ActionResult Index()
// {
// return new JsonResult("Hello world!");
// }
// [HttpGet("{slug}/.well-known/openid-configuration")]
// public ActionResult GetOpenIdConfiguration(
// string slug,
// [FromServices]LinkGenerator linkGenerator)
// {
// var s = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}";
// var searchString = $"realms/{slug}";
// int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
// string baseUri = s.Substring(0, index + searchString.Length);
//
// return new JsonResult(baseUri);
// }
// [HttpPost("{slug}/protocol/openid-connect/token")]
// public ActionResult GetOpenIdConnectToken(string slug)
//
// {
// return new JsonResult("Hello world!");
// }
}

View file

@ -0,0 +1,11 @@
{
"Db": {
"LogSensitiveData": true
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
</ItemGroup>
</Project>

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

@ -0,0 +1,107 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using IdentityShroud.Core.Messages;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Tests;
public class UnitTest1
{
private const string TestJwt = @"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0.eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9.Z1mjZkpFLIMsx2EWRNwFXYinwUO4iRmteClGWoj70c9AffhjEN5hmSL2ErLn9RjofODY5JovbDo3RDBrTWMDdGHYavxaZRzn8EJe6Ndp9b2n7kUNHJpBrqIGEMhD5sY_5YRTfMIe7j7k2oRW8QIpcQ5_bYUBKEWfHYQqfi8IfpRjLhgd6zKMC3Faj4e442p5zY2dEdWHEr5j6--_Py4HhNsomtTY6WPFH8nTCJ9pbMM9ThSDmdjbSiMbNLeSAQzJNVXp5GGsSVlkJNRlMI_Mmfd7jWpSDZpNPzxJ-HmkDhY9oiInZW0livnf9-eesKWljO3TAXKEuiqigsblSbLjsQ";
[Fact]
public void DecodeTest()
{
var decoded = JwtReader.Decode(TestJwt);
Assert.Equal("RS256", decoded.Header.Algorithm);
Assert.Equal("https://iam.kassacloud.nl/auth/realms/mpluskassa", decoded.Payload.Issuer);
}
[Fact]
public void CreateTest()
{
using (RSA rsa = RSA.Create())
{
// Option 1: Load from PEM string
// string privateKeyPem = @"-----BEGIN PRIVATE KEY-----
// ... your key here ...
// -----END PRIVATE KEY-----";
// rsa.ImportFromPem(privateKeyPem);
// Option 2: Load from XML
// string xmlKey = "<RSAKeyValue>...</RSAKeyValue>";
// rsa.FromXmlString(xmlKey);
// Option 3: Generate a new key for testing
rsa.KeySize = 2048;
// Your already encoded header and payload
string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0";
string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9";
// Generate signature
string signature = JwtSignatureGenerator.GenerateRS256Signature(header, payload, rsa);
string publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
string token = $"{header}.{payload}.{signature}";
Console.WriteLine($"Signature: {signature}");
// Or generate complete JWT
// string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa);
// Console.WriteLine($"Complete JWT: {completeJwt}");
}
}
}
public static class JwtReader
{
public static JsonWebToken Decode(string jwt)
{
// there should only be two, if not one of the base64 decode calls should fail.
int firstDot = jwt.IndexOf('.');
int secondDot = jwt.LastIndexOf('.');
return new JsonWebToken()
{
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))),
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))),
Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
};
}
}
public static class RsaKeyLoader
{
/// <summary>
/// Load RSA private key from PEM format (.NET 5+)
/// </summary>
public static RSA LoadFromPem(string pemKey)
{
var rsa = RSA.Create();
rsa.ImportFromPem(pemKey);
return rsa;
}
/// <summary>
/// Load RSA private key from file
/// </summary>
public static RSA LoadFromPemFile(string filePath)
{
string pemContent = System.IO.File.ReadAllText(filePath);
return LoadFromPem(pemContent);
}
/// <summary>
/// Load RSA private key from PKCS#8 format
/// </summary>
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
return rsa;
}
}

View file

@ -0,0 +1,6 @@
namespace IdentityShroud.Core.Contracts;
public interface ISecretProvider
{
Task<string> GetSecretAsync(string name);
}

38
IdentityShroud.Core/Db.cs Normal file
View file

@ -0,0 +1,38 @@
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace IdentityShroud.Core;
public class DbConfiguration
{
public string ConnectionString { get; set; } = "";
public bool LogSensitiveData { get; set; } = false;
}
public class Db(
IOptions<DbConfiguration> configuration,
ILoggerFactory? loggerFactory)
: DbContext
{
public virtual DbSet<Realm> Realms { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql("<connection string>");
optionsBuilder.UseNpgsql(
configuration.Value.ConnectionString,
o => o.MigrationsAssembly("IdentityShroud.Migrations")); // , o => o.UseNodaTime().UseVector().MigrationsAssembly("Migrations.KnowledgeBaseDB"));
optionsBuilder.UseSnakeCaseNamingConvention();
if (configuration.Value.LogSensitiveData)
optionsBuilder.EnableSensitiveDataLogging();
if (loggerFactory is { } )
{
optionsBuilder.UseLoggerFactory(loggerFactory);
}
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.WebUtilities">
<HintPath>..\..\..\.nuget\packages\microsoft.aspnetcore.webutilities\10.0.2\lib\net10.0\Microsoft.AspNetCore.WebUtilities.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
public class JsonWebKey
{
[JsonPropertyName("kty")]
public string KeyType { get; set; } = "RSA";
[JsonPropertyName("use")]
public string Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
[JsonPropertyName("alg")]
public string Algorithm { get; set; } = "RS256";
[JsonPropertyName("kid")]
public string KeyId { get; set; }
// RSA Public Key Components
[JsonPropertyName("n")]
public string Modulus { get; set; }
[JsonPropertyName("e")]
public string Exponent { get; set; }
// Optional fields
[JsonPropertyName("x5c")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> X509CertificateChain { get; set; }
[JsonPropertyName("x5t")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string X509CertificateThumbprint { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
public class JsonWebKeySet
{
[JsonPropertyName("keys")]
public List<JsonWebKey> Keys { get; set; } = new List<JsonWebKey>();
}

View file

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
public class JsonWebTokenHeader
{
[JsonPropertyName("alg")]
public string Algorithm { get; set; } = "HS256";
[JsonPropertyName("typ")]
public string Type { get; set; } = "JWT";
[JsonPropertyName("kid")]
public string KeyId { get; set; }
}
public class JsonWebTokenPayload
{
[JsonPropertyName("iss")]
public string Issuer { get; set; }
[JsonPropertyName("aud")]
public string[] Audience { get; set; }
[JsonPropertyName("sub")]
public string Subject { get; set; }
[JsonPropertyName("exp")]
public long Expires { get; set; }
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
[JsonPropertyName("nbf")]
public long NotBefore { get; set; }
[JsonPropertyName("jti")]
public Guid JwtId { get; set; }
}
public class JsonWebToken
{
public JsonWebTokenHeader Header { get; set; } = new();
public JsonWebTokenPayload Payload { get; set; } = new();
public byte[] Signature { get; set; } = [];
}

View file

@ -0,0 +1,72 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
/// <summary>
/// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
/// </summary>
public class OpenIdConfiguration
{
/// <summary>
/// REQUIRED. URL using the https scheme with no query or fragment components that the OP asserts as its
/// Issuer Identifier. If Issuer discovery is supported (see Section 2), this value MUST be identical to the
/// issuer value returned by WebFinger. This also MUST be identical to the iss Claim value in ID Tokens issued
/// from this Issuer.
/// </summary>
[JsonPropertyName("issuer")]
public required string Issuer { get; set; }
/// <summary>
/// REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint [OpenID.Core]. This URL MUST use the https scheme
/// and MAY contain port, path, and query parameter components.
/// </summary>
[JsonPropertyName("authorization_endpoint")]
public required string AuthorizationEndpoint { get; set; }
/// <summary>
/// URL of the OP's OAuth 2.0 Token Endpoint [OpenID.Core]. This is REQUIRED unless only the Implicit Flow is used.
/// This URL MUST use the https scheme and MAY contain port, path, and query parameter components.
/// </summary>
[JsonPropertyName("token_endpoint")]
public string? TokenEndpoint { get; set; }
/// <summary>
/// RECOMMENDED. URL of the OP's UserInfo Endpoint [OpenID.Core]. This URL MUST use the https scheme and MAY contain
/// port, path, and query parameter components.
/// </summary>
[JsonPropertyName("userinfo_endpoint")]
public string? UserInfoEndpoint { get; set; }
/// <summary>
/// REQUIRED. URL of the OP's JWK Set [JWK] document, which MUST use the https scheme. This contains the signing
/// key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also contain the Server's encryption
/// key(s), which are used by RPs to encrypt requests to the Server. When both signing and encryption keys are made
/// available, a use (public key use) parameter value is REQUIRED for all keys in the referenced JWK Set to indicate
/// each key's intended usage. Although some algorithms allow the same key to be used for both signatures and
/// encryption, doing so is NOT RECOMMENDED, as it is less secure. The JWK x5c parameter MAY be used to provide
/// X.509 representations of keys provided. When used, the bare key values MUST still be present and MUST match
/// those in the certificate. The JWK Set MUST NOT contain private or symmetric key values.
/// </summary>
[JsonPropertyName("jwks_uri")]
public required string JwksUri { get; set; }
/// <summary>
/// REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. Dynamic
/// OpenID Providers MUST support the code, id_token, and the id_token token Response Type values.
/// </summary>
[JsonPropertyName("response_types_supported")]
public string[] ResponseTypesSupported { get; set; } = [ "code", "id_token", "id_token token"];
/// <summary>
/// REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. Valid types
/// include pairwise and public.
/// </summary>
[JsonPropertyName("subject_types_supported")]
public string[] SubjectTypesSupported { get; set; } = [ "public" ];
/// <summary>
/// REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the
/// ID Token to encode the Claims in a JWT [JWT]. The algorithm RS256 MUST be included. The value none MAY be
/// supported but MUST NOT be used unless the Response Type used returns no ID Token from the Authorization
/// Endpoint (such as when using the Authorization Code Flow).
/// </summary>
[JsonPropertyName("id_token_signing_alg_values_supported")]
public string[] IdTokenSigningAlgValuesSupported { get; set; } = [ "RS256" ];
}

View file

@ -0,0 +1,7 @@
namespace IdentityShroud.Core.Model;
public class Client
{
public Guid Id { get; set; }
public string Name { get; set; }
}

View file

@ -0,0 +1,10 @@
namespace IdentityShroud.Core.Model;
public class Realm
{
public Guid Id { get; set; }
public string Slug { get; set; } = "";
public string Description { get; set; } = "";
public List<Client> Clients { get; set; } = [];
public byte[] PrivateKey { get; set; }
}

View file

@ -0,0 +1,64 @@
using System.Security.Cryptography;
namespace IdentityShroud.Core.Security;
public static class AesGcmHelper
{
public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key)
{
using var aes = new AesGcm(key);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Return concatenated nonce|ciphertext|tag (or store separately)
return nonce.Concat(ciphertext).Concat(tag).ToArray();
}
// --------------------------------------------------------------------
// DecryptAesGcm
// • key 32byte (256bit) secret key (same key used for encryption)
// • payload byte[] containing nonce‖ciphertext‖tag
// • returns the original plaintext bytes
// --------------------------------------------------------------------
public static byte[] DecryptAesGcm(byte[] payload, byte[] key)
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (key == null) throw new ArgumentNullException(nameof(key));
if (key.Length != 32) // 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 = new(payload, 0, nonceSize);
ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
byte[] plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key);
try
{
aes.Decrypt(nonce, ciphertext, tag, plaintext);
}
catch (CryptographicException ex)
{
// Tag verification failed → tampering or wrong key/nonce.
throw new InvalidOperationException("Decryption failed authentication tag mismatch.", ex);
}
return plaintext;
}
}

View file

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

View file

@ -0,0 +1,21 @@
using IdentityShroud.Core;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace IdentityShroud.Migrations;
public sealed class DesignTimeDbFactory : IDesignTimeDbContextFactory<Db>
{
public Db CreateDbContext(string[] args)
{
// You can load from env/args/user-secrets if you like; keep placeholders out of source control.
var cfg = Options.Create(new DbConfiguration
{
ConnectionString = "Host=localhost;Port=5432;Database=identityshroud;Username=identityshroud;Password=enshrouded;SSL Mode=allow;Trust Server Certificate=true",
LogSensitiveData = false
});
return new Db(cfg, NullLoggerFactory.Instance);
}
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
</ItemGroup>
</Project>

39
IdentityShroud.sln Normal file
View file

@ -0,0 +1,39 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api", "IdentityShroud.Api\IdentityShroud.Api.csproj", "{D2B446A0-AB62-4555-9D79-33FF43D7CEF4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core", "IdentityShroud.Core\IdentityShroud.Core.csproj", "{8490BF59-B68A-4BE0-9F96-6CB262AF4850}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core.Tests", "IdentityShroud.Core.Tests\IdentityShroud.Core.Tests.csproj", "{DC887623-8680-4D3B-B23A-D54F7DA91891}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{576359FF-C672-4CC3-A683-3BB9D647E75D}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", "IdentityShroud.Migrations\IdentityShroud.Migrations.csproj", "{DEECABE3-8934-4696-B0E1-48738DD0CEC4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D2B446A0-AB62-4555-9D79-33FF43D7CEF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2B446A0-AB62-4555-9D79-33FF43D7CEF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2B446A0-AB62-4555-9D79-33FF43D7CEF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2B446A0-AB62-4555-9D79-33FF43D7CEF4}.Release|Any CPU.Build.0 = Release|Any CPU
{8490BF59-B68A-4BE0-9F96-6CB262AF4850}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8490BF59-B68A-4BE0-9F96-6CB262AF4850}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8490BF59-B68A-4BE0-9F96-6CB262AF4850}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8490BF59-B68A-4BE0-9F96-6CB262AF4850}.Release|Any CPU.Build.0 = Release|Any CPU
{DC887623-8680-4D3B-B23A-D54F7DA91891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC887623-8680-4D3B-B23A-D54F7DA91891}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC887623-8680-4D3B-B23A-D54F7DA91891}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC887623-8680-4D3B-B23A-D54F7DA91891}.Release|Any CPU.Build.0 = Release|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,14 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHealthCheckEndpointRouteBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6d0f079e13da4e98881aa3e6e169c6d34f08_003F0e_003Fc2b30661_003FHealthCheckEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANamingConventionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feacd26cff49d864d97bf44d3424fd383a26620b1d0c43fb1d6f115da85c655_003FNamingConventionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOkOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe2a19de442f561af862af2dcad0852b7e62707a5cf194d266d1656f92bbb6d2_003FOkOfT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=7283fa6f_002Dab5a_002D49e4_002Db89b_002D4cdb1b0ba2b3/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="DecodeTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;TestAncestor&gt;
&lt;TestId&gt;xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.UnitTest1.DecodeTest&lt;/TestId&gt;
&lt;TestId&gt;xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.UnitTest1.CreateTest&lt;/TestId&gt;
&lt;TestId&gt;xUnit::DC887623-8680-4D3B-B23A-D54F7DA91891::net10.0::IdentityShroud.Core.Tests.Security.AesGcmHelperTests.EncryptDecryptCycleWorks&lt;/TestId&gt;
&lt;/TestAncestor&gt;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

13
dotnet-tools.json Normal file
View file

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.2",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}