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:
parent
1a8c63808a
commit
8782ef39c6
80 changed files with 1331 additions and 414 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
3
IdentityShroud.Api/Apis/Dto/ErrorDto.cs
Normal file
3
IdentityShroud.Api/Apis/Dto/ErrorDto.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
namespace IdentityShroud.Api.Apis;
|
||||
|
||||
public record ErrorDto(string Error);
|
||||
6
IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
Normal file
6
IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace IdentityShroud.Api.Apis;
|
||||
|
||||
public record RealmRepresentation(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name);
|
||||
21
IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
Normal file
21
IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
50
IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
Normal file
50
IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue