Skip to content

Avoid cookie login redirects for known API endpoints #62816

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/ApiEndpointMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http.Metadata;

/// <summary>
/// Metadata that indicates the endpoint is intended for API clients.
/// When present, authentication handlers should prefer returning status codes over browser redirects.
/// </summary>
internal sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
/// <summary>
/// Singleton instance of <see cref="ApiEndpointMetadata"/>.
/// </summary>
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}
}
12 changes: 12 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/IApiEndpointMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http.Metadata;

/// <summary>
/// Metadata that indicates the endpoint is an API intended for programmatic access rather than direct browser navigation.
/// When present, authentication handlers should prefer returning status codes over browser redirects.
/// </summary>
public interface IApiEndpointMetadata
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Extensions" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Results" />
</ItemGroup>
Expand All @@ -61,5 +60,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Http.Metadata.IApiEndpointMetadata
Microsoft.AspNetCore.Http.Metadata.IDisableValidationMetadata
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string?
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)

if (hasFormBody)
{
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataType);
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataClass);
}

if (hasJsonBody || hasResponseMetadata)
{
codeWriter.WriteLine(RequestDelegateGeneratorSources.ApiEndpointMetadataClass);
}

if (hasFormBody || hasJsonBody || hasResponseMetadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,19 +479,39 @@ internal ParameterBindingMetadata(
}
""";

public static string AntiforgeryMetadataType = """
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
{
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);

public AntiforgeryMetadata(bool requiresValidation)
public static string AntiforgeryMetadataClass = """
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
{
RequiresValidation = requiresValidation;
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);

public AntiforgeryMetadata(bool requiresValidation)
{
RequiresValidation = requiresValidation;
}

public bool RequiresValidation { get; }
}
""";

public bool RequiresValidation { get; }
}
public static string ApiEndpointMetadataClass = """
file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
""";

public static string GetGeneratedRouteBuilderExtensionsSource(string endpoints, string helperMethods, string helperTypes, ImmutableHashSet<string> verbs) => $$"""
{{SourceHeader}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
return;
}

if (!endpoint.Response.IsAwaitable && (response.HasNoResponse || response.IsIResult))
if (response.HasNoResponse || response.IsIResult)
{
return;
}
Expand All @@ -215,13 +215,10 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(string), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
}
else if (response.IsAwaitable && response.ResponseType == null)
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(void), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
}
else if (response.ResponseType is { } responseType)
{
codeWriter.WriteLine($$"""options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof({{responseType.ToDisplayString(EmitterConstants.DisplayFormatWithoutNullability)}}), contentTypes: GeneratedMetadataConstants.JsonContentType));""");
codeWriter.WriteLine("ApiEndpointMetadata.AddApiEndpointMetadataIfMissing(options.EndpointBuilder);");
}
}

Expand Down Expand Up @@ -339,13 +336,15 @@ public static void EmitJsonAcceptsMetadata(this Endpoint endpoint, CodeWriter co
codeWriter.WriteLine("if (!serviceProviderIsService.IsService(type))");
codeWriter.StartBlock();
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(type: type, isOptional: isOptional, contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
codeWriter.WriteLine("break;");
codeWriter.EndBlock();
codeWriter.EndBlock();
}
else
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
}
}

Expand Down
28 changes: 19 additions & 9 deletions src/Http/Http.Extensions/src/RequestDelegateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,14 @@ private static Expression[] CreateArgumentsAndInferMetadata(MethodInfo methodInf
InferAntiforgeryMetadata(factoryContext);
}

PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder);
// If this endpoint expects a JSON request body, we assume its an API endpoint not intended for browser navigation.
// When present, authentication handlers should prefer returning status codes over browser redirects.
if (factoryContext.JsonRequestBodyParameter is not null)
{
factoryContext.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);
}

PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext);

// Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above
EndpointMetadataPopulator.PopulateMetadata(methodInfo, factoryContext.EndpointBuilder, factoryContext.Parameters);
Expand Down Expand Up @@ -1023,37 +1030,40 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(Type retu
return Expression.Block(localVariables, checkParamAndCallMethod);
}

private static void PopulateBuiltInResponseTypeMetadata(Type returnType, EndpointBuilder builder)
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, RequestDelegateFactoryContext factoryContext)
{
if (returnType.IsByRefLike)
{
throw GetUnsupportedReturnTypeException(returnType);
}

var isAwaitable = false;
if (CoercedAwaitableInfo.IsTypeAwaitable(returnType, out var coercedAwaitableInfo))
{
returnType = coercedAwaitableInfo.AwaitableInfo.ResultType;
isAwaitable = true;
}

// Skip void returns and IResults. IResults might implement IEndpointMetadataProvider but otherwise we don't know what it might do.
if (!isAwaitable && (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType)))
if (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType))
{
return;
}

var builder = factoryContext.EndpointBuilder;

if (returnType == typeof(string))
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: typeof(string), statusCode: 200, PlaintextContentType));
}
else if (returnType == typeof(void))
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, PlaintextContentType));
}
else
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, DefaultAcceptsAndProducesContentType));

if (factoryContext.JsonRequestBodyParameter is null)
{
// Since this endpoint responds with JSON, we assume its an API endpoint not intended for browser navigation,
// but we don't want to bother adding this metadata twice if we've already inferred it based on the expected JSON request body.
builder.Metadata.Add(ApiEndpointMetadata.Instance);
}
}
}

Expand Down
27 changes: 15 additions & 12 deletions src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2533,7 +2533,7 @@ public void Create_AddJsonResponseType_AsMetadata()
var @delegate = () => new object();
var result = RequestDelegateFactory.Create(@delegate);

var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
var responseMetadata = Assert.Single(result.EndpointMetadata.OfType<IProducesResponseTypeMetadata>());

Assert.Equal("application/json", Assert.Single(responseMetadata.ContentTypes));
Assert.Equal(typeof(object), responseMetadata.Type);
Expand All @@ -2545,7 +2545,7 @@ public void Create_AddPlaintextResponseType_AsMetadata()
var @delegate = () => "Hello";
var result = RequestDelegateFactory.Create(@delegate);

var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
var responseMetadata = Assert.Single(result.EndpointMetadata.OfType<IProducesResponseTypeMetadata>());

Assert.Equal("text/plain", Assert.Single(responseMetadata.ContentTypes));
Assert.Equal(typeof(string), responseMetadata.Type);
Expand Down Expand Up @@ -2683,6 +2683,7 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromReturnTypesImplementin

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}
Expand All @@ -2705,9 +2706,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromTaskWrappedReturnTypes

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is ProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
// Expecting the custom metadata and the implicit metadata associated with a Task-based return type to be inserted
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand All @@ -2728,9 +2729,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromValueTaskWrappedReturn

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is ProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
// Expecting the custom metadata nad hte implicit metadata associated with a Task-based return type to be inserted
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand All @@ -2751,9 +2752,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromFSharpAsyncWrappedRetu

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand Down Expand Up @@ -2824,14 +2825,16 @@ public void Create_CombinesAllMetadata_InCorrectOrder()
m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)),
// Inferred ParameterBinding metadata
m => Assert.True(m is IParameterBindingMetadata { Name: "param1" }),
// Inferred ProducesResopnseTypeMetadata from RDF for complex type
// Inferred IApiEndpointMetadata from RDF for complex request and response type
m => Assert.True(m is IApiEndpointMetadata),
// Inferred ProducesResponseTypeMetadata from RDF for complex type
m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type),
// Metadata provided by parameters implementing IEndpointParameterMetadataProvider
m => Assert.True(m is ParameterNameMetadata { Name: "param1" }),
// Metadata provided by parameters implementing IEndpointMetadataProvider
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }),
// Metadata provided by return type implementing IEndpointMetadataProvider
m => Assert.True(m is MetadataCountMetadata { Count: 6 }));
m => Assert.True(m is MetadataCountMetadata { Count: 7 }));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Loading
Loading