Reworked code around signing keys have key details much more isolated from the other parts of the program.
This commit is contained in:
parent
eb872a4f44
commit
0c6f227049
40 changed files with 474 additions and 281 deletions
|
|
@ -20,11 +20,17 @@ public static class ClientApi
|
|||
|
||||
public static void MapEndpoints(this IEndpointRouteBuilder erp)
|
||||
{
|
||||
erp.MapPost("", ClientCreate)
|
||||
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
|
||||
|
||||
clientsGroup.MapPost("", ClientCreate)
|
||||
.Validate<ClientCreateRequest>()
|
||||
.WithName("ClientCreate")
|
||||
.Produces(StatusCodes.Status201Created);
|
||||
erp.MapGet("{clientId}", ClientGet)
|
||||
|
||||
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
|
||||
.AddEndpointFilter<ClientIdValidationFilter>();
|
||||
|
||||
clientIdGroup.MapGet("", ClientGet)
|
||||
.WithName(ClientGetRouteName);
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +39,7 @@ public static class ClientApi
|
|||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<ClientCreateReponse>, InternalServerError>>
|
||||
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
|
||||
ClientCreate(
|
||||
ClientCreateRequest request,
|
||||
[FromServices] IClientService service,
|
||||
|
|
@ -42,14 +48,22 @@ public static class ClientApi
|
|||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
Result<Client> result = await service.Create(realm.Id, request, cancellationToken);
|
||||
|
||||
// Should i have two set of paths? one for actual REST and one for openid
|
||||
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
|
||||
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
|
||||
// api: api/v1/realms/{realmId}/....
|
||||
// api: api/v1/realms/{realmId}/clients/{clientId}
|
||||
|
||||
//return Results.CreatedAtRoute(ClientGetRouteName, [ "realmSlug" = realmId!?])
|
||||
|
||||
if (result.IsFailed)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
Client client = result.Value;
|
||||
|
||||
return TypedResults.CreatedAtRoute(
|
||||
new ClientCreateReponse(client.Id, client.ClientId),
|
||||
ClientGetRouteName,
|
||||
new RouteValueDictionary()
|
||||
{
|
||||
["realmId"] = realm.Id,
|
||||
["clientId"] = client.Id,
|
||||
});
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
using System.Buffers;
|
||||
using System.Buffers.Text;
|
||||
using System.Text.Json;
|
||||
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 string? Modulus { get; set; }
|
||||
|
||||
[JsonPropertyName("e")]
|
||||
public string? Exponent { get; set; }
|
||||
|
||||
// ECdsa
|
||||
public string? Curve { get; set; }
|
||||
[JsonConverter(typeof(Base64UrlConverter))]
|
||||
public byte[]? X { get; set; }
|
||||
[JsonConverter(typeof(Base64UrlConverter))]
|
||||
public byte[]? Y { 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; }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
}
|
||||
15
IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs
Normal file
15
IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace IdentityShroud.Api;
|
||||
|
||||
public static class EndpointRouteBuilderExtensions
|
||||
{
|
||||
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
|
||||
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
|
||||
|
||||
public static void MapApis(this IEndpointRouteBuilder erp)
|
||||
{
|
||||
RealmApi.MapRealmEndpoints(erp);
|
||||
|
||||
OpenIdEndpoints.MapEndpoints(erp);
|
||||
}
|
||||
|
||||
}
|
||||
20
IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs
Normal file
20
IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
int id = context.Arguments.OfType<int>().First();
|
||||
Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted);
|
||||
if (client is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
context.HttpContext.Items["ClientEntity"] = client;
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
20
IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs
Normal file
20
IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Model;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
Guid id = context.Arguments.OfType<Guid>().First();
|
||||
Realm? realm = await realmService.FindById(id, context.HttpContext.RequestAborted);
|
||||
if (realm is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
context.HttpContext.Items["RealmEntity"] = realm;
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,13 @@ namespace IdentityShroud.Api;
|
|||
/// consistently.
|
||||
/// </summary>
|
||||
/// <param name="realmService"></param>
|
||||
public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter
|
||||
public class RealmSlugValidationFilter(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);
|
||||
string realmSlug = context.Arguments.OfType<string>().FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Expected argument missing, ensure you include path parameters in your handlers signature even when you don't use them");
|
||||
Realm? realm = await realmService.FindBySlug(realmSlug, context.HttpContext.RequestAborted);
|
||||
if (realm is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
|
@ -7,41 +7,14 @@ using Microsoft.AspNetCore.WebUtilities;
|
|||
|
||||
namespace IdentityShroud.Api.Mappers;
|
||||
|
||||
public class KeyMapper(IEncryptionService encryptionService)
|
||||
public class KeyMapper(IKeyService keyService)
|
||||
{
|
||||
public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey)
|
||||
{
|
||||
|
||||
JsonWebKey result = new()
|
||||
{
|
||||
KeyId = realmKey.Id.ToString(),
|
||||
Use = "sig",
|
||||
};
|
||||
switch (realmKey.KeyType)
|
||||
{
|
||||
case "RSA":
|
||||
using (var rsa = RsaHelper.LoadFromPkcs8(realmKey.GetPrivateKey(encryptionService)))
|
||||
{
|
||||
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
result.KeyType = rsa.SignatureAlgorithm;
|
||||
result.Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!);
|
||||
result.Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
|
||||
{
|
||||
JsonWebKeySet wks = new();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
var wk = KeyToJsonWebKey(k);
|
||||
var wk = keyService.CreateJsonWebKey(k);
|
||||
if (wk is {})
|
||||
{
|
||||
wks.Keys.Add(wk);
|
||||
|
|
|
|||
72
IdentityShroud.Api/Apis/OpenIdEndpoints.cs
Normal file
72
IdentityShroud.Api/Apis/OpenIdEndpoints.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Model;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public static class OpenIdEndpoints
|
||||
{
|
||||
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
|
||||
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
|
||||
|
||||
|
||||
public static void MapEndpoints(this IEndpointRouteBuilder erp)
|
||||
{
|
||||
var realmsGroup = erp.MapGroup("/auth/realms");
|
||||
|
||||
var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}")
|
||||
.AddEndpointFilter<RealmSlugValidationFilter>();
|
||||
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
|
||||
|
||||
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
|
||||
openidConnect.MapPost("auth", OpenIdConnectAuth);
|
||||
openidConnect.MapPost("token", OpenIdConnectToken);
|
||||
openidConnect.MapGet("jwks", OpenIdConnectJwks);
|
||||
}
|
||||
|
||||
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
|
||||
string realmSlug,
|
||||
[FromServices]IRealmService realmService,
|
||||
HttpContext context)
|
||||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
|
||||
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
|
||||
var searchString = $"realms/{realmSlug}";
|
||||
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 async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
|
||||
string realmSlug,
|
||||
[FromServices]IRealmService realmService,
|
||||
[FromServices]KeyMapper keyMapper,
|
||||
HttpContext context)
|
||||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
await realmService.LoadActiveKeys(realm);
|
||||
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
|
||||
}
|
||||
|
||||
private static Task OpenIdConnectToken(HttpContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static Task OpenIdConnectAuth(HttpContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
using FluentResults;
|
||||
using IdentityShroud.Api.Mappers;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Messages;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
using IdentityShroud.Core.Services;
|
||||
|
|
@ -15,29 +12,28 @@ public static class HttpContextExtensions
|
|||
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
|
||||
}
|
||||
|
||||
// api: api/v1/realms/{realmId}/....
|
||||
// api: api/v1/realms/{realmId}/clients/{clientId}
|
||||
|
||||
|
||||
|
||||
public static class RealmApi
|
||||
{
|
||||
public static void MapRealmEndpoints(this IEndpointRouteBuilder erp)
|
||||
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
|
||||
{
|
||||
var realmsGroup = erp.MapGroup("/realms");
|
||||
var realmsGroup = erp.MapGroup("/api/v1/realms");
|
||||
realmsGroup.MapPost("", RealmCreate)
|
||||
.Validate<RealmCreateRequest>()
|
||||
.WithName("Create Realm")
|
||||
.Produces(StatusCodes.Status201Created);
|
||||
|
||||
var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}")
|
||||
.AddEndpointFilter<SlugValidationFilter>();
|
||||
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
|
||||
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
|
||||
.AddEndpointFilter<RealmIdValidationFilter>();
|
||||
|
||||
RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients");
|
||||
ClientApi.MapEndpoints(realmIdGroup);
|
||||
|
||||
|
||||
|
||||
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
|
||||
openidConnect.MapPost("auth", OpenIdConnectAuth);
|
||||
openidConnect.MapPost("token", OpenIdConnectToken);
|
||||
openidConnect.MapGet("jwks", OpenIdConnectJwks);
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
|
||||
|
|
@ -50,46 +46,4 @@ public static class RealmApi
|
|||
// TODO make helper to convert failure response to a proper HTTP result.
|
||||
return TypedResults.InternalServerError();
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
|
||||
string slug,
|
||||
[FromServices]IRealmService realmService,
|
||||
[FromServices]KeyMapper keyMapper,
|
||||
HttpContext context)
|
||||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
await realmService.LoadActiveKeys(realm);
|
||||
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
|
||||
}
|
||||
|
||||
private static Task OpenIdConnectToken(HttpContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static Task OpenIdConnectAuth(HttpContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
|
||||
string slug,
|
||||
[FromServices]IRealmService realmService,
|
||||
HttpContext context)
|
||||
{
|
||||
Realm realm = context.GetValidatedRealm();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using FluentValidation;
|
||||
using IdentityShroud.Core.Messages.Realm;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
|
||||
{
|
||||
private const string SlugPattern = @"^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$";
|
||||
|
||||
public RealmCreateRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.NotEqual(Guid.Empty).When(x => x.Id.HasValue);
|
||||
RuleFor(x => x.Slug)
|
||||
.Matches(SlugPattern).Unless(x => x.Slug is null);
|
||||
RuleFor(x => x.Name)
|
||||
.NotNull().Length(1, 255);
|
||||
}
|
||||
}
|
||||
33
IdentityShroud.Api/Apis/Validation/ValidateFilter.cs
Normal file
33
IdentityShroud.Api/Apis/Validation/ValidateFilter.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using FluentValidation;
|
||||
|
||||
namespace IdentityShroud.Api;
|
||||
|
||||
public class ValidateFilter<T> : IEndpointFilter where T : class
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
// Grab the deserialized argument (the DTO) from the context.
|
||||
// The order of arguments matches the order of parameters in the endpoint delegate.
|
||||
var dto = context.Arguments
|
||||
.FirstOrDefault(arg => arg is T) as T;
|
||||
|
||||
if (dto == null)
|
||||
return Results.BadRequest("Unable to read request body.");
|
||||
|
||||
// Resolve the matching validator from DI.
|
||||
var validator = context.HttpContext.RequestServices
|
||||
.GetService<IValidator<T>>();
|
||||
|
||||
if (validator != null)
|
||||
{
|
||||
var validationResult = await validator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
return Results.ValidationProblem(validationResult.ToDictionary());
|
||||
}
|
||||
|
||||
// Validation passed – continue to the actual handler.
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue