Still working on getting client credential flow complete, most of the request works but still working on generating the JWT.

This commit is contained in:
eelke 2026-03-16 19:15:04 +01:00
parent 1a8c63808a
commit 8782ef39c6
80 changed files with 1331 additions and 414 deletions

View file

@ -5,11 +5,7 @@ using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public record ClientCreateReponse(int Id, string ClientId);
namespace IdentityShroud.Api.Apis;
/// <summary>
/// The part of the api below realms/{slug}/clients
@ -20,12 +16,12 @@ public static class ClientApi
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
.Produces(StatusCodes.Status201Created)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate");
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>();
@ -43,11 +39,12 @@ public static class ClientApi
return TypedResults.Ok(new ClientMapper().ToDto(client));
}
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
private static async Task<Results<CreatedAtRoute<ClientRepresentation>, InternalServerError>>
ClientCreate(
Guid realmId,
ClientCreateRequest request,
[FromServices] IClientService service,
[FromServices] IDataEncryptionService cryptor,
HttpContext context,
CancellationToken cancellationToken)
{
@ -60,9 +57,12 @@ public static class ClientApi
}
Client client = result.Value;
ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client);
var secret = SelectBestSecret(client.Secrets);
if (secret is {} s)
clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret);
return TypedResults.CreatedAtRoute(
new ClientCreateReponse(client.Id, client.ClientId),
clientRepresentation,
ClientGetRouteName,
new RouteValueDictionary()
{
@ -70,4 +70,28 @@ public static class ClientApi
["clientId"] = client.Id,
});
}
private static ClientSecret? SelectBestSecret(List<ClientSecret> clientSecrets)
{
ClientSecret? result = null;
foreach (var cs in clientSecrets)
{
if (cs.RevokedAt is null && (!cs.Expires.HasValue || cs.Expires.Value > DateTime.UtcNow))
{
if (result is null)
{
result = cs;
}
else
{
int d = (cs.Expires ?? DateTime.MaxValue).CompareTo(result.Expires ?? DateTime.MaxValue);
if (d > 0 || (d == 0 && cs.CreatedAt > result.CreatedAt))
result = cs;
}
}
}
return result;
}
}

View file

@ -10,7 +10,10 @@ public record ClientRepresentation
public string? SignatureAlgorithm { get; set; }
public bool Confidential { get; set; }
public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; }
public string? Secret { get; set; }
}

View file

@ -0,0 +1,3 @@
namespace IdentityShroud.Api.Apis;
public record ErrorDto(string Error);

View file

@ -0,0 +1,6 @@
namespace IdentityShroud.Api.Apis;
public record RealmRepresentation(
Guid Id,
string Slug,
string Name);

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.DTO.OpenId;
public class TokenRequestBody
{
[JsonPropertyName("grant_type")]
public GrantTypes GrantType { get; init; }
/// <summary>
/// In most cases required but not when basic auth header is used
/// </summary>
[JsonPropertyName("client_id")]
public string? ClientId { get; init; } = "";
[JsonPropertyName("client_secret")]
public string? ClientSecret { get; init; }
[JsonPropertyName("scope")]
public string? Scope { get; init; }
}

View file

@ -2,9 +2,10 @@ namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions
{
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
public static IEndpointConventionBuilder Validate<TDto>(this IEndpointConventionBuilder builder)
where TDto : class
=> builder.AddEndpointFilter<IEndpointConventionBuilder, ValidateFilter<TDto>>();
public static void MapApis(this IEndpointRouteBuilder erp)
{
RealmApi.MapRealmEndpoints(erp);

View file

@ -0,0 +1,50 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Microsoft.Extensions.Primitives;
namespace IdentityShroud.Api.Helpers;
public static class HeaderHelpers
{
public static bool TryGetBasicAuth(
HttpContext context,
[NotNullWhen(true)] out string? user,
[NotNullWhen(true)] out string? password)
{
var headers = context?.Request.Headers;
if (headers is not null)
{
if (headers.TryGetValue("Authorization", out StringValues s))
return TryDecodeBasicAuth(s.ToString(), out user, out password);
}
user = password = null;
return false;
}
public static bool TryDecodeBasicAuth(
string authorizationHeader,
[NotNullWhen(true)] out string? user,
[NotNullWhen(true)] out string? password)
{
if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase))
{
ReadOnlySpan<char> val = authorizationHeader.AsSpan(6); // basic + space
Span<byte> b = new byte[(val.Length * 6 / 8) + 1];
if (Convert.TryFromBase64Chars(val, b, out int written))
{
int sepIdx = b.IndexOf((byte)':');
if (sepIdx > 0 && sepIdx < written - 1)
{
user = Encoding.UTF8.GetString(b.Slice(0, sepIdx));
password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1)));
return true;
}
}
}
user = password = null;
return false;
}
}

View file

@ -0,0 +1,38 @@
namespace IdentityShroud.Api.Apis.ISResults;
public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult
{
private readonly List<string> _wwwAuthenticateValues;
/// <summary>
/// Initializes a new instance of the <see cref="UnauthorizedHttpResult"/> class.
/// </summary>
internal ISUnauthorizedHttpResult(List<string> wwwAuthenticateValues)
{
_wwwAuthenticateValues = wwwAuthenticateValues;
}
/// <summary>
/// Gets the HTTP status code: <see cref="StatusCodes.Status401Unauthorized"/>
/// </summary>
public int StatusCode => StatusCodes.Status401Unauthorized;
int? IStatusCodeHttpResult.StatusCode => StatusCode;
/// <inheritdoc />
public Task ExecuteAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
// Creating the logger with a string to preserve the category after the refactoring.
// var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
// var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult");
// HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode);
httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray());
httpContext.Response.StatusCode = StatusCode;
return Task.CompletedTask;
}
}

View file

@ -1,20 +1,28 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IKeyService keyService)
public class KeyMapper(IKeyProviderFactory keyProviderFactory)
{
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmSigningKey> keys)
{
JsonWebKeySet wks = new();
foreach (var k in keys)
{
var wk = keyService.CreateJsonWebKey(k);
if (wk is {})
IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType);
if (provider.IsPublic)
{
wks.Keys.Add(wk);
JsonWebKey jwk = new()
{
KeyId = k.Id.ToString(),
KeyType = k.KeyType,
Use = "sig",
};
provider.SetJwkParameters(k.PublicKeyParameters!, jwk);
wks.Keys.Add(jwk);
}
}
return wks;

View file

@ -1,7 +1,11 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Api.Apis.ISResults;
using IdentityShroud.Api.Helpers;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services.OpenId;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@ -11,8 +15,6 @@ 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");
@ -56,17 +58,79 @@ public static class OpenIdEndpoints
{
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys));
}
private static Task OpenIdConnectToken(HttpContext context)
private static async Task<Results<
Ok<TokenResponse>,
BadRequest<ErrorDto>,
ISUnauthorizedHttpResult
>> OpenIdConnectToken(
string realmSlug,
[FromServices] IClientService clientService,
HttpContext context,
CancellationToken ct)
{
throw new NotImplementedException();
IFormCollection form = await context.Request.ReadFormAsync();
string grantType = form["grant_type"].ToString();
string clientId = form["client_id"].ToString();
string scope = form["scope"].ToString();
if (grantType == "client_credentials")
{
string? clientSecret = null;
bool withAuthHeader = false;
if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password))
{
withAuthHeader = true;
clientId = user;
clientSecret = password;
}
clientSecret ??= form["client_secret"].ToString();
if (string.IsNullOrEmpty(clientId) ||
string.IsNullOrEmpty(clientSecret))
{
return CreateBadRequest("invalid_request");
}
Realm realm = context.GetValidatedRealm();
Client? client = await clientService.GetByClientId(realm.Id, clientId, ct);
if (client is null)
{
if (withAuthHeader)
{
return new ISUnauthorizedHttpResult([$"Basic realm=\"{realm.Slug}\""]);
}
return CreateBadRequest("invalid_client");
}
if (!client.AllowClientCredentialsFlow)
return CreateBadRequest("unauthorized_client");
}
else
return CreateBadRequest("unsupported_grant_type");
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers.Pragma = "no-cache";
return TypedResults.Ok(new TokenResponse()
{
AccessToken = "token",
TokenType = "bearer",
ExpiresIn = 3600,
});
}
private static BadRequest<ErrorDto> CreateBadRequest(string error) =>
TypedResults.BadRequest(new ErrorDto(error));
private static Task OpenIdConnectAuth(HttpContext context)
{
throw new NotImplementedException();
}
}

View file

@ -1,7 +1,7 @@
using IdentityShroud.Api.Apis;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@ -19,31 +19,56 @@ public static class HttpContextExtensions
public static class RealmApi
{
public const string GetRealmRoute = "Get Realm";
public const string CreateRealmRoute = "Create Realm";
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
{
var realmsGroup = erp.MapGroup("/api/v1/realms");
realmsGroup.MapPost("", RealmCreate)
.Validate<RealmCreateRequest>()
.WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
.Produces(StatusCodes.Status201Created)
.Validate<RealmCreateRequest>()
.WithName(CreateRealmRoute);
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
.AddEndpointFilter<RealmIdValidationFilter>();
ClientApi.MapEndpoints(realmIdGroup);
realmIdGroup.MapGet("", RealmGet)
.WithName(GetRealmRoute);
ClientApi.MapEndpoints(realmIdGroup);
}
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
private static Ok<RealmRepresentation> RealmGet(
Guid realmId,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
return TypedResults.Ok(MapToRepresentation(realm));
}
private static async Task<Results<CreatedAtRoute<RealmRepresentation>, InternalServerError>>
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
{
var response = await service.Create(request);
if (response.IsSuccess)
return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value);
{
var realm = response.Value;
return TypedResults.CreatedAtRoute(
MapToRepresentation(realm),
GetRealmRoute,
new { realmId = realm.Id });
}
// TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError();
}
}
private static RealmRepresentation MapToRepresentation(Realm realm)
=> new(realm.Id, realm.Slug, realm.Name);
}

View file

@ -6,9 +6,9 @@ namespace IdentityShroud.Api;
public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest>
{
// most of standard ascii minus the control characters and space
private const string ClientIdPattern = "^[\x21-\x7E]+";
private const string ClientIdPattern = "^[a-zA-Z0-9_-]+";
private string[] AllowedAlgorithms = [ "RS256", "ES256" ];
private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ];
public ClientCreateRequestValidator()
{
@ -16,7 +16,9 @@ public class ClientCreateRequestValidator : AbstractValidator<ClientCreateReques
RuleFor(e => e.Name).MaximumLength(80);
RuleFor(e => e.Description).MaximumLength(2048);
RuleFor(e => e.SignatureAlgorithm)
.Must(v => v is null || AllowedAlgorithms.Contains(v))
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null");
.Must(v => v is null || _allowedAlgorithms.Contains(v))
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null");
RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true);
RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true);
}
}