dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.19k stars 9.93k forks source link

Extensibility for Identity Endpoints (MapIdentity<TUser>) #50303

Open apanaligan opened 1 year ago

apanaligan commented 1 year ago

Is there an existing issue for this?

Is your feature request related to a problem? Please describe the problem.

No response

Describe the solution you'd like

After reading about the Identity API endpoints, I immediately got excited, tried it and was able to make it work after 10 minutes! (Yes! All identity features almost instantly created and working).

One thing came to mind is on up to what extent these endpoints are extensible (e.g. Decorator pattern) to support custom workflow mostly for registration or login?

I explored the code that creates the routes and it seems the behavior are directly added to the routes pipeline. For instance, the RegisterRequest DTO contains only Email and Password. Normally, you would like to throw in a few more fields captured in your registration process.

If I missed anything that relates to this feature, I would appreciate if somebody can point them out to me.

Additional context

No response

SteveSandersonMS commented 1 year ago

Thanks for the suggestion. We're filing this away for considering possible future enhancements.

ghost commented 9 months ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

halter73 commented 9 months ago

We are open to improving MapIdentityApi's extensibility, but we need to be careful to not to overly inhibit our ability to add functionality. Right now, we could add new service parameters to existing minimal route handlers and add entire new endpoints without breaking people, and it's important we can continue to do so.

Of course, rather than include all the service parameters in the decorator signatures, we could limit the decorators to only being Func<HttpContext, RequestDTO, Task<IResult>> for each endpoint, but what if you wanted to inspect the response DTO? You could do Func<HttpContext, RequestDTO, Task<ResponseDTO>>, but what if you wanted to return a different kind of response?

These decorators are strongly typed rather than just being a Delegate because MapIdentityApi relies on the new Request Delegate Generator which requires strong typing in order to get AOT support. Designing an API that would allow you to effectively change RegisterRequest and potentially other DTOs to require additional fields in a trim safe way seems challenging, but I'm open to suggestions.

And even if we got that all figured out, what if we added another endpoint after .NET 9 to allow you to register with an external access token instead of a password? Now any decorator that was wrapping the existing /register endpoint to enforce extra registration requirements would be skipped for people registering with external providers.

Trying to reuse the existing /register endpoint for this with its RegisterRequest would be problematic because Password is supposed to be non-nullable. We could argue that the access token is the Password and add a new optional bool to indicate what it really represents, but that would definitely muddy up the API.

A change to support external logins might need be opt-in anyway, but you could argue just adding the external auth provider should be a sufficient opt-in just like it is for AddDefaultUI Razor pages. However, that argument becomes worse if people are already adding extra registration requirements using decorators.

The good news is that since we made all the MapIdentityApi DTOs public, it's now much easier to copy the code into your project. You can take IdentityApiEndpointRouteBuilderExtensions.cs as-is and just rename MapIdentityApi<TUser> to MyMapIdentityApi<TUser> and call that instead. You do not need to copy any other files because it only calls into public API.

And if you really want to go with the decorator approach, you can write a custom endpoint filters which is now much easier with the DTOs being public. For example:

app.MapIdentityApi<MyUser>().AddEndpointFilterFactory((filterFactoryContext, next) =>
{
    var parameters = filterFactoryContext.MethodInfo.GetParameters();
    if (parameters.Length >= 1 && parameters[0].ParameterType == typeof(RegisterRequest))
    {
        return async invocationContext =>
        {
            // Validate some extra required parameters were included in the query string using
            // invocationContext.HttpContext.Request.Query or something.
            // ...
            return await next(invocationContext);
        };
    }

    return next; 
});

That said, I recommend copying IdentityApiEndpointRouteBuilderExtensions.cs for the most flexibility and robustness. Relying on non-public route handers to keep the exact same signature they currently have is bound to be fragile as it's not public API at least as of now.

Haeri commented 9 months ago

Apologies for the potentially silly question but can we not just have the identity endpoints exposed as regular functions? So we could write our own endpoints, have our own request and response bodies, do all the additional validation work on the custom properties, and then just call the base identity functions like register and pass the user object?

agdgb commented 9 months ago

Apologies for the potentially silly question but can we not just have the identity endpoints exposed as regular functions? So we could write our own endpoints, have our own request and response bodies, do all the additional validation work on the custom properties, and then just call the base identity functions like register and pass the user object?

This is exactly what I am looking for as in the previous features in MVC!

halter73 commented 9 months ago

Apologies for the potentially silly question but can we not just have the identity endpoints exposed as regular functions?

This is a very good question. I think maybe these should be exposed as regular functions. It's a nice in-between solution in case you don't want just the default behavior, but you also don't want to copy hundreds of lines of code.

The reason we didn't do it immediately in .NET 8 is because it would limit some of the changes we could make. For example, we'd potentially have to keep around legacy overloads of functions that don't accept all the parameters supported by future versions of the given endpoint.

This is the same basic reason the request and response DTOs were not public in early .NET 8 previews, but we switched course on that to improve customizability. If we feel people are happy enough with the current core implementation of these endpoint functions and there's enough demand, we'd certainly consider making these public. If someone wants to take a stab at and API proposal, please do.

Here's the "API proposal" issue template. Feel free to just copy the markdown into this issue if you want to keep the context. We can apply the right labels to this issue so it shows up on https://apireview.net/?g=aspnetcore for review.

Sander-Brilman commented 8 months ago

Being able to customize the endpoints would be great but given the issues mentioned i propose a different solution. What if only the option to disable specific endpoints was provided.

Image i would want custom logic inside my /register endpoint. instead of providing a delegate or something similar i could just disable the original endpoint and create my own endpoint with the same route. that would already solve alot of problems (for me atleast). That would also help with hiding endpoints that are unused by the application to enhance the security.

Would that be something feasible?

Haeri commented 8 months ago

Here is my attempt at extracting the base functions

MyIdentityApiEndpointRouteBuilderExtensions.cs ```cs // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Linq; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.Data; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Routing; public static class IdentityApiEndpoint { public const string PostRegister = "/register"; public const string PostLogin = "/login"; public const string PostRefresh = "refresh"; public const string GetConfirmEmail = "/confirmEmail"; public const string PostResendConfirmationEmail = "/resendConfirmationEmail"; public const string PostForgotPassword = "/forgotPassword"; public const string PostResetPassword = "/resetPassword"; public const string GetInfo = "/info"; public const string PostInfo = "/info"; public const string PostTwoFa = "/2fa"; } /// /// Provides extension methods for to add identity endpoints. /// public static class MyIdentityApiEndpointRouteBuilderExtensions { // Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true. private static readonly EmailAddressAttribute _emailAddressAttribute = new(); // private static readonly TimeProvider? timeProvider = null; // private static readonly IOptionsMonitor? bearerTokenOptions = null; // private static readonly IEmailSender? emailSender = null; private static readonly LinkGenerator? linkGenerator = null; // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation. private static string? confirmEmailEndpointName = null; private static async Task SendConfirmationEmailAsync(TUser user, UserManager userManager, HttpContext context, string email, bool isChange = false) where TUser : class, new() { if (confirmEmailEndpointName is null) { throw new NotSupportedException("No email confirmation endpoint was registered!"); } var code = isChange ? await userManager.GenerateChangeEmailTokenAsync(user, email) : await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var userId = await userManager.GetUserIdAsync(user); var routeValues = new RouteValueDictionary() { ["userId"] = userId, ["code"] = code, }; if (isChange) { // This is validated by the /confirmEmail endpoint on change. routeValues.Add("changedEmail", email); } var confirmEmailUrl = linkGenerator?.GetUriByName(context, confirmEmailEndpointName, routeValues) ?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); // await emailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(confirmEmailUrl)); } public static async Task> Register(IServiceProvider sp, RegisterRequest registration, HttpContext context, TUser? user) where TUser : class, new() { var userManager = sp.GetRequiredService>(); if (!userManager.SupportsUserEmail) { throw new NotSupportedException($"{nameof(MyMapIdentityApi)} requires a user store with email support."); } var userStore = sp.GetRequiredService>(); var emailStore = (IUserEmailStore)userStore; var email = registration.Email; if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email)) { return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email))); } user ??= new TUser(); await userStore.SetUserNameAsync(user, email, CancellationToken.None); await emailStore.SetEmailAsync(user, email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); if (!result.Succeeded) { return CreateValidationProblem(result); } //await SendConfirmationEmailAsync(user, userManager, context, email); return TypedResults.Ok(); } /// /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. /// /// The type describing the user. This should match the generic parameter in . /// /// The to add the identity endpoints to. /// Call to add a prefix to all the endpoints. /// /// An to further customize the added endpoints. public static IEndpointConventionBuilder MyMapIdentityApi(this IEndpointRouteBuilder endpoints, List? removedEndpoints = null) where TUser : class, new() { ArgumentNullException.ThrowIfNull(endpoints); var timeProvider = endpoints.ServiceProvider.GetRequiredService(); var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService>(); var emailSender = endpoints.ServiceProvider.GetRequiredService>(); var linkGenerator = endpoints.ServiceProvider.GetRequiredService(); var routeGroup = endpoints.MapGroup(""); // NOTE: We cannot inject UserManager directly because the TUser generic parameter is currently unsupported by RDG. // https://github.com/dotnet/aspnetcore/issues/47338 if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostRegister)) { routeGroup.MapPost(IdentityApiEndpoint.PostRegister, async Task> ([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => { return await Register(sp, registration, context, null); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostLogin)) { routeGroup.MapPost(IdentityApiEndpoint.PostLogin, async Task, EmptyHttpResult, ProblemHttpResult>> ([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); var useCookieScheme = (useCookies == true) || (useSessionCookies == true); var isPersistent = (useCookies == true) && (useSessionCookies != true); signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); if (result.RequiresTwoFactor) { if (!string.IsNullOrEmpty(login.TwoFactorCode)) { result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent); } else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode)) { result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode); } } if (!result.Succeeded) { return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); } // The signInManager already produced the needed response in the form of a cookie or bearer token. return TypedResults.Empty; }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostRefresh)) { routeGroup.MapPost(IdentityApiEndpoint.PostRefresh, async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector; var refreshTicket = refreshTokenProtector.Unprotect(refreshRequest.RefreshToken); // Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc || timeProvider.GetUtcNow() >= expiresUtc || await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not TUser user) { return TypedResults.Challenge(); } var newPrincipal = await signInManager.CreateUserPrincipalAsync(user); return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.GetConfirmEmail)) { routeGroup.MapGet(IdentityApiEndpoint.GetConfirmEmail, async Task> ([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.FindByIdAsync(userId) is not { } user) { // We could respond with a 404 instead of a 401 like Identity UI, but that feels like unnecessary information. return TypedResults.Unauthorized(); } try { code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); } catch (FormatException) { return TypedResults.Unauthorized(); } IdentityResult result; if (string.IsNullOrEmpty(changedEmail)) { result = await userManager.ConfirmEmailAsync(user, code); } else { // As with Identity UI, email and user name are one and the same. So when we update the email, // we need to update the user name. result = await userManager.ChangeEmailAsync(user, changedEmail, code); if (result.Succeeded) { result = await userManager.SetUserNameAsync(user, changedEmail); } } if (!result.Succeeded) { return TypedResults.Unauthorized(); } return TypedResults.Text("Thank you for confirming your email."); }) .Add(endpointBuilder => { var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText; confirmEmailEndpointName = $"{nameof(MyMapIdentityApi)}-{finalPattern}"; endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName)); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostResendConfirmationEmail)) { routeGroup.MapPost(IdentityApiEndpoint.PostResendConfirmationEmail, async Task ([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.FindByEmailAsync(resendRequest.Email) is not { } user) { return TypedResults.Ok(); } await SendConfirmationEmailAsync(user, userManager, context, resendRequest.Email); return TypedResults.Ok(); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostForgotPassword)) { routeGroup.MapPost(IdentityApiEndpoint.PostForgotPassword, async Task> ([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is not null && await userManager.IsEmailConfirmedAsync(user)) { var code = await userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code)); } // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have // returned a 400 for an invalid code given a valid user email. return TypedResults.Ok(); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostResetPassword)) { routeGroup.MapPost(IdentityApiEndpoint.PostResetPassword, async Task> ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is null || !(await userManager.IsEmailConfirmedAsync(user))) { // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have // returned a 400 for an invalid code given a valid user email. return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken())); } IdentityResult result; try { var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(resetRequest.ResetCode)); result = await userManager.ResetPasswordAsync(user, code, resetRequest.NewPassword); } catch (FormatException) { result = IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken()); } if (!result.Succeeded) { return CreateValidationProblem(result); } return TypedResults.Ok(); }); } var accountGroup = routeGroup.MapGroup("/manage").RequireAuthorization(); if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostTwoFa)) { accountGroup.MapPost(IdentityApiEndpoint.PostTwoFa, async Task, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); var userManager = signInManager.UserManager; if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (tfaRequest.Enable == true) { if (tfaRequest.ResetSharedKey) { return CreateValidationProblem("CannotResetSharedKeyAndEnable", "Resetting the 2fa shared key must disable 2fa until a 2fa token based on the new shared key is validated."); } else if (string.IsNullOrEmpty(tfaRequest.TwoFactorCode)) { return CreateValidationProblem("RequiresTwoFactor", "No 2fa token was provided by the request. A valid 2fa token is required to enable 2fa."); } else if (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, tfaRequest.TwoFactorCode)) { return CreateValidationProblem("InvalidTwoFactorCode", "The 2fa token provided by the request was invalid. A valid 2fa token is required to enable 2fa."); } await userManager.SetTwoFactorEnabledAsync(user, true); } else if (tfaRequest.Enable == false || tfaRequest.ResetSharedKey) { await userManager.SetTwoFactorEnabledAsync(user, false); } if (tfaRequest.ResetSharedKey) { await userManager.ResetAuthenticatorKeyAsync(user); } string[]? recoveryCodes = null; if (tfaRequest.ResetRecoveryCodes || (tfaRequest.Enable == true && await userManager.CountRecoveryCodesAsync(user) == 0)) { var recoveryCodesEnumerable = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); recoveryCodes = recoveryCodesEnumerable?.ToArray(); } if (tfaRequest.ForgetMachine) { await signInManager.ForgetTwoFactorClientAsync(); } var key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(key)) { await userManager.ResetAuthenticatorKeyAsync(user); key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(key)) { throw new NotSupportedException("The user manager must produce an authenticator key after reset."); } } return TypedResults.Ok(new TwoFactorResponse { SharedKey = key, RecoveryCodes = recoveryCodes, RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user), IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user), IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user), }); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.GetInfo)) { accountGroup.MapGet(IdentityApiEndpoint.GetInfo, async Task, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager)); }); } if (removedEndpoints == null || !removedEndpoints.Contains(IdentityApiEndpoint.PostInfo)) { accountGroup.MapPost(IdentityApiEndpoint.PostInfo, async Task, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (!string.IsNullOrEmpty(infoRequest.NewEmail) && !_emailAddressAttribute.IsValid(infoRequest.NewEmail)) { return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(infoRequest.NewEmail))); } if (!string.IsNullOrEmpty(infoRequest.NewPassword)) { if (string.IsNullOrEmpty(infoRequest.OldPassword)) { return CreateValidationProblem("OldPasswordRequired", "The old password is required to set a new password. If the old password is forgotten, use /resetPassword."); } var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword); if (!changePasswordResult.Succeeded) { return CreateValidationProblem(changePasswordResult); } } if (!string.IsNullOrEmpty(infoRequest.NewEmail)) { var email = await userManager.GetEmailAsync(user); if (email != infoRequest.NewEmail) { await SendConfirmationEmailAsync(user, userManager, context, infoRequest.NewEmail, isChange: true); } } return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager)); }); } return new IdentityEndpointsConventionBuilder(routeGroup); } private static ValidationProblem CreateValidationProblem(string errorCode, string errorDescription) => TypedResults.ValidationProblem(new Dictionary { { errorCode, [errorDescription] } }); private static ValidationProblem CreateValidationProblem(IdentityResult result) { // We expect a single error code and description in the normal case. // This could be golfed with GroupBy and ToDictionary, but perf! :P Debug.Assert(!result.Succeeded); var errorDictionary = new Dictionary(1); foreach (var error in result.Errors) { string[] newDescriptions; if (errorDictionary.TryGetValue(error.Code, out var descriptions)) { newDescriptions = new string[descriptions.Length + 1]; Array.Copy(descriptions, newDescriptions, descriptions.Length); newDescriptions[descriptions.Length] = error.Description; } else { newDescriptions = [error.Description]; } errorDictionary[error.Code] = newDescriptions; } return TypedResults.ValidationProblem(errorDictionary); } private static async Task CreateInfoResponseAsync(TUser user, UserManager userManager) where TUser : class { return new() { Email = await userManager.GetEmailAsync(user) ?? throw new NotSupportedException("Users must have an email."), IsEmailConfirmed = await userManager.IsEmailConfirmedAsync(user), }; } // Wrap RouteGroupBuilder with a non-public type to avoid a potential future behavioral breaking change. private sealed class IdentityEndpointsConventionBuilder(RouteGroupBuilder inner) : IEndpointConventionBuilder { private IEndpointConventionBuilder InnerAsConventionBuilder => inner; public void Add(Action convention) => InnerAsConventionBuilder.Add(convention); public void Finally(Action finallyConvention) => InnerAsConventionBuilder.Finally(finallyConvention); } [AttributeUsage(AttributeTargets.Parameter)] private sealed class FromBodyAttribute : Attribute, IFromBodyMetadata { } [AttributeUsage(AttributeTargets.Parameter)] private sealed class FromServicesAttribute : Attribute, IFromServiceMetadata { } [AttributeUsage(AttributeTargets.Parameter)] private sealed class FromQueryAttribute : Attribute, IFromQueryMetadata { public string? Name => null; } } ```

Some remarks:

  1. It is not complete. I have only extracted the register function
  2. I had to disable the email confirmation because there is still some plumbing to do. Specifically, I had to rescope certain variables and now I would have to make the entire class generic because of the TUser. Still looking for a good solution here.
  3. I have added an option to conditionally enable endpoints but had to resort to poor man's string enums.

But other than that it seems to be working fine. Here is my Program.cs setup:

// Disable the routes /register and /info
app.MapGroup("/auth").MyMapIdentityApi<AuthUser>([IdentityApiEndpoint.PostRegister, IdentityApiEndpoint.GetInfo]);

and here is my custom registration endpoint

[AllowAnonymous]
[HttpPost("register")]
public async Task<Results<Ok, ValidationProblem>> Register([FromBody] RegisterRequestDTO registration)
{
    if (string.IsNullOrEmpty(registration.FirstName.Trim()))
    {
        Dictionary<string, string[]> err = new() { { "RequiredFieldEmpty", ["First Name must not be empty"] } };
        return TypedResults.ValidationProblem(err);
    }
    if (string.IsNullOrEmpty(registration.LastName.Trim()))
    {
        Dictionary<string, string[]> err = new() { { "RequiredFieldEmpty", ["Last Name must not be empty"] } };
        return TypedResults.ValidationProblem(err);
    }

    AuthUser user = new()
    {
        FirstName = registration.FirstName,
        LastName = registration.LastName,
    };

    RegisterRequest reg = new()
    {
        Email = registration.Email,
        Password = registration.Password
    };

    return await MyIdentityApiEndpointRouteBuilderExtensions.Register(_serviceProvider, reg, HttpContext, user);
}
agdgb commented 8 months ago

Apologies for the potentially silly question but can we not just have the identity endpoints exposed as regular functions?

This is a very good question. I think maybe these should be exposed as regular functions. It's a nice in-between solution in case you don't want just the default behavior, but you also don't want to copy hundreds of lines of code.

The reason we didn't do it immediately in .NET 8 is because it would limit some of the changes we could make. For example, we'd potentially have to keep around legacy overloads of functions that don't accept all the parameters supported by future versions of the given endpoint.

This is the same basic reason the request and response DTOs were not public in early .NET 8 previews, but we switched course on that to improve customizability. If we feel people are happy enough with the current core implementation of these endpoint functions and there's enough demand, we'd certainly consider making these public. If someone wants to take a stab at and API proposal, please do.

Here's the "API proposal" issue template. Feel free to just copy the markdown into this issue if you want to keep the context. We can apply the right labels to this issue so it shows up on https://apireview.net/?g=aspnetcore for review.

"The reason we didn't do it immediately in .NET 8 is because it would limit some of the changes we could make. For example, we'd potentially have to keep around legacy overloads of functions that don't accept all the parameters supported by future versions of the given endpoint."

Does that mean it's considered to expose the full api methods in future? This will be amazing!!

toddlucas commented 1 month ago

I just wanted to say that copying the extension methods class into my project is a great bridge solution. Combined with the interface based stores with generic parameters for the entities, it gives me all the flexibility I need.