Start Debugging

Como remover ou ocultar endpoints individuais que o MapIdentityApi mapeia no ASP.NET Core

MapIdentityApi<TUser>() mapeia dez endpoints e não tem nenhuma opção para pular um deles. Estas são quatro formas medidas de lidar com isso no .NET 11: ocultar um endpoint do OpenAPI, fazê-lo retornar 404, sobrepô-lo ou removê-lo completamente do roteamento, além da armadilha do /confirmEmail que transforma o registro em um 500.

Resposta curta: MapIdentityApi<TUser>() não tem nenhum parâmetro para escolher endpoints, e isso continua valendo no .NET 11 RC 2. O que você recebe de volta é um IEndpointConventionBuilder, cujas convenções rodam em cada endpoint com o padrão de rota final já disponível, então você pode identificar /register ou /manage/2fa pelo padrão. Para ocultar um endpoint apenas do documento OpenAPI, adicione ExcludeFromDescriptionAttribute em uma convenção. Para torná-lo inutilizável, substitua também EndpointBuilder.RequestDelegate por um que retorne 404. Para removê-lo completamente do roteamento, mapeie o Identity em um IEndpointRouteBuilder privado e reexponha seus endpoints por meio de um EndpointDataSource filtrante (cerca de 30 linhas, mostradas abaixo). Nunca remova /confirmEmail enquanto /register ou /resendConfirmationEmail estiverem ativos: ambos lançam NotSupportedException e retornam 500.

Tudo abaixo foi executado no macOS com o SDK 11.0.100-rc.1.26425.128, Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128 e um store InMemory do EF Core. Comparei IdentityApiEndpointRouteBuilderExtensions.cs entre as tags v8.0.0, v11.0.0-rc.1.26425.128 e v11.0.0-rc.2.26504.105: a lista de rotas é idêntica nas três, e o arquivo não mudou entre o RC 1 e o RC 2, então as técnicas se aplicam ao .NET 8, 9, 10 e 11.

Por que não existe uma forma nativa de pular um endpoint

MapIdentityApi<TUser>() é um único método de extensão que cria um grupo de rotas vazio e mapeia tudo dentro dele:

MétodoRotaExige autenticação
POST/registernão
POST/loginnão
POST/refreshnão
GET/confirmEmailnão
POST/resendConfirmationEmailnão
POST/forgotPasswordnão
POST/resetPasswordnão
POST/manage/2fasim
GET/manage/infosim
POST/manage/infosim

Em seguida, o código-fonte envolve o grupo em um tipo privado IdentityEndpointsConventionBuilder, com um comentário dizendo que faz isso “to avoid a potential future behavioral breaking change”. Você não consegue converter o valor de retorno de volta para RouteGroupBuilder, e os handlers são lambdas dentro do método, então também não dá para chamá-los você mesmo.

O pedido para dividi-lo está aberto desde 2024 como dotnet/aspnetcore#55792 (“Split MapIdentityApi() into multiple APIs and allow selective end points to be enabled”), e o dotnet/aspnetcore#50303, mais amplo, sobre extensibilidade remonta às previews do .NET 8. Ambos estão no milestone Backlog. Até que um deles seja entregue, os motivos usuais que levam as pessoas a esbarrar nisso (o cadastro público precisa ficar desligado, a 2FA é tratada em outro lugar, o documento OpenAPI não deve anunciar a redefinição de senha) precisam ser tratados todos pelo lado de fora.

O ambiente de teste

Este é o app em que cada variante abaixo se encaixa. Os endpoints do Identity ficam em /account, porque é assim que a maioria dos apps reais os monta e porque isso afeta como você identifica as rotas:

// .NET 11 RC 1, Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ids"));
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();
builder.Services.AddOpenApi();

var app = builder.Build();
app.MapOpenApi();

var account = app.MapGroup("/account");
account.MapIdentityApi<IdentityUser>();

app.Run();

class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<IdentityUser>(options);

Sem nenhuma alteração, /openapi/v1.json lista as dez rotas em /account, POST /account/register retorna 200 e um POST /account/manage/2fa anônimo retorna 401.

Um detalhe importa para todas as técnicas abaixo: dentro de uma convenção, RouteEndpointBuilder.RoutePattern.RawText é o padrão final, com o prefixo do grupo incluído. Registrá-lo em log a partir de uma convenção imprime /account/register, /account/manage/2fa e assim por diante, não /register. O próprio Identity depende disso ao nomear o endpoint de confirmação de e-mail. Portanto, compare com EndsWith ou com o caminho completo com prefixo:

// .NET 11 RC 1
string[] blocked = ["/register", "/manage/2fa"];

bool IsBlocked(EndpointBuilder b) =>
    b is RouteEndpointBuilder rb &&
    blocked.Any(p => rb.RoutePattern.RawText!.EndsWith(p, StringComparison.OrdinalIgnoreCase));

Opção 1: ocultar um endpoint apenas do documento OpenAPI

Se o endpoint deve continuar funcionando, mas ficar fora do seu contrato publicado (comum para /refresh ou /manage/info em um documento de API público), adicione o mesmo metadado que .ExcludeFromDescription() adiciona:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (IsBlocked(b))
        b.Metadata.Add(new ExcludeFromDescriptionAttribute());
});

Resultado medido: /account/register e /account/manage/2fa desaparecem de /openapi/v1.json, e POST /account/register continua retornando 200. Esse é o objetivo desta opção, e também o seu limite. Ocultar é documentação, não controle de acesso. Qualquer pessoa que tenha lido a documentação do Identity sabe que a rota existe.

ExcludeFromDescriptionAttribute fica em Microsoft.AspNetCore.Routing e implementa IExcludeFromDescriptionMetadata, que tanto o gerador embutido Microsoft.AspNetCore.OpenApi quanto o API explorer do Swashbuckle respeitam. Se preferir manter tudo isso em um só lugar, você pode fazer a mesma filtragem em um document transformer, como mostrado no post sobre transformers de operação e de schema do OpenAPI, mas a abordagem por metadado é mais simples e também cobre outros consumidores do API explorer.

Opção 2: manter a rota, mas fazê-la retornar 404

Para realmente desligar um endpoint, substitua o request delegate dele na mesma convenção. As convenções recebem o EndpointBuilder antes de o RouteEndpoint ser construído, e RequestDelegate é uma propriedade com setter:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (!IsBlocked(b)) return;

    b.Metadata.Add(new ExcludeFromDescriptionAttribute());
    b.RequestDelegate = ctx =>
    {
        ctx.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.CompletedTask;
    };
});

Resultado medido: POST /account/register retorna 404, ambas as rotas somem do documento OpenAPI, e todos os outros endpoints se comportam como antes. Obtive o mesmo resultado com .Finally(...) no lugar de .Add(...). As convenções Finally rodam depois de todas as outras, o que é a escolha mais segura se algo mais no seu pipeline também mexer em RequestDelegate.

O problema é que o endpoint ainda existe para o roteamento e a autorização. Ele mantém todos os seus metadados, incluindo o RequireAuthorization() que o Identity coloca no grupo /manage. O middleware de autorização roda antes do delegate do endpoint, então um POST /account/manage/2fa anônimo ainda retorna 401, não 404. Apenas chamadores autenticados chegam ao seu 404. Se o objetivo é “não revelar que isso existe”, essa diferença é visível de fora.

Um endpoint filter leva ao mesmo resultado, porque AddEndpointFilter funciona em qualquer IEndpointConventionBuilder:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().AddEndpointFilter(async (ctx, next) =>
{
    var route = (ctx.HttpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText ?? "";
    if (blocked.Any(p => route.EndsWith(p))) return Results.NotFound();
    return await next(ctx);
});

Ele também retornou 404 para /account/register, mas aqui é estritamente pior: o filtro roda em toda requisição do Identity, os endpoints continuam no documento OpenAPI a menos que você adicione também o metadado, e o model binding do corpo da requisição já aconteceu quando o filtro roda. A comparação entre endpoint filters e middleware explica essa ordem com mais detalhes. Use a troca do delegate.

Opção 3: sobrepor a rota com um endpoint de maior prioridade

Muitas respostas online sugerem mapear seu próprio endpoint na mesma rota. Mapeado de forma ingênua, com account.MapPost("/register", () => Results.NotFound()) depois de MapIdentityApi, isso não tem utilidade: eu esperava uma AmbiguousMatchException, mas um POST /account/register em JSON ainda retornou 200 do handler do Identity. O endpoint do Identity faz bind de um parâmetro [FromBody], então carrega metadados de accepts application/json, e a matcher policy de content-type o classifica acima do seu endpoint, que não declara nada. Seu endpoint nunca roda, em silêncio. Dê a ele um Order menor e o matcher o prefere antes de qualquer desempate:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>();
account.MapPost("/register", () => Results.NotFound())
    .WithOrder(-1)
    .ExcludeFromDescription();

Resultado medido: POST /account/register retorna 404, e a lista de endpoints agora contém tanto POST /account/register order=0 quanto POST /account/register order=-1. O endpoint original continua no documento OpenAPI, porque ExcludeFromDescription() se aplica apenas ao seu endpoint sobreposto. Você precisa da opção 1 por cima para ocultá-lo.

A sobreposição é a ferramenta certa quando você quer substituir um endpoint em vez de removê-lo, por exemplo um /register que também exige um código de convite, enquanto o resto de MapIdentityApi continua padrão. Para uma remoção simples, é a mais bagunçada das quatro opções.

Opção 4: remover endpoints do roteamento por completo

Para fazer um endpoint realmente não existir (sem rota, sem entrada no OpenAPI, sem 401 para chamadores anônimos, apenas o mesmo 404 de qualquer URL desconhecida), você precisa impedir que o RouteEndpoint dele chegue ao sistema de roteamento. MapIdentityApi usa apenas dois membros do IEndpointRouteBuilder que você passa: ServiceProvider, para resolver seus serviços, e DataSources, onde MapGroup("") adiciona sua fonte de endpoints. Isso lhe dá um ponto de apoio:

  1. Passe para MapIdentityApi um pequeno IEndpointRouteBuilder seu, cuja coleção DataSources seja privada.
  2. Envolva essa coleção privada em um EndpointDataSource que descarta os endpoints indesejados.
  3. Adicione o wrapper aos DataSources do construtor de rotas real.
// .NET 11 RC 1, works the same on .NET 8, 9 and 10
using Microsoft.Extensions.Primitives;

static class IdentityApiExceptExtensions
{
    public static IEndpointConventionBuilder MapIdentityApiExcept<TUser>(
        this IEndpointRouteBuilder endpoints, params string[] excludedRoutes)
        where TUser : class, new()
    {
        var capture = new CapturingRouteBuilder(endpoints);
        var conventions = capture.MapIdentityApi<TUser>();

        bool Keep(Endpoint e) =>
            e is not RouteEndpoint re ||
            !excludedRoutes.Any(r =>
                re.RoutePattern.RawText!.EndsWith(r, StringComparison.OrdinalIgnoreCase));

        endpoints.DataSources.Add(new FilteringEndpointDataSource(capture.DataSources, Keep));
        return conventions;
    }

    private sealed class CapturingRouteBuilder(IEndpointRouteBuilder inner) : IEndpointRouteBuilder
    {
        public ICollection<EndpointDataSource> DataSources { get; } = new List<EndpointDataSource>();
        public IServiceProvider ServiceProvider => inner.ServiceProvider;
        public IApplicationBuilder CreateApplicationBuilder() => inner.CreateApplicationBuilder();
    }

    private sealed class FilteringEndpointDataSource(
        ICollection<EndpointDataSource> sources, Func<Endpoint, bool> keep) : EndpointDataSource
    {
        private readonly CompositeEndpointDataSource _inner = new(sources);

        public override IReadOnlyList<Endpoint> Endpoints =>
            _inner.Endpoints.Where(keep).ToList();

        public override IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context) =>
            _inner.GetGroupedEndpoints(context).Where(keep).ToList();

        public override IChangeToken GetChangeToken() => _inner.GetChangeToken();
    }
}

O uso é uma mudança de uma linha:

// .NET 11 RC 1
var account = app.MapGroup("/account");
account.MapIdentityApiExcept<IdentityUser>("/register", "/manage/2fa");

Resultado medido: a lista de endpoints tem oito entradas em vez de dez, /openapi/v1.json lista oito caminhos, e tanto POST /account/register quanto um POST /account/manage/2fa anônimo retornam 404. Os outros oito endpoints funcionam como antes, e o construtor de convenções retornado ainda se aplica a eles, então .RequireRateLimiting(...) ou .WithTags(...) encadeados no resultado se comportam normalmente.

Duas partes desse código são fáceis de errar:

Isso depende de MapIdentityApi usar apenas ServiceProvider e DataSources. Isso é verdade em todas as versões do 8.0 ao 11 RC 2, mas é um detalhe de implementação. Uma versão futura que, por exemplo, chamasse CreateApplicationBuilder() ainda funcionaria por meio da delegação acima. Uma que mapeie para algum outro construtor não funcionaria. Um teste que verifique a lista de rotas esperada (o dump de IEnumerable<EndpointDataSource> que usei tem três linhas) vai detectar isso na atualização.

A armadilha do /confirmEmail

O único endpoint que você não pode remover isoladamente é GET /confirmEmail. /register, /resendConfirmationEmail e o ramo de troca de e-mail de POST /manage/info geram o link de confirmação pelo nome do endpoint, com LinkGenerator.GetUriByName. O nome é atribuído em uma convenção Finally em /confirmEmail, como MapIdentityApi- seguido do padrão de rota final.

Com a opção 4 e MapIdentityApiExcept<IdentityUser>("/confirmEmail"), a convenção ainda roda (o endpoint é construído e depois filtrado), então o nome é definido, mas o gerador de links não encontra nenhum endpoint com esse nome. O registro então falha:

System.NotSupportedException: Could not find endpoint named 'MapIdentityApi-/account/confirmEmail'.

Resultado medido: POST /account/register retorna 500 e POST /account/resendConfirmationEmail também, enquanto /login continua funcionando. Se você não quer confirmação de e-mail por esta API, remova /register e /resendConfirmationEmail junto com /confirmEmail, ou mantenha /confirmEmail roteado e apenas oculte-o do OpenAPI com a opção 1.

A opção 2 não tem esse problema, porque o endpoint continua existindo para a geração de links. Ele apenas responde 404.

Qual opção usar

Se você já está no ponto de substituir vários endpoints, compare antes os prós e contras em JWT vs autenticação por cookie no ASP.NET Core 11. Os bearer tokens que o MapIdentityApi emite são tokens opacos do Data Protection, não JWTs, e esse costuma ser o verdadeiro motivo pelo qual uma equipe supera esses endpoints.

Armadilhas

Relacionado

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar