Implement jwks endpoint and add test for it.

This also let to some improvements/cleanups of the other tests and fixtures.
This commit is contained in:
eelke 2026-02-15 19:06:09 +01:00
parent a80c133e2a
commit ccb06b260c
24 changed files with 353 additions and 107 deletions

View file

@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
// https://www.rfc-editor.org/rfc/rfc7517.html
public class JsonWebKey
{
[JsonPropertyName("kty")]
public string KeyType { get; set; } = "RSA";
// Common values sig(nature) enc(ryption)
[JsonPropertyName("use")]
public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
// Per standard this field is optional for now we will use RS256
[JsonPropertyName("alg")]
public string? Algorithm { get; set; } = "RS256";
[JsonPropertyName("kid")]
public required string KeyId { get; set; }
// RSA Public Key Components
[JsonPropertyName("n")]
public required string Modulus { get; set; }
[JsonPropertyName("e")]
public required 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

@ -1,9 +0,0 @@
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

@ -1,7 +1,11 @@
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Model;
public class Client
{
public Guid Id { get; set; }
public string Name { get; set; }
public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
}

View file

@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model;
@ -19,4 +21,10 @@ public class Realm
public List<Client> Clients { get; init; } = [];
public List<Key> Keys { get; init; } = [];
}
/// <summary>
/// Can be overriden per client
/// </summary>
public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
}

View file

@ -7,14 +7,22 @@ public static class AesGcmHelper
public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key)
{
using var aes = new AesGcm(key);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
int tagSize = AesGcm.TagByteSizes.MaxSize;
using var aes = new AesGcm(key, tagSize);
Span<byte> nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize];
RandomNumberGenerator.Fill(nonce);
Span<byte> ciphertext = stackalloc byte[plaintext.Length];
Span<byte> tag = stackalloc byte[tagSize];
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Return concatenated nonce|ciphertext|tag (or store separately)
return nonce.Concat(ciphertext).Concat(tag).ToArray();
// Return concatenated nonce|ciphertext|tag
var result = new byte[nonce.Length + ciphertext.Length + tag.Length];
nonce.CopyTo(result.AsSpan(0, nonce.Length));
ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length));
tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length));
return result;
}
// --------------------------------------------------------------------
@ -44,11 +52,10 @@ public static class AesGcmHelper
ReadOnlySpan<byte> nonce = new(payload, 0, nonceSize);
ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
byte[] plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key);
using var aes = new AesGcm(key, tagSize);
try
{
aes.Decrypt(nonce, ciphertext, tag, plaintext);

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

@ -4,4 +4,13 @@ namespace IdentityShroud.Core.Security;
public static class RsaHelper
{
/// <summary>
/// Load RSA private key from PKCS#8 format
/// </summary>
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
return rsa;
}
}

View file

@ -8,4 +8,5 @@ public interface IRealmService
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm);
}

View file

@ -15,7 +15,8 @@ public class RealmService(
{
public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{
return await db.Realms.SingleOrDefaultAsync(r => r.Slug == slug, ct);
return await db.Realms
.SingleOrDefaultAsync(r => r.Slug == slug, ct);
}
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
@ -35,6 +36,15 @@ public class RealmService(
realm.Id, realm.Slug, realm.Name);
}
public async Task LoadActiveKeys(Realm realm)
{
await db.Entry(realm).Collection(r => r.Keys)
.Query()
.Where(k => k.DeactivatedAt == null)
.LoadAsync();
}
private Key CreateKey()
{
using RSA rsa = RSA.Create(2048);