Skip to content

Commit cc6b317

Browse files
authored
Avoid cookie login redirects for known API endpoints (#62816)
1 parent c56ce2f commit cc6b317

File tree

110 files changed

+1170
-71
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+1170
-71
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http.Metadata;
5+
6+
/// <summary>
7+
/// Metadata that indicates the endpoint is intended for API clients.
8+
/// When present, authentication handlers should prefer returning status codes over browser redirects.
9+
/// </summary>
10+
internal sealed class ApiEndpointMetadata : IApiEndpointMetadata
11+
{
12+
/// <summary>
13+
/// Singleton instance of <see cref="ApiEndpointMetadata"/>.
14+
/// </summary>
15+
public static readonly ApiEndpointMetadata Instance = new();
16+
17+
private ApiEndpointMetadata()
18+
{
19+
}
20+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http.Metadata;
5+
6+
/// <summary>
7+
/// Metadata that indicates the endpoint is an API intended for programmatic access rather than direct browser navigation.
8+
/// When present, authentication handlers should prefer returning status codes over browser redirects.
9+
/// </summary>
10+
public interface IApiEndpointMetadata
11+
{
12+
}

src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
3535
</ItemGroup>
3636

3737
<ItemGroup>
38-
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
3938
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Extensions" />
4039
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Results" />
4140
</ItemGroup>
@@ -61,5 +60,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
6160

6261
<ItemGroup>
6362
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Tests" />
63+
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
6464
</ItemGroup>
6565
</Project>

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Http.Metadata.IApiEndpointMetadata
23
Microsoft.AspNetCore.Http.Metadata.IDisableValidationMetadata
34
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string?
45
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/RequestDelegateGenerator.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
248248

249249
if (hasFormBody)
250250
{
251-
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataType);
251+
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataClass);
252+
}
253+
254+
if (hasJsonBody || hasResponseMetadata)
255+
{
256+
codeWriter.WriteLine(RequestDelegateGeneratorSources.ApiEndpointMetadataClass);
252257
}
253258

254259
if (hasFormBody || hasJsonBody || hasResponseMetadata)

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/RequestDelegateGeneratorSources.cs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -479,19 +479,39 @@ internal ParameterBindingMetadata(
479479
}
480480
""";
481481

482-
public static string AntiforgeryMetadataType = """
483-
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
484-
{
485-
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);
486-
487-
public AntiforgeryMetadata(bool requiresValidation)
482+
public static string AntiforgeryMetadataClass = """
483+
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
488484
{
489-
RequiresValidation = requiresValidation;
485+
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);
486+
487+
public AntiforgeryMetadata(bool requiresValidation)
488+
{
489+
RequiresValidation = requiresValidation;
490+
}
491+
492+
public bool RequiresValidation { get; }
490493
}
494+
""";
491495

492-
public bool RequiresValidation { get; }
493-
}
496+
public static string ApiEndpointMetadataClass = """
497+
file sealed class ApiEndpointMetadata : IApiEndpointMetadata
498+
{
499+
public static readonly ApiEndpointMetadata Instance = new();
500+
501+
private ApiEndpointMetadata()
502+
{
503+
}
504+
505+
public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
506+
{
507+
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
508+
{
509+
builder.Metadata.Add(Instance);
510+
}
511+
}
512+
}
494513
""";
514+
495515
public static string GetGeneratedRouteBuilderExtensionsSource(string endpoints, string helperMethods, string helperTypes, ImmutableHashSet<string> verbs) => $$"""
496516
{{SourceHeader}}
497517

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/StaticRouteHandlerModel.Emitter.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
205205
return;
206206
}
207207

208-
if (!endpoint.Response.IsAwaitable && (response.HasNoResponse || response.IsIResult))
208+
if (response.HasNoResponse || response.IsIResult)
209209
{
210210
return;
211211
}
@@ -215,13 +215,10 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
215215
{
216216
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(string), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
217217
}
218-
else if (response.IsAwaitable && response.ResponseType == null)
219-
{
220-
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(void), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
221-
}
222218
else if (response.ResponseType is { } responseType)
223219
{
224220
codeWriter.WriteLine($$"""options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof({{responseType.ToDisplayString(EmitterConstants.DisplayFormatWithoutNullability)}}), contentTypes: GeneratedMetadataConstants.JsonContentType));""");
221+
codeWriter.WriteLine("ApiEndpointMetadata.AddApiEndpointMetadataIfMissing(options.EndpointBuilder);");
225222
}
226223
}
227224

@@ -339,13 +336,15 @@ public static void EmitJsonAcceptsMetadata(this Endpoint endpoint, CodeWriter co
339336
codeWriter.WriteLine("if (!serviceProviderIsService.IsService(type))");
340337
codeWriter.StartBlock();
341338
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(type: type, isOptional: isOptional, contentTypes: GeneratedMetadataConstants.JsonContentType));");
339+
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
342340
codeWriter.WriteLine("break;");
343341
codeWriter.EndBlock();
344342
codeWriter.EndBlock();
345343
}
346344
else
347345
{
348-
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
346+
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
347+
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
349348
}
350349
}
351350

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,14 @@ private static Expression[] CreateArgumentsAndInferMetadata(MethodInfo methodInf
401401
InferAntiforgeryMetadata(factoryContext);
402402
}
403403

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

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

1026-
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, EndpointBuilder builder)
1033+
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, RequestDelegateFactoryContext factoryContext)
10271034
{
10281035
if (returnType.IsByRefLike)
10291036
{
10301037
throw GetUnsupportedReturnTypeException(returnType);
10311038
}
10321039

1033-
var isAwaitable = false;
10341040
if (CoercedAwaitableInfo.IsTypeAwaitable(returnType, out var coercedAwaitableInfo))
10351041
{
10361042
returnType = coercedAwaitableInfo.AwaitableInfo.ResultType;
1037-
isAwaitable = true;
10381043
}
10391044

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

1051+
var builder = factoryContext.EndpointBuilder;
1052+
10461053
if (returnType == typeof(string))
10471054
{
10481055
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: typeof(string), statusCode: 200, PlaintextContentType));
10491056
}
1050-
else if (returnType == typeof(void))
1051-
{
1052-
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, PlaintextContentType));
1053-
}
10541057
else
10551058
{
10561059
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, DefaultAcceptsAndProducesContentType));
1060+
1061+
if (factoryContext.JsonRequestBodyParameter is null)
1062+
{
1063+
// Since this endpoint responds with JSON, we assume its an API endpoint not intended for browser navigation,
1064+
// but we don't want to bother adding this metadata twice if we've already inferred it based on the expected JSON request body.
1065+
builder.Metadata.Add(ApiEndpointMetadata.Instance);
1066+
}
10571067
}
10581068
}
10591069

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2533,7 +2533,7 @@ public void Create_AddJsonResponseType_AsMetadata()
25332533
var @delegate = () => new object();
25342534
var result = RequestDelegateFactory.Create(@delegate);
25352535

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

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

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

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

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

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

27132714
[Fact]
@@ -2728,9 +2729,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromValueTaskWrappedReturn
27282729

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

27362737
[Fact]
@@ -2751,9 +2752,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromFSharpAsyncWrappedRetu
27512752

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

27592760
[Fact]
@@ -2824,14 +2825,16 @@ public void Create_CombinesAllMetadata_InCorrectOrder()
28242825
m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)),
28252826
// Inferred ParameterBinding metadata
28262827
m => Assert.True(m is IParameterBindingMetadata { Name: "param1" }),
2827-
// Inferred ProducesResopnseTypeMetadata from RDF for complex type
2828+
// Inferred IApiEndpointMetadata from RDF for complex request and response type
2829+
m => Assert.True(m is IApiEndpointMetadata),
2830+
// Inferred ProducesResponseTypeMetadata from RDF for complex type
28282831
m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type),
28292832
// Metadata provided by parameters implementing IEndpointParameterMetadataProvider
28302833
m => Assert.True(m is ParameterNameMetadata { Name: "param1" }),
28312834
// Metadata provided by parameters implementing IEndpointMetadataProvider
28322835
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }),
28332836
// Metadata provided by return type implementing IEndpointMetadataProvider
2834-
m => Assert.True(m is MetadataCountMetadata { Count: 6 }));
2837+
m => Assert.True(m is MetadataCountMetadata { Count: 7 }));
28352838
}
28362839

28372840
[Fact]

src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/HandlesEndpointsWithAndWithoutDiagnostics.generated.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ namespace Microsoft.AspNetCore.Http.Generated
225225

226226
}
227227

228+
file sealed class ApiEndpointMetadata : IApiEndpointMetadata
229+
{
230+
public static readonly ApiEndpointMetadata Instance = new();
231+
232+
private ApiEndpointMetadata()
233+
{
234+
}
235+
236+
public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
237+
{
238+
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
239+
{
240+
builder.Metadata.Add(Instance);
241+
}
242+
}
243+
}
228244
%GENERATEDCODEATTRIBUTE%
229245
file static class GeneratedMetadataConstants
230246
{

0 commit comments

Comments
 (0)