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:
parent
a80c133e2a
commit
ccb06b260c
24 changed files with 353 additions and 107 deletions
41
IdentityShroud.Api/Apis/DTO/JsonWebKey.cs
Normal file
41
IdentityShroud.Api/Apis/DTO/JsonWebKey.cs
Normal 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; }
|
||||
}
|
||||
9
IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs
Normal file
9
IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs
Normal 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>();
|
||||
}
|
||||
26
IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs
Normal file
26
IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
34
IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
Normal file
34
IdentityShroud.Api/Apis/Mappers/KeyMapper.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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!");
|
||||
// }
|
||||
}
|
||||
3
IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings
Normal file
3
IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cdto/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
using FluentValidation;
|
||||
using IdentityShroud.Api;
|
||||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Api.Validation;
|
||||
using IdentityShroud.Core;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Security;
|
||||
using IdentityShroud.Core.Services;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Json;
|
||||
|
||||
|
|
@ -34,8 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder)
|
|||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
services.AddOpenApi();
|
||||
services.AddScoped<Db>();
|
||||
services.AddScoped<IRealmService, RealmService>();
|
||||
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
|
||||
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
|
||||
services.AddSingleton<KeyMapper>();
|
||||
services.AddSingleton<IEncryptionService>(c =>
|
||||
{
|
||||
var configuration = c.GetRequiredService<IConfiguration>();
|
||||
return new EncryptionService(configuration.GetValue<string>("Secrets:Master"));
|
||||
});
|
||||
|
||||
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue