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

@ -0,0 +1,41 @@
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, commented out for now as it seems not
// have any good use in an identity server. Anyone validating tokens should use
// the algorithm specified in the header of the token.
// [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

@ -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,26 @@
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Api;
/// <summary>
/// Note the filter depends on the slug path parameter to be the first string argument on the context.
/// The endpoint handlers should place path arguments first and in order of the path to ensure this works
/// consistently.
/// </summary>
/// <param name="realmService"></param>
public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
string slug = context.Arguments.OfType<string>().First();
Realm? realm = await realmService.FindBySlug(slug);
if (realm is null)
{
return Results.NotFound();
}
context.HttpContext.Items["RealmEntity"] = realm;
return await next(context);
}
}

View file

@ -0,0 +1,34 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IEncryptionService encryptionService)
{
public JsonWebKey KeyToJsonWebKey(Key key)
{
using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService));
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
return new JsonWebKey()
{
KeyType = rsa.SignatureAlgorithm,
KeyId = key.Id.ToString(),
Use = "sig",
Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!),
Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!),
};
}
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<Key> keys)
{
return new JsonWebKeySet()
{
Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(),
};
}
}

View file

@ -1,13 +1,22 @@
using FluentResults;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public static class HttpContextExtensions
{
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
}
public static class RealmApi
{
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
@ -18,7 +27,8 @@ public static class RealmApi
.WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
var realmSlugGroup = realmsGroup.MapGroup("{slug}");
var realmSlugGroup = realmsGroup.MapGroup("{slug}")
.AddEndpointFilter<SlugValidationFilter>();
realmSlugGroup.MapGet("", GetRealmInfo);
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
@ -39,9 +49,15 @@ public static class RealmApi
return TypedResults.InternalServerError();
}
private static Task OpenIdConnectJwks(HttpContext context)
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
string slug,
[FromServices]IRealmService realmService,
[FromServices]KeyMapper keyMapper,
HttpContext context)
{
throw new NotImplementedException();
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
}
private static Task OpenIdConnectToken(HttpContext context)
@ -54,17 +70,12 @@ public static class RealmApi
throw new NotImplementedException();
}
private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest, NotFound>> GetOpenIdConfiguration(
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
string slug,
[FromServices]IRealmService realmService,
HttpContext context,
string slug)
HttpContext context)
{
if (string.IsNullOrEmpty(slug))
return TypedResults.BadRequest();
var realm = await realmService.FindBySlug(slug);
if (realm is null)
return TypedResults.NotFound();
Realm realm = context.GetValidatedRealm();
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{slug}";
@ -94,30 +105,4 @@ public static class RealmApi
}
*/
}
// [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!");
// }
}