From a076f1503c69b4a292af8fcde69b48099dc82b67 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 22 Jan 2026 19:18:35 +0100 Subject: [PATCH 01/39] feat(auth): add auth policies to operations --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 8 ++- .../CodeGeneration/AuthGenerator.cs | 63 +++++++++++++++++++ .../OperationRouterGenerator.cs | 30 +++++++-- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 3c41a40..ea4cbbd 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -5,6 +5,7 @@ using System.Net.Http; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.CodeGeneration; using OpenAPI.WebApiGenerator.Extensions; using OpenAPI.WebApiGenerator.OpenApi; @@ -83,7 +84,7 @@ private static void GenerateCode(SourceProductionContext context, var validationExtensionsGenerator = new ValidationExtensionsGenerator(rootNamespace); validationExtensionsGenerator.GenerateClass().AddTo(context); - var operations = new List<(string Namespace, HttpMethod HttpMethod)>(); + var operations = new List<(string Namespace, KeyValuePair Operation)>(); foreach (var path in openApi.Paths) { var pathExpression = path.Key; @@ -193,7 +194,7 @@ private static void GenerateCode(SourceProductionContext context, operationDirectory); responseSourceCode.AddTo(context); - operations.Add((operationNamespace, operationMethod)); + operations.Add((operationNamespace, openApiOperation)); var endpointSource = endpointGenerator .Generate(operationNamespace, operationDirectory, @@ -213,7 +214,8 @@ private static void GenerateCode(SourceProductionContext context, } } - var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); + var authGenerator = new AuthGenerator(openApi); + var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs new file mode 100644 index 0000000..b3261a2 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; + +internal sealed class AuthGenerator +{ + private readonly IDictionary _securitySchemes; + private readonly string[][] _topLevelSecuritySchemeGroups; + + public AuthGenerator(OpenApiDocument securitySchemes) + { + _securitySchemes = securitySchemes.Components?.SecuritySchemes ?? + new Dictionary(); + _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(securitySchemes.Security) ?? []; + } + + internal string GenerateAuthorizationDirective(IList? securityRequirements) + { + var requiredSecuritySchemeGroups = + GetSecuritySchemeGroups(securityRequirements) ?? _topLevelSecuritySchemeGroups; + + var uniqueSecuritySchemes = requiredSecuritySchemeGroups + .SelectMany(schemes => schemes) + .Distinct(); + return +$$""" +.RequireAuthorization(policy => + policy + .AddAuthenticationSchemes({{string.Join(", ", uniqueSecuritySchemes.Select(scheme => $"\"{scheme}\""))}}) + .RequireAssertion(context => + {{(requiredSecuritySchemeGroups.Any() + ? string.Join(" || ", requiredSecuritySchemeGroups.Select(requirement => + $"({GenerateAuthenticationConditions(requirement)})")) + : "true")}})) +"""; + } + + private static string GenerateAuthenticationConditions(string[] schemes) => + schemes.Any() + ? string.Join(" && ", schemes.Select(scheme => + $"""context.IsAuthenticated("{scheme}")""")) + : "true"; + + internal string GenerateIsAuthenticatedExtensionMethod() + { + return """ + private static bool IsAuthenticated(this AuthorizationHandlerContext context, string authType) => + context.User.Identities.Any(identity => identity.AuthenticationType == authType && identity.IsAuthenticated); + """; + } + + private string[][]? GetSecuritySchemeGroups(IList? securityRequirements) => + securityRequirements? + .Select(requirement => + requirement.Keys + .Select(GetSecuritySchemeName) + .ToArray()) + .ToArray(); + private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) + => _securitySchemes.First(pair => pair.Value == reference.Target).Key; +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 32535ef..345d8a6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -1,35 +1,55 @@ using System.Collections.Generic; using System.Net.Http; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class OperationRouterGenerator(string @namespace) +internal sealed class OperationRouterGenerator(string @namespace, AuthGenerator authGenerator) { - internal SourceCode ForMinimalApi(List<(string Namespace, HttpMethod HttpMethod)> operations) => + internal SourceCode ForMinimalApi(List<(string Namespace, KeyValuePair Operation)> operations) => new("OperationRouter.g.cs", $$""" #nullable enable +using Microsoft.AspNetCore.Authorization; + namespace {{@namespace}}; internal static class OperationRouter { internal static WebApplication MapOperations(this WebApplication app) - {{{operations.AggregateToString(operation => + { +{{operations.AggregateToString( +""" + app.UseAuthentication(); + app.UseAuthorization(); + +""", + operation => $""" - app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.HttpMethod.Method}"], {operation.Namespace}.Operation.HandleAsync); + app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) +{authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; """)}} return app; } internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder builder, WebApiConfiguration? configuration = null) - {{{operations.AggregateToString(operation => + { +{{operations.AggregateToString( +""" + builder.Services.AddAuthentication(); + builder.Services.AddAuthorization(); + +""", + operation => $""" builder.Services.AddScoped<{operation.Namespace}.Operation>(); """)}} builder.Services.AddSingleton(configuration ?? new()); return builder; } + +{{authGenerator.GenerateIsAuthenticatedExtensionMethod().Indent(4)}} } #nullable restore """); From dc6cf6aff851573b235db11162a694a46707f954 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 22 Jan 2026 19:34:25 +0100 Subject: [PATCH 02/39] test(auth): extend test apis with auth directives --- .../OpenApiSpecs/openapi-v2.json | 15 ++++++++++ .../OpenApiSpecs/openapi-v3.1.json | 18 ++++++++++++ .../OpenApiSpecs/openapi-v3.2.json | 29 +++++++++++++++++++ .../OpenApiSpecs/openapi-v3.2.yaml | 23 +++++++++++++++ .../OpenApiSpecs/openapi-v3.json | 18 ++++++++++++ 5 files changed, 103 insertions(+) diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json index c2e9925..88e965a 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json @@ -28,6 +28,7 @@ "operationId": "listPets", "summary": "List all pets", "tags": ["pets"], + "security": [], "parameters": [ { "name": "limit", @@ -116,6 +117,7 @@ "operationId": "createPet", "summary": "Create a pet", "tags": ["pets"], + "security": [{"bearerAuth": []}, {"apiKey": []}], "parameters": [ { "name": "body", @@ -214,6 +216,7 @@ "operationId": "deletePet", "summary": "Delete a pet", "tags": ["pets"], + "security": [{"bearerAuth": [], "apiKey": []}], "parameters": [ { "name": "X-Api-Key", @@ -281,6 +284,7 @@ "operationId": "placeOrder", "summary": "Place an order", "tags": ["store"], + "security": [{"oauth2": ["write:pets"]}], "parameters": [ { "name": "body", @@ -364,6 +368,7 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [{"oauth2": ["read:pets"]}, {"apiKey": []}], "responses": { "200": { "description": "Inventory counts by status", @@ -788,6 +793,16 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "oauth2": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:pets": "Read access to pets", + "write:pets": "Write access to pets" + } } }, "security": [ diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json index 2b00e74..157f302 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json @@ -30,6 +30,7 @@ "operationId": "listPets", "summary": "List all pets", "tags": ["pets"], + "security": [], "parameters": [ {"$ref": "#/components/parameters/LimitParam"}, {"$ref": "#/components/parameters/OffsetParam"}, @@ -98,6 +99,7 @@ "operationId": "createPet", "summary": "Create a pet", "tags": ["pets"], + "security": [{"bearerAuth": []}, {"apiKey": []}], "requestBody": {"$ref": "#/components/requestBodies/NewPetBody"}, "responses": { "201": { @@ -169,6 +171,7 @@ "operationId": "deletePet", "summary": "Delete a pet", "tags": ["pets"], + "security": [{"bearerAuth": [], "apiKey": []}], "parameters": [ { "name": "X-Api-Key", @@ -244,6 +247,7 @@ "operationId": "placeOrder", "summary": "Place an order", "tags": ["store"], + "security": [{"oauth2": ["write:pets"]}], "requestBody": {"$ref": "#/components/requestBodies/OrderBody"}, "responses": { "201": {"$ref": "#/components/responses/OrderResponse"}, @@ -305,6 +309,7 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [{"oauth2": ["read:pets"]}, {"apiKey": []}], "responses": { "200": { "description": "Inventory counts by status", @@ -854,6 +859,19 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:pets": "Read access to pets", + "write:pets": "Write access to pets" + } + } + } } } }, diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json index 60f2112..5e89455 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json @@ -30,6 +30,7 @@ "operationId": "listPets", "summary": "List all pets", "tags": ["pets"], + "security": [], "parameters": [ {"$ref": "#/components/parameters/LimitParam"}, {"$ref": "#/components/parameters/OffsetParam"}, @@ -98,6 +99,10 @@ "operationId": "createPet", "summary": "Create a pet", "tags": ["pets"], + "security": [ + {"bearerAuth": []}, + {"apiKey": []} + ], "requestBody": {"$ref": "#/components/requestBodies/NewPetBody"}, "responses": { "201": { @@ -169,6 +174,9 @@ "operationId": "deletePet", "summary": "Delete a pet", "tags": ["pets"], + "security": [ + {"bearerAuth": [], "apiKey": []} + ], "parameters": [ { "name": "X-Api-Key", @@ -244,6 +252,9 @@ "operationId": "placeOrder", "summary": "Place an order", "tags": ["store"], + "security": [ + {"oauth2": ["write:pets"]} + ], "requestBody": {"$ref": "#/components/requestBodies/OrderBody"}, "responses": { "201": {"$ref": "#/components/responses/OrderResponse"}, @@ -305,6 +316,10 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [ + {"oauth2": ["read:pets"]}, + {"apiKey": []} + ], "responses": { "200": { "description": "Inventory counts by status", @@ -854,6 +869,20 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://auth.example.com/authorize", + "tokenUrl": "https://auth.example.com/token", + "scopes": { + "read:pets": "Read pets", + "write:pets": "Write pets", + "admin": "Admin access" + } + } + } } } }, diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.yaml b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.yaml index 59a1095..8e7aa03 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.yaml +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.yaml @@ -26,6 +26,7 @@ paths: summary: List all pets tags: - pets + security: [] parameters: - $ref: "#/components/parameters/LimitParam" - $ref: "#/components/parameters/OffsetParam" @@ -75,6 +76,9 @@ paths: summary: Create a pet tags: - pets + security: + - bearerAuth: [] + - apiKey: [] requestBody: $ref: "#/components/requestBodies/NewPetBody" responses: @@ -131,6 +135,9 @@ paths: summary: Delete a pet tags: - pets + security: + - bearerAuth: [] + apiKey: [] parameters: - name: X-Api-Key in: header @@ -184,6 +191,9 @@ paths: summary: Place an order tags: - store + security: + - oauth2: + - write:pets requestBody: $ref: "#/components/requestBodies/OrderBody" responses: @@ -231,6 +241,10 @@ paths: summary: Get store inventory tags: - store + security: + - oauth2: + - read:pets + - apiKey: [] responses: "200": description: Inventory counts by status @@ -648,5 +662,14 @@ components: type: apiKey in: header name: X-Api-Key + oauth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read:pets: Read access to pets + write:pets: Write access to pets security: - bearerAuth: [] \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json index caece69..2eb563a 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json @@ -28,6 +28,7 @@ "operationId": "listPets", "summary": "List all pets", "tags": ["pets"], + "security": [], "parameters": [ { "name": "limit", @@ -143,6 +144,7 @@ "operationId": "createPet", "summary": "Create a pet", "tags": ["pets"], + "security": [{"bearerAuth": []}, {"apiKey": []}], "requestBody": { "description": "Pet to create", "required": true, @@ -269,6 +271,7 @@ "operationId": "deletePet", "summary": "Delete a pet", "tags": ["pets"], + "security": [{"bearerAuth": [], "apiKey": []}], "parameters": [ { "name": "X-Api-Key", @@ -353,6 +356,7 @@ "operationId": "placeOrder", "summary": "Place an order", "tags": ["store"], + "security": [{"oauth2": ["write:pets"]}], "requestBody": { "required": true, "content": { @@ -449,6 +453,7 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [{"oauth2": ["read:pets"]}, {"apiKey": []}], "responses": { "200": { "description": "Inventory counts by status", @@ -897,6 +902,19 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:pets": "Read access to pets", + "write:pets": "Write access to pets" + } + } + } } } }, From 7aaff8a3ab989109fcb5182266689521c85464e3 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 23 Jan 2026 18:43:07 +0100 Subject: [PATCH 03/39] feat: generate security schemes --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 1 + .../CodeGeneration/AuthGenerator.cs | 91 ++++++++++++++++++- .../Extensions/EnumerableExtensions.cs | 8 +- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index ea4cbbd..8a40fe7 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -215,6 +215,7 @@ private static void GenerateCode(SourceProductionContext context, } var authGenerator = new AuthGenerator(openApi); + authGenerator.GenerateSecuritySchemeClass()?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index b3261a2..5247c44 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,6 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Linq; using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -15,6 +19,89 @@ public AuthGenerator(OpenApiDocument securitySchemes) new Dictionary(); _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(securitySchemes.Security) ?? []; } + + internal SourceCode? GenerateSecuritySchemeClass() + { + if (!_securitySchemes.Any()) + { + return null; + } + return new SourceCode("SecuritySchemes.g.cs", +$$""" +internal static class SecuritySchemes +{{{_securitySchemes.AggregateToString(pair => + { + var className = pair.Key.ToPascalCase(); + var scheme = pair.Value; + return scheme.Type == null ? string.Empty : +$$""" + internal static class {{className}} + {{{new [] + { + GenerateConst(nameof(scheme.Description), scheme.Description), + GenerateConst(nameof(scheme.Type), GetEnumName(scheme.Type)), + GenerateConst(nameof(scheme.Name), scheme.Name), + GenerateConst(nameof(scheme.In), GetEnumName(scheme.In)), + GenerateConst(nameof(scheme.Scheme), scheme.Scheme), + GenerateConst(nameof(scheme.BearerFormat), scheme.BearerFormat), + GenerateConst(nameof(scheme.OpenIdConnectUrl), scheme.OpenIdConnectUrl?.ToString()), + GenerateConst(nameof(scheme.Deprecated), scheme.Deprecated.ToString().ToLowerInvariant()), + GenerateFlowsObject(nameof(scheme.Flows), scheme.Flows) + }.RemoveEmptyLines().AggregateToString().Indent(8)}} + } +"""; + })}} +} +"""); + } + + private static string? GetEnumName(T? value) where T : struct, Enum => + value == null ? null : Enum.GetName(typeof(T), value); + + private static string GenerateConst(string name, string? value) => + value == null + ? string.Empty + : $""" + internal const string {name} = "{value}"; + """; + + private static string GenerateFlowsObject(string className, OpenApiOAuthFlows? flows) => + flows == null ? string.Empty : +$$""" +internal static class {{className}} +{{{new [] +{ + GenerateFlowObject(nameof(flows.AuthorizationCode), flows.AuthorizationCode), + GenerateFlowObject(nameof(flows.ClientCredentials), flows.ClientCredentials), + GenerateFlowObject(nameof(flows.DeviceAuthorization), flows.DeviceAuthorization), + GenerateFlowObject(nameof(flows.Implicit), flows.Implicit), + GenerateFlowObject(nameof(flows.Password), flows.Password) +}.RemoveEmptyLines().AggregateToString().Indent(4)}} +} +"""; + + private static string GenerateFlowObject(string className, OpenApiOAuthFlow? flow) => + flow == null ? string.Empty : +$$""" +internal static class {{className}} +{{{new [] +{ + GenerateConst(nameof(flow.AuthorizationUrl), flow.AuthorizationUrl?.ToString()), + GenerateConst(nameof(flow.DeviceAuthorizationUrl), flow.DeviceAuthorizationUrl?.ToString()), + GenerateConst(nameof(flow.RefreshUrl), flow.RefreshUrl?.ToString()), + GenerateConst(nameof(flow.TokenUrl), flow.TokenUrl?.ToString()), + flow.Scopes == null ? string.Empty : +$$""" +internal static readonly System.Collections.Immutable.ImmutableDictionary {{nameof(flow.Scopes)}} = + System.Collections.Immutable.ImmutableDictionary.CreateRange([{{flow.Scopes.AggregateToString(scope => +$""" + new("{scope.Key}", "{scope.Value}"), +""").TrimEnd(',')}} +]); +""" +}.RemoveEmptyLines().AggregateToString().Indent(4)}} +} +"""; internal string GenerateAuthorizationDirective(IList? securityRequirements) { @@ -60,4 +147,4 @@ private static bool IsAuthenticated(this AuthorizationHandlerContext context, st .ToArray(); private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) => _securitySchemes.First(pair => pair.Value == reference.Target).Key; -} \ No newline at end of file +} diff --git a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs index eb0a13d..1c2c586 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs @@ -7,6 +7,8 @@ namespace OpenAPI.WebApiGenerator.Extensions; internal static class EnumerableExtensions { + internal static string AggregateToString(this IEnumerable items) => + items.AggregateToString(str => str); internal static string AggregateToString(this IEnumerable items, Func convert) => items.AggregateToString(new StringBuilder().AppendLine(), convert); internal static string AggregateToString(this IEnumerable items, string firstLine, Func convert) => @@ -17,7 +19,11 @@ private static string AggregateToString(this IEnumerable items, StringBuil builder.AppendLine(convert(item))) .ToString() .TrimEnd(); - + internal static IEnumerable<(T item, int i)> WithIndex(this IEnumerable items) => items.Select((arg1, i) => (arg1, i)); + + internal static IEnumerable RemoveEmptyLines(this IEnumerable list) => + list + .Where(line => !string.IsNullOrWhiteSpace(line)); } \ No newline at end of file From ec31c19bbc436aada71267050f6ccc7543203184 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 23 Jan 2026 18:47:20 +0100 Subject: [PATCH 04/39] generate security schemes in root namespace --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 2 +- .../CodeGeneration/AuthGenerator.cs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 8a40fe7..f0160f2 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -215,7 +215,7 @@ private static void GenerateCode(SourceProductionContext context, } var authGenerator = new AuthGenerator(openApi); - authGenerator.GenerateSecuritySchemeClass()?.AddTo(context); + authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 5247c44..f302e90 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; -using System.Collections.ObjectModel; using System.Linq; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -20,7 +18,7 @@ public AuthGenerator(OpenApiDocument securitySchemes) _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(securitySchemes.Security) ?? []; } - internal SourceCode? GenerateSecuritySchemeClass() + internal SourceCode? GenerateSecuritySchemeClass(string @namespace) { if (!_securitySchemes.Any()) { @@ -28,6 +26,10 @@ public AuthGenerator(OpenApiDocument securitySchemes) } return new SourceCode("SecuritySchemes.g.cs", $$""" +using System.Collections.Immutable; + +namespace {{@namespace}}; + internal static class SecuritySchemes {{{_securitySchemes.AggregateToString(pair => { @@ -92,8 +94,8 @@ internal static class {{className}} GenerateConst(nameof(flow.TokenUrl), flow.TokenUrl?.ToString()), flow.Scopes == null ? string.Empty : $$""" -internal static readonly System.Collections.Immutable.ImmutableDictionary {{nameof(flow.Scopes)}} = - System.Collections.Immutable.ImmutableDictionary.CreateRange([{{flow.Scopes.AggregateToString(scope => +internal static readonly ImmutableDictionary {{nameof(flow.Scopes)}} = + ImmutableDictionary.CreateRange([{{flow.Scopes.AggregateToString(scope => $""" new("{scope.Key}", "{scope.Value}"), """).TrimEnd(',')}} From eae28c2c3eca6c54093043215b625baeeaee8168 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 23 Jan 2026 23:58:57 +0100 Subject: [PATCH 05/39] feat(auth): include scope validation --- .../CodeGeneration/AuthGenerator.cs | 31 +++++++++++++------ .../OperationRouterGenerator.cs | 4 ++- .../Extensions/EnumerableExtensions.cs | 6 ++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index f302e90..780bcb1 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -9,7 +9,7 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class AuthGenerator { private readonly IDictionary _securitySchemes; - private readonly string[][] _topLevelSecuritySchemeGroups; + private readonly Dictionary>[] _topLevelSecuritySchemeGroups; public AuthGenerator(OpenApiDocument securitySchemes) { @@ -37,6 +37,7 @@ internal static class SecuritySchemes var scheme = pair.Value; return scheme.Type == null ? string.Empty : $$""" + internal const string {{className}}Key = "{{pair.Key}}"; internal static class {{className}} {{{new [] { @@ -47,7 +48,7 @@ internal static class {{className}} GenerateConst(nameof(scheme.Scheme), scheme.Scheme), GenerateConst(nameof(scheme.BearerFormat), scheme.BearerFormat), GenerateConst(nameof(scheme.OpenIdConnectUrl), scheme.OpenIdConnectUrl?.ToString()), - GenerateConst(nameof(scheme.Deprecated), scheme.Deprecated.ToString().ToLowerInvariant()), + $"internal const bool {nameof(scheme.Deprecated)} = {scheme.Deprecated.ToString().ToLowerInvariant()};", GenerateFlowsObject(nameof(scheme.Flows), scheme.Flows) }.RemoveEmptyLines().AggregateToString().Indent(8)}} } @@ -111,7 +112,7 @@ internal string GenerateAuthorizationDirective(IList GetSecuritySchemeGroups(securityRequirements) ?? _topLevelSecuritySchemeGroups; var uniqueSecuritySchemes = requiredSecuritySchemeGroups - .SelectMany(schemes => schemes) + .SelectMany(schemes => schemes.Select(pair => pair.Key)) .Distinct(); return $$""" @@ -126,10 +127,11 @@ internal string GenerateAuthorizationDirective(IList """; } - private static string GenerateAuthenticationConditions(string[] schemes) => + private static string GenerateAuthenticationConditions(Dictionary> schemes) => schemes.Any() ? string.Join(" && ", schemes.Select(scheme => - $"""context.IsAuthenticated("{scheme}")""")) + $"context.IsAuthenticated(\"{scheme.Key}\") && " + + $"context.ClaimContainsScopes(scopeClaim, {scheme.Value.AsParams()})")) : "true"; internal string GenerateIsAuthenticatedExtensionMethod() @@ -140,12 +142,23 @@ private static bool IsAuthenticated(this AuthorizationHandlerContext context, st """; } - private string[][]? GetSecuritySchemeGroups(IList? securityRequirements) => + internal string GenerateScopeClaimExtensionMethod() + { + return """ + private static bool ClaimContainsScopes(this AuthorizationHandlerContext context, string claim, params string[] scopes) + { + var foundScopes = context.User.FindFirst(claim)?.Value?.Split(' ') ?? []; + return scopes.Aggregate(true, (result, scope) => result && foundScopes.Contains(scope)); + } + """; + } + + private Dictionary>[]? GetSecuritySchemeGroups(IList? securityRequirements) => securityRequirements? .Select(requirement => - requirement.Keys - .Select(GetSecuritySchemeName) - .ToArray()) + requirement.ToDictionary( + pair => GetSecuritySchemeName(pair.Key), + pair => pair.Value)) .ToArray(); private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) => _securitySchemes.First(pair => pair.Value == reference.Target).Key; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 345d8a6..46f05ea 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -17,7 +17,7 @@ namespace {{@namespace}}; internal static class OperationRouter { - internal static WebApplication MapOperations(this WebApplication app) + internal static WebApplication MapOperations(this WebApplication app, string scopeClaim = "scope") { {{operations.AggregateToString( """ @@ -50,6 +50,8 @@ internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder b } {{authGenerator.GenerateIsAuthenticatedExtensionMethod().Indent(4)}} + +{{authGenerator.GenerateScopeClaimExtensionMethod().Indent(4)}} } #nullable restore """); diff --git a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs index 1c2c586..093684e 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs @@ -26,4 +26,10 @@ private static string AggregateToString(this IEnumerable items, StringBuil internal static IEnumerable RemoveEmptyLines(this IEnumerable list) => list .Where(line => !string.IsNullOrWhiteSpace(line)); + + internal static string AsParams(this IEnumerable values) + { + var result = string.Join(", ", values.Select(scope => $"\"{scope}\"")); + return result == string.Empty ? "[]" : result; + } } \ No newline at end of file From 17525a76938866675f40939f8a3272c9e96afcc5 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 24 Jan 2026 11:35:37 +0100 Subject: [PATCH 06/39] feat(auth): add security scheme options for configuring scope claim and format --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 1 + .../CodeGeneration/AuthGenerator.cs | 65 ++++++++++++++++--- .../OperationRouterGenerator.cs | 14 ++-- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index f0160f2..5eb2bdc 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -216,6 +216,7 @@ private static void GenerateCode(SourceProductionContext context, var authGenerator = new AuthGenerator(openApi); authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); + authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 780bcb1..06aa713 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -16,8 +16,10 @@ public AuthGenerator(OpenApiDocument securitySchemes) _securitySchemes = securitySchemes.Components?.SecuritySchemes ?? new Dictionary(); _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(securitySchemes.Security) ?? []; + HasSecuritySchemes = _securitySchemes.Any(); } + internal bool HasSecuritySchemes { get; } internal SourceCode? GenerateSecuritySchemeClass(string @namespace) { if (!_securitySchemes.Any()) @@ -131,7 +133,7 @@ private static string GenerateAuthenticationConditions(Dictionary $"context.IsAuthenticated(\"{scheme.Key}\") && " + - $"context.ClaimContainsScopes(scopeClaim, {scheme.Value.AsParams()})")) + $"context.ClaimContainsScopes(securitySchemeOptions.{scheme.Key.ToPascalCase()}.Scope, {scheme.Value.AsParams()})")) : "true"; internal string GenerateIsAuthenticatedExtensionMethod() @@ -142,17 +144,62 @@ private static bool IsAuthenticated(this AuthorizationHandlerContext context, st """; } - internal string GenerateScopeClaimExtensionMethod() + internal string GenerateScopeClaimExtensionMethod() => + _securitySchemes.Any() + ? """ + private static bool ClaimContainsScopes(this AuthorizationHandlerContext context, SecuritySchemeOptions.ScopeOptions scopeOptions, params string[] scopes) + { + var foundScopes = scopeOptions.Format switch + { + SecuritySchemeOptions.ScopeOptions.ClaimFormat.SpaceDelimited => context.User.FindFirst(scopeOptions.Claim)?.Value?.Split(' ') ?? [], + SecuritySchemeOptions.ScopeOptions.ClaimFormat.Array => context.User.FindAll(scopeOptions.Claim).Select(claim => claim.Value).ToArray(), + _ => throw new InvalidOperationException($"{Enum.GetName(typeof(SecuritySchemeOptions.ScopeOptions.ClaimFormat), scopeOptions.Format)} not supported") + }; + return scopes.Aggregate(true, (result, scope) => result && foundScopes.Contains(scope)); + } + """ + : string.Empty; + + internal SourceCode? GenerateSecuritySchemeOptionsClass(string @namespace) { - return """ - private static bool ClaimContainsScopes(this AuthorizationHandlerContext context, string claim, params string[] scopes) - { - var foundScopes = context.User.FindFirst(claim)?.Value?.Split(' ') ?? []; - return scopes.Aggregate(true, (result, scope) => result && foundScopes.Contains(scope)); - } - """; + if (!_securitySchemes.Any()) + { + return null; + } + return new SourceCode("SecuritySchemeOptions.g.cs", +$$""" +namespace {{@namespace}}; + +internal sealed class SecuritySchemeOptions +{{{_securitySchemes.AggregateToString(pair => + $$""" + internal SecuritySchemeOption {{pair.Key.ToPascalCase()}} { get; init; } = new(); + """).Indent(4)}} + + internal sealed class SecuritySchemeOption + { + internal ScopeOptions Scope {get; init; } = new() + { + Claim = "scope", + Format = ScopeOptions.ClaimFormat.SpaceDelimited + }; } + + internal sealed class ScopeOptions + { + public string Claim { get; set; } + public ClaimFormat Format { get; set; } + internal enum ClaimFormat + { + SpaceDelimited, + Array + } + } +} +"""); + } + private Dictionary>[]? GetSecuritySchemeGroups(IList? securityRequirements) => securityRequirements? .Select(requirement => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 46f05ea..8f2fb0d 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -17,8 +17,12 @@ namespace {{@namespace}}; internal static class OperationRouter { - internal static WebApplication MapOperations(this WebApplication app, string scopeClaim = "scope") - { + internal static WebApplication MapOperations(this WebApplication app{{(authGenerator.HasSecuritySchemes ? ", SecuritySchemeOptions? securitySchemeOptions = null" : "")}}) + {{{(authGenerator.HasSecuritySchemes ? +""" + + securitySchemeOptions ??= new(); +""" : "")}} {{operations.AggregateToString( """ app.UseAuthentication(); @@ -49,9 +53,11 @@ internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder b return builder; } -{{authGenerator.GenerateIsAuthenticatedExtensionMethod().Indent(4)}} +{{$""" +{authGenerator.GenerateIsAuthenticatedExtensionMethod().Indent(4)} -{{authGenerator.GenerateScopeClaimExtensionMethod().Indent(4)}} +{authGenerator.GenerateScopeClaimExtensionMethod().Indent(4)} +""".TrimEnd()}} } #nullable restore """); From 4fc34ba65a1008a03f698faf98e82aef03fcf85a Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 24 Jan 2026 19:49:48 +0100 Subject: [PATCH 07/39] fix(auth): add OpenAPI compatible authorization handler for security requirements --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 6 +- .../ApiConfigurationGenerator.cs | 9 +- .../CodeGeneration/AuthGenerator.cs | 136 +++++++++++++----- .../OperationRouterGenerator.cs | 15 +- 4 files changed, 112 insertions(+), 54 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 5eb2bdc..759140b 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -78,7 +78,9 @@ private static void GenerateCode(SourceProductionContext context, openApiVersion); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); - var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace); + var authGenerator = new AuthGenerator(openApi); + + var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace, authGenerator); apiConfigurationGenerator.GenerateClass().AddTo(context); var validationExtensionsGenerator = new ValidationExtensionsGenerator(rootNamespace); @@ -214,9 +216,9 @@ private static void GenerateCode(SourceProductionContext context, } } - var authGenerator = new AuthGenerator(openApi); authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); + authGenerator.GenerateSecurityRequirementHandler(rootNamespace)?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ApiConfigurationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ApiConfigurationGenerator.cs index 88c2598..9b240e8 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ApiConfigurationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ApiConfigurationGenerator.cs @@ -1,6 +1,6 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class ApiConfigurationGenerator(string @namespace) +internal sealed class ApiConfigurationGenerator(string @namespace, AuthGenerator authGenerator) { private const string ClassName = "WebApiConfiguration"; @@ -8,6 +8,7 @@ internal SourceCode GenerateClass() => new($"{ClassName}.g.cs", $$""" #nullable enable + using Microsoft.AspNetCore.Authorization; using System; namespace {{@namespace}}; @@ -19,7 +20,11 @@ public sealed class {{ClassName}} /// This is used in the SchemaLocation of the ValidationResult. /// https://localhost/openapi.json /// - public Uri? OpenApiSpecificationUri { get; init; } + public Uri? OpenApiSpecificationUri { get; init; }{{(authGenerator.HasSecuritySchemes ? + """ + + internal SecuritySchemeOptions SecuritySchemeOptions { get; set; } = new(); + """ : "")}} } #nullable restore """); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 06aa713..b10b12e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -110,10 +110,10 @@ internal static class {{className}} internal string GenerateAuthorizationDirective(IList? securityRequirements) { - var requiredSecuritySchemeGroups = + var securityRequirementGroups = GetSecuritySchemeGroups(securityRequirements) ?? _topLevelSecuritySchemeGroups; - var uniqueSecuritySchemes = requiredSecuritySchemeGroups + var uniqueSecuritySchemes = securityRequirementGroups .SelectMany(schemes => schemes.Select(pair => pair.Key)) .Distinct(); return @@ -121,44 +121,93 @@ internal string GenerateAuthorizationDirective(IList .RequireAuthorization(policy => policy .AddAuthenticationSchemes({{string.Join(", ", uniqueSecuritySchemes.Select(scheme => $"\"{scheme}\""))}}) - .RequireAssertion(context => - {{(requiredSecuritySchemeGroups.Any() - ? string.Join(" || ", requiredSecuritySchemeGroups.Select(requirement => - $"({GenerateAuthenticationConditions(requirement)})")) - : "true")}})) + .AddRequirements({{(securityRequirementGroups.Any() ? +$$""" + new SecurityRequirements + { +{{securityRequirementGroups.Aggregate(string.Empty, (result, securityRequirementGroup) => + result + securityRequirementGroup.AggregateToString(securityRequirement => +$$""" + new SecurityRequirement + { + ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] + } +"""))}} + } +""" : "new AssertionRequirement(_ => true)")}})) """; } - private static string GenerateAuthenticationConditions(Dictionary> schemes) => - schemes.Any() - ? string.Join(" && ", schemes.Select(scheme => - $"context.IsAuthenticated(\"{scheme.Key}\") && " + - $"context.ClaimContainsScopes(securitySchemeOptions.{scheme.Key.ToPascalCase()}.Scope, {scheme.Value.AsParams()})")) - : "true"; + internal SourceCode? GenerateSecurityRequirementHandler(string @namespace) + { + if (!HasSecuritySchemes) + { + return null; + } + return new SourceCode("SecurityRequirementHandler.g.cs", +$$""" +#nullable enable +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using System.Security.Claims; - internal string GenerateIsAuthenticatedExtensionMethod() +namespace {{@namespace}}; + +internal sealed class SecurityRequirementHandler(IHttpContextAccessor httpContextAccessor, WebApiConfiguration configuration) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + SecurityRequirements securityRequirements) { - return """ - private static bool IsAuthenticated(this AuthorizationHandlerContext context, string authType) => - context.User.Identities.Any(identity => identity.AuthenticationType == authType && identity.IsAuthenticated); - """; - } + var httpContext = httpContextAccessor.HttpContext; - internal string GenerateScopeClaimExtensionMethod() => - _securitySchemes.Any() - ? """ - private static bool ClaimContainsScopes(this AuthorizationHandlerContext context, SecuritySchemeOptions.ScopeOptions scopeOptions, params string[] scopes) - { - var foundScopes = scopeOptions.Format switch - { - SecuritySchemeOptions.ScopeOptions.ClaimFormat.SpaceDelimited => context.User.FindFirst(scopeOptions.Claim)?.Value?.Split(' ') ?? [], - SecuritySchemeOptions.ScopeOptions.ClaimFormat.Array => context.User.FindAll(scopeOptions.Claim).Select(claim => claim.Value).ToArray(), - _ => throw new InvalidOperationException($"{Enum.GetName(typeof(SecuritySchemeOptions.ScopeOptions.ClaimFormat), scopeOptions.Format)} not supported") - }; - return scopes.Aggregate(true, (result, scope) => result && foundScopes.Contains(scope)); - } - """ - : string.Empty; + if (httpContext == null) + { + context.Fail(new AuthorizationFailureReason(this, "No HttpContext available")); + return; + } + + // Only one of the security requirement objects need to be satisfied to authorize a request. + foreach (var securityRequirement in securityRequirements) + { + var allRequirementsPassed = true; + // Security Requirement Objects that contain multiple schemes require that all schemes MUST be satisfied for a request to be authorized. + foreach (var (scheme, scopes) in securityRequirement) + { + var authenticateResult = await httpContext.AuthenticateAsync(scheme) + .ConfigureAwait(false); + allRequirementsPassed = authenticateResult.Succeeded && + ClaimContainsScopes(authenticateResult.Principal, configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); + if (!allRequirementsPassed) + { + break; + } + } + if (allRequirementsPassed) + { + context.Succeed(securityRequirements); + return; + } + } + } + + private static bool ClaimContainsScopes(ClaimsPrincipal? principal, SecuritySchemeOptions.ScopeOptions scopeOptions, params string[] scopes) + { + var foundScopes = scopeOptions.Format switch + { + SecuritySchemeOptions.ScopeOptions.ClaimFormat.SpaceDelimited => principal?.FindFirst(scopeOptions.Claim)?.Value?.Split(' ') ?? [], + SecuritySchemeOptions.ScopeOptions.ClaimFormat.Array => principal?.FindAll(scopeOptions.Claim)?.Select(claim => claim.Value)?.ToArray() ?? [], + _ => throw new InvalidOperationException($"{Enum.GetName(typeof(SecuritySchemeOptions.ScopeOptions.ClaimFormat), scopeOptions.Format)} not supported") + }; + + return scopes.All(scope => foundScopes.Contains(scope)); + } +} +#nullable restore +"""); + } internal SourceCode? GenerateSecuritySchemeOptionsClass(string @namespace) { @@ -168,17 +217,27 @@ private static bool ClaimContainsScopes(this AuthorizationHandlerContext context } return new SourceCode("SecuritySchemeOptions.g.cs", $$""" +#nullable enable namespace {{@namespace}}; internal sealed class SecuritySchemeOptions {{{_securitySchemes.AggregateToString(pair => $$""" - internal SecuritySchemeOption {{pair.Key.ToPascalCase()}} { get; init; } = new(); + public SecuritySchemeOption {{pair.Key.ToPascalCase()}} { get; init; } = new(); """).Indent(4)}} + internal ScopeOptions GetScopeOptions(string scheme) => + scheme switch + {{{_securitySchemes.AggregateToString(pair => +$""" + "{pair.Key}" => {pair.Key.ToPascalCase()}.Scope, +""")}} + _ => throw new InvalidOperationException($"Scheme {scheme} is unknown") + }; + internal sealed class SecuritySchemeOption { - internal ScopeOptions Scope {get; init; } = new() + public ScopeOptions Scope {get; init; } = new() { Claim = "scope", Format = ScopeOptions.ClaimFormat.SpaceDelimited @@ -187,8 +246,8 @@ internal sealed class SecuritySchemeOption internal sealed class ScopeOptions { - public string Claim { get; set; } - public ClaimFormat Format { get; set; } + public required string Claim { get; init; } + public required ClaimFormat Format { get; init; } internal enum ClaimFormat { @@ -197,6 +256,7 @@ internal enum ClaimFormat } } } +#nullable restore """); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 8f2fb0d..be4f222 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -12,17 +12,14 @@ internal SourceCode ForMinimalApi(List<(string Namespace, KeyValuePair Date: Sun, 25 Jan 2026 00:44:02 +0100 Subject: [PATCH 08/39] refactor(auth): remove security directives when there is no auth specified as no directives mean anonymous access --- .../CodeGeneration/AuthGenerator.cs | 16 +++++++++------- .../CodeGeneration/OperationRouterGenerator.cs | 5 ++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index b10b12e..498f720 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -112,29 +112,31 @@ internal string GenerateAuthorizationDirective(IList { var securityRequirementGroups = GetSecuritySchemeGroups(securityRequirements) ?? _topLevelSecuritySchemeGroups; + if (!securityRequirementGroups.Any()) + { + return string.Empty; + } var uniqueSecuritySchemes = securityRequirementGroups .SelectMany(schemes => schemes.Select(pair => pair.Key)) .Distinct(); return $$""" + .RequireAuthorization(policy => policy .AddAuthenticationSchemes({{string.Join(", ", uniqueSecuritySchemes.Select(scheme => $"\"{scheme}\""))}}) - .AddRequirements({{(securityRequirementGroups.Any() ? -$$""" + .AddRequirements( new SecurityRequirements - { -{{securityRequirementGroups.Aggregate(string.Empty, (result, securityRequirementGroup) => - result + securityRequirementGroup.AggregateToString(securityRequirement => + {{{securityRequirementGroups.Aggregate(string.Empty, (result, securityRequirementGroup) => + result + securityRequirementGroup.AggregateToString(securityRequirement => $$""" new SecurityRequirement { ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] } """))}} - } -""" : "new AssertionRequirement(_ => true)")}})) + })) """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index be4f222..dc3e457 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -12,7 +12,6 @@ internal SourceCode ForMinimalApi(List<(string Namespace, KeyValuePair $""" - app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) -{authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; + app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync){ + authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; """)}} return app; } From 2710eeac4dcbb8e0817e8230c6dfd0f7b15c6ed8 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 00:56:46 +0100 Subject: [PATCH 09/39] add auth dependencies --- .../OperationRouterGenerator.cs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index dc3e457..e06e4c0 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -18,14 +18,13 @@ namespace {{@namespace}}; internal static class OperationRouter { internal static WebApplication MapOperations(this WebApplication app) - { -{{operations.AggregateToString( -""" + {{{(authGenerator.HasSecuritySchemes ? + """ + app.UseAuthentication(); app.UseAuthorization(); - -""", - operation => + """ : "")}} +{{operations.AggregateToString(operation => $""" app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync){ authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; @@ -34,14 +33,16 @@ internal static WebApplication MapOperations(this WebApplication app) } internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder builder, WebApiConfiguration? configuration = null) - { -{{operations.AggregateToString( -""" + {{{(authGenerator.HasSecuritySchemes ? + """ + builder.Services.AddAuthentication(); builder.Services.AddAuthorization(); - -""", - operation => + + builder.Services.AddHttpContextAccessor(); + builder.Services.AddSingleton(); + """ : "")}} +{{operations.AggregateToString(operation => $""" builder.Services.AddScoped<{operation.Namespace}.Operation>(); """)}} From 2f25a9363510decece173726309f0eea983a0fae Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 01:13:07 +0100 Subject: [PATCH 10/39] test: add OIDC auth test handler --- .../Auth/HotWiredJwtBackchannelHandler.cs | 12 ++ .../Auth/OIDCAuthHttpHandler.cs | 153 ++++++++++++++++++ .../Observability/WebHostBuilderExtensions.cs | 20 +++ .../OpenAPI.IntegrationTestHelpers.csproj | 13 ++ OpenAPI.WebApiGenerator.sln | 14 ++ .../Example.OpenApi31.IntegrationTests.csproj | 1 + .../FooApplicationFactory.cs | 29 +++- 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 OpenAPI.IntegrationTestHelpers/Auth/HotWiredJwtBackchannelHandler.cs create mode 100644 OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs create mode 100644 OpenAPI.IntegrationTestHelpers/Observability/WebHostBuilderExtensions.cs create mode 100644 OpenAPI.IntegrationTestHelpers/OpenAPI.IntegrationTestHelpers.csproj diff --git a/OpenAPI.IntegrationTestHelpers/Auth/HotWiredJwtBackchannelHandler.cs b/OpenAPI.IntegrationTestHelpers/Auth/HotWiredJwtBackchannelHandler.cs new file mode 100644 index 0000000..5ba5152 --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/HotWiredJwtBackchannelHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace OpenAPI.IntegrationTestHelpers.Auth; + +public sealed class HotWiredJwtBackchannelHandler : IPostConfigureOptions +{ + public void PostConfigure(string? name, JwtBearerOptions options) + { + options.BackchannelHttpHandler = new OIDCAuthHttpHandler(); + } +} \ No newline at end of file diff --git a/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs new file mode 100644 index 0000000..7c9dd83 --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs @@ -0,0 +1,153 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; + +namespace OpenAPI.IntegrationTestHelpers.Auth; + +public sealed class OIDCAuthHttpHandler : HttpMessageHandler +{ + private const string Kid = "test"; + private static readonly RSAParameters PrivateRsaParameters; + private static string OidcConfigurationContent { get; } + private static string JwksContent { get; } + + static OIDCAuthHttpHandler() + { + using var rsa = new RSACryptoServiceProvider + { + PersistKeyInCsp = false + }; + + PrivateRsaParameters = rsa.ExportParameters(true); + var securityKey = new RsaSecurityKey(PrivateRsaParameters) + { + KeyId = Base64UrlEncoder.Encode(Kid) + }; + + var privateKey = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature); + + Jwt = GenerateJwtToken(privateKey); + OidcConfigurationContent = CreateOidcConfigurationContent(); + JwksContent = CreateJwksContent(); + } + + public static readonly string Jwt; + internal const string Issuer = "https://localhost/"; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri == null || + !request.RequestUri.AbsoluteUri.StartsWith(Issuer)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + return request.RequestUri.AbsolutePath switch + { + "/.well-known/openid-configuration" => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(OidcConfigurationContent) + }), + "/oauth/jwks" => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JwksContent) + }), + _ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)) + }; + } + + private static string GenerateJwtToken(SigningCredentials privateKey) + { + var securityTokenDescriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Issuer, + Subject = new ClaimsIdentity(), + Expires = DateTime.UtcNow.AddHours(1), + IssuedAt = DateTime.UtcNow, + SigningCredentials = privateKey, + Claims = new Dictionary() { { "scope", "short" } } + }; + + var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + var jwt = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor); + var token = jwtSecurityTokenHandler.WriteToken(jwt); + return token; + } + + private static string CreateJwksContent() => + $$""" + { + "keys" : [ + { + "kid": "{{Base64UrlEncoder.Encode(Kid)}}", + "e": "{{Base64UrlEncoder.Encode(PrivateRsaParameters.Exponent)}}", + "kty": "RSA", + "alg": "RS256", + "n": "{{Base64UrlEncoder.Encode(PrivateRsaParameters.Modulus)}}" + } + ] + } + """; + + private static string CreateOidcConfigurationContent() => + $$""" + { + "issuer":"{{Issuer}}", + "authorization_endpoint":"{{Issuer}}oauth/auz/authorize", + "token_endpoint":"{{Issuer}}oauth/oauth20/token", + "userinfo_endpoint":"{{Issuer}}/oauth/userinfo", + "jwks_uri":"{{Issuer}}oauth/jwks", + "scopes_supported":[ + "READ", + "WRITE", + "DELETE", + "openid", + "scope", + "profile", + "email", + "address", + "phone" + ], + "response_types_supported":[ + "code", + "code id_token", + "code token", + "code id_token token", + "token", + "id_token", + "id_token token" + ], + "grant_types_supported":[ + "authorization_code", + "implicit", + "client_credentials", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ], + "subject_types_supported":[ + "public" + ], + "id_token_signing_alg_values_supported":[ + "RS256" + ], + "id_token_encryption_alg_values_supported":[ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "id_token_encryption_enc_values_supported":[ + "A256GCM" + ], + "token_endpoint_auth_methods_supported":[ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported":[ + "RS256" + ] + } + """; +} \ No newline at end of file diff --git a/OpenAPI.IntegrationTestHelpers/Observability/WebHostBuilderExtensions.cs b/OpenAPI.IntegrationTestHelpers/Observability/WebHostBuilderExtensions.cs new file mode 100644 index 0000000..63edcb3 --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Observability/WebHostBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace OpenAPI.IntegrationTestHelpers.Observability; + +public static class WebHostBuilderExtensions +{ + public static IWebHostBuilder AddLogging(this IWebHostBuilder builder) => + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.AddDebug(); + logging.SetMinimumLevel(LogLevel.Trace); + + logging.AddFilter("Microsoft.AspNetCore.Authentication", LogLevel.Trace); + logging.AddFilter("Microsoft.AspNetCore.Authorization", LogLevel.Trace); + logging.AddFilter("Microsoft.AspNetCore.Routing", LogLevel.Trace); + }); +} \ No newline at end of file diff --git a/OpenAPI.IntegrationTestHelpers/OpenAPI.IntegrationTestHelpers.csproj b/OpenAPI.IntegrationTestHelpers/OpenAPI.IntegrationTestHelpers.csproj new file mode 100644 index 0000000..f10802b --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/OpenAPI.IntegrationTestHelpers.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index a8837be..21f888a 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi32", "tests\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi32.IntegrationTests", "tests\Example.OpenApi32.IntegrationTests\Example.OpenApi32.IntegrationTests.csproj", "{CFC6595B-DAA2-4866-AE50-6A9AD7863160}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.IntegrationTestHelpers", "OpenAPI.IntegrationTestHelpers\OpenAPI.IntegrationTestHelpers.csproj", "{58614455-A749-4B08-A8B9-DF876D8262AC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -158,6 +160,18 @@ Global {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x64.Build.0 = Release|Any CPU {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x86.ActiveCfg = Release|Any CPU {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x86.Build.0 = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|x64.ActiveCfg = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|x64.Build.0 = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|x86.ActiveCfg = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Debug|x86.Build.0 = Debug|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|Any CPU.Build.0 = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x64.ActiveCfg = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x64.Build.0 = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x86.ActiveCfg = Release|Any CPU + {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj b/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj index 78f593d..e805cd4 100644 --- a/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj +++ b/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs index 2ba4121..1fa981f 100644 --- a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs @@ -1,7 +1,34 @@ +using System.Net.Http.Headers; using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenAPI.IntegrationTestHelpers.Auth; +using OpenAPI.IntegrationTestHelpers.Observability; namespace Example.OpenApi31.IntegrationTests; [UsedImplicitly] -public class FooApplicationFactory : WebApplicationFactory; \ No newline at end of file +public class FooApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.Insert(0, + ServiceDescriptor.Singleton, HotWiredJwtBackchannelHandler>()); + }); + + builder.AddLogging(); + } + + protected override void ConfigureClient(HttpClient client) + { + base.ConfigureClient(client); + client.DefaultRequestHeaders.Authorization = + AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.Jwt}"); + } +} \ No newline at end of file From 40c4ed0b1c7dc6df2f61b93cc9fb33e1dfabbf6d Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 01:33:06 +0100 Subject: [PATCH 11/39] fix indentations --- .../CodeGeneration/OperationRouterGenerator.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index e06e4c0..f85413d 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -19,12 +19,12 @@ internal static class OperationRouter { internal static WebApplication MapOperations(this WebApplication app) {{{(authGenerator.HasSecuritySchemes ? - """ +""" app.UseAuthentication(); app.UseAuthorization(); - """ : "")}} -{{operations.AggregateToString(operation => + +""" : "")}}{{operations.AggregateToString(operation => $""" app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync){ authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; @@ -34,15 +34,14 @@ internal static WebApplication MapOperations(this WebApplication app) internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder builder, WebApiConfiguration? configuration = null) {{{(authGenerator.HasSecuritySchemes ? - """ +""" builder.Services.AddAuthentication(); builder.Services.AddAuthorization(); - builder.Services.AddHttpContextAccessor(); - builder.Services.AddSingleton(); - """ : "")}} -{{operations.AggregateToString(operation => + builder.Services.AddSingleton(); + +""" : "")}}{{operations.AggregateToString(operation => $""" builder.Services.AddScoped<{operation.Namespace}.Operation>(); """)}} From 30574791a98e4d1873379ddc43341a6c8ad241c6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 11:59:33 +0100 Subject: [PATCH 12/39] add missing security requirements --- src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 498f720..cab7ed6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -205,8 +205,11 @@ private static bool ClaimContainsScopes(ClaimsPrincipal? principal, SecuritySche }; return scopes.All(scope => foundScopes.Contains(scope)); - } + } } + +internal sealed class SecurityRequirements : List, IAuthorizationRequirement; +internal sealed class SecurityRequirement : Dictionary; #nullable restore """); } From 310fd0b31f025d1a86c202f39b56d7be99068811 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 12:15:49 +0100 Subject: [PATCH 13/39] tests: extract auth extensions --- .../Auth/HttpClientAuthExtensions.cs | 13 +++++++++++++ .../Auth/ServiceCollectionAuthExtensions.cs | 15 +++++++++++++++ .../FooApplicationFactory.cs | 14 +------------- .../UpdateFooTests.cs | 7 +++++-- 4 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs create mode 100644 OpenAPI.IntegrationTestHelpers/Auth/ServiceCollectionAuthExtensions.cs diff --git a/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs new file mode 100644 index 0000000..4b53249 --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs @@ -0,0 +1,13 @@ +using System.Net.Http.Headers; + +namespace OpenAPI.IntegrationTestHelpers.Auth; + +public static class HttpClientAuthExtensions +{ + public static HttpClient WithOAuth2ImplicitFlowAuthentication(this HttpClient client) + { + client.DefaultRequestHeaders.Authorization = + AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.Jwt}"); + return client; + } +} \ No newline at end of file diff --git a/OpenAPI.IntegrationTestHelpers/Auth/ServiceCollectionAuthExtensions.cs b/OpenAPI.IntegrationTestHelpers/Auth/ServiceCollectionAuthExtensions.cs new file mode 100644 index 0000000..d93db13 --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/ServiceCollectionAuthExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace OpenAPI.IntegrationTestHelpers.Auth; + +public static class ServiceCollectionAuthExtensions +{ + public static IServiceCollection InjectJwtBackChannelHandler(this IServiceCollection services) + { + services.Insert(0, + ServiceDescriptor.Singleton, HotWiredJwtBackchannelHandler>()); + return services; + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs index 1fa981f..b4314b6 100644 --- a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs @@ -1,11 +1,7 @@ -using System.Net.Http.Headers; using JetBrains.Annotations; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using OpenAPI.IntegrationTestHelpers.Auth; using OpenAPI.IntegrationTestHelpers.Observability; @@ -18,17 +14,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => { - services.Insert(0, - ServiceDescriptor.Singleton, HotWiredJwtBackchannelHandler>()); + services.InjectJwtBackChannelHandler(); }); builder.AddLogging(); } - - protected override void ConfigureClient(HttpClient client) - { - base.ConfigureClient(client); - client.DefaultRequestHeaders.Authorization = - AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.Jwt}"); - } } \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs index 248a95e..5b2faa6 100644 --- a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs @@ -3,6 +3,7 @@ using AwesomeAssertions; using Example.OpenApi31.IntegrationTests.Http; using Example.OpenApi31.IntegrationTests.Json; +using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi31.IntegrationTests; @@ -11,7 +12,8 @@ public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication(); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), @@ -41,7 +43,8 @@ public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() [Fact] public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication(); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/test"), From 3b65dd4f98ee9d3064b6c1802fcf0ea184871971 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 25 Jan 2026 12:17:03 +0100 Subject: [PATCH 14/39] test: add oauth2 implicit flow example --- .../Example.OpenApi31/Example.OpenApi31.csproj | 2 ++ tests/Example.OpenApi31/Program.cs | 13 +++++++++++++ tests/Example.OpenApi31/openapi.json | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/Example.OpenApi31/Example.OpenApi31.csproj b/tests/Example.OpenApi31/Example.OpenApi31.csproj index 9c5c66c..6a0cc2b 100644 --- a/tests/Example.OpenApi31/Example.OpenApi31.csproj +++ b/tests/Example.OpenApi31/Example.OpenApi31.csproj @@ -9,6 +9,8 @@ + + diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 334940a..103f185 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -1,6 +1,19 @@ using Example.OpenApi31; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAuthentication() + .AddJwtBearer(SecuritySchemes.PetstoreAuthKey, options => + { + var authority = + new Uri(SecuritySchemes.PetstoreAuth.Flows.Implicit.AuthorizationUrl).GetLeftPart(UriPartial.Authority); + options.Authority = authority; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authority, + ValidAudience = authority, + }; + }); builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); app.MapOperations(); diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index f8d7cba..e1650c1 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -58,7 +58,8 @@ "400": { "$ref": "#/components/responses/BadRequest" } - } + }, + "security": [{"petstore_auth": []}] }, "delete": { "operationId": "Delete_Foo", @@ -142,6 +143,20 @@ } } } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://localhost/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + } } } } \ No newline at end of file From d38052ac488db8d61dc50c9c74a51c70399fb654 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Mon, 26 Jan 2026 21:59:13 +0100 Subject: [PATCH 15/39] test: add apikey authentication --- OpenAPI.WebApiGenerator.sln | 17 +++++++++ .../CodeGeneration/AuthGenerator.cs | 13 +++---- .../Auth/ApiKeyAuthenticationHandler.cs | 36 +++++++++++++++++++ .../Auth/ApiKeyAuthenticationOptions.cs | 9 +++++ tests/Example.OpenApi/Example.OpenApi.csproj | 13 +++++++ .../Example.OpenApi31.csproj | 1 + tests/Example.OpenApi31/Program.cs | 15 ++++++-- tests/Example.OpenApi31/openapi.json | 10 +++++- 8 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs create mode 100644 tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs create mode 100644 tests/Example.OpenApi/Example.OpenApi.csproj diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index 21f888a..3db1389 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -30,6 +30,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi32.Integrati EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.IntegrationTestHelpers", "OpenAPI.IntegrationTestHelpers\OpenAPI.IntegrationTestHelpers.csproj", "{58614455-A749-4B08-A8B9-DF876D8262AC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi", "tests\Example.OpenApi\Example.OpenApi.csproj", "{4E274740-E49C-4E56-9B69-C33D9409C119}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -172,10 +176,23 @@ Global {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x64.Build.0 = Release|Any CPU {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x86.ActiveCfg = Release|Any CPU {58614455-A749-4B08-A8B9-DF876D8262AC}.Release|x86.Build.0 = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|x64.Build.0 = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|x86.ActiveCfg = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Debug|x86.Build.0 = Debug|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|Any CPU.Build.0 = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x64.ActiveCfg = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x64.Build.0 = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.ActiveCfg = Release|Any CPU + {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {4E274740-E49C-4E56-9B69-C33D9409C119} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index cab7ed6..a50ca3c 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -44,9 +44,9 @@ internal static class {{className}} {{{new [] { GenerateConst(nameof(scheme.Description), scheme.Description), - GenerateConst(nameof(scheme.Type), GetEnumName(scheme.Type)), + GenerateConst(nameof(scheme.Type), scheme.Type?.GetDisplayName()), GenerateConst(nameof(scheme.Name), scheme.Name), - GenerateConst(nameof(scheme.In), GetEnumName(scheme.In)), + GenerateConst(nameof(scheme.In), scheme.In?.GetDisplayName()), GenerateConst(nameof(scheme.Scheme), scheme.Scheme), GenerateConst(nameof(scheme.BearerFormat), scheme.BearerFormat), GenerateConst(nameof(scheme.OpenIdConnectUrl), scheme.OpenIdConnectUrl?.ToString()), @@ -60,9 +60,6 @@ internal static class {{className}} """); } - private static string? GetEnumName(T? value) where T : struct, Enum => - value == null ? null : Enum.GetName(typeof(T), value); - private static string GenerateConst(string name, string? value) => value == null ? string.Empty @@ -128,14 +125,14 @@ internal string GenerateAuthorizationDirective(IList .AddAuthenticationSchemes({{string.Join(", ", uniqueSecuritySchemes.Select(scheme => $"\"{scheme}\""))}}) .AddRequirements( new SecurityRequirements - {{{securityRequirementGroups.Aggregate(string.Empty, (result, securityRequirementGroup) => - result + securityRequirementGroup.AggregateToString(securityRequirement => + {{{string.Join(", ", securityRequirementGroups.Select(securityRequirementGroup => + securityRequirementGroup.AggregateToString(securityRequirement => $$""" new SecurityRequirement { ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] } -"""))}} +""")))}} })) """; } diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..c0b8a26 --- /dev/null +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Example.OpenApi.Auth; + +public class ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var authenticated = Options.In switch + { + "header" => Request.Headers.TryGetValue(Options.Name, out var apiKey) && + !string.IsNullOrEmpty(apiKey), + "cookie" => Request.Cookies.TryGetValue(Options.Name, out var apiKey) && + !string.IsNullOrEmpty(apiKey), + "query" => Request.Query.TryGetValue(Options.Name, out var apiKey) && + !string.IsNullOrEmpty(apiKey), + _ => throw new InvalidOperationException($"Unknown location {Options.In}") + }; + if (!authenticated) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var identity = new ClaimsIdentity(Scheme.Name); + var principal = new ClaimsPrincipal(identity); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs new file mode 100644 index 0000000..365e812 --- /dev/null +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Example.OpenApi.Auth; + +public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions +{ + public string In { get; set; } = "Header"; + public string Name { get; set; } = "X-Api-Key"; +} \ No newline at end of file diff --git a/tests/Example.OpenApi/Example.OpenApi.csproj b/tests/Example.OpenApi/Example.OpenApi.csproj new file mode 100644 index 0000000..223d246 --- /dev/null +++ b/tests/Example.OpenApi/Example.OpenApi.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/tests/Example.OpenApi31/Example.OpenApi31.csproj b/tests/Example.OpenApi31/Example.OpenApi31.csproj index 6a0cc2b..926d379 100644 --- a/tests/Example.OpenApi31/Example.OpenApi31.csproj +++ b/tests/Example.OpenApi31/Example.OpenApi31.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 103f185..2d4dfd6 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -1,8 +1,9 @@ +using Example.OpenApi.Auth; using Example.OpenApi31; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddAuthentication() +builder.Services.AddAuthentication() .AddJwtBearer(SecuritySchemes.PetstoreAuthKey, options => { var authority = @@ -10,10 +11,18 @@ options.Authority = authority; options.TokenValidationParameters = new TokenValidationParameters { - ValidIssuer = authority, + ValidIssuer = authority, ValidAudience = authority, }; - }); + }) + .AddScheme( + SecuritySchemes.SecretKeyKey, + options => + { + options.In = SecuritySchemes.SecretKey.In; + options.Name = SecuritySchemes.SecretKey.Name; + }); + builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); app.MapOperations(); diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index e1650c1..a49dac3 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -59,7 +59,10 @@ "$ref": "#/components/responses/BadRequest" } }, - "security": [{"petstore_auth": []}] + "security": [ + {"petstore_auth": []}, + {"secret_key": []} + ] }, "delete": { "operationId": "Delete_Foo", @@ -156,6 +159,11 @@ } } } + }, + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "SecretKey" } } } From aaa5efbcae051dc3637c03dac95ccc0c65d89865 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 27 Jan 2026 23:18:52 +0100 Subject: [PATCH 16/39] refactor: separate request binding to a filter to be able to use request parameters in authentication filters --- .../CodeGeneration/OperationGenerator.cs | 27 +++++++++++++++++-- .../OperationRouterGenerator.cs | 5 ++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 145447b..681692c 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -20,6 +20,7 @@ internal SourceCode Generate(string @namespace, string path, string pathTemplate var endpointSource = $$""" using Corvus.Json; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Collections.Immutable; using System.Threading; @@ -31,6 +32,8 @@ internal partial class Operation internal const string PathTemplate = "{{pathTemplate}}"; internal const string Method = "{{method.Method}}"; + private const string RequestItemKey = "OpenAPI.WebApiGenerator.Request"; + /// /// Set validation level for requests and responses /// @@ -51,6 +54,23 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; + internal sealed class BindRequestFilter(Operation operation) : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var cancellationToken = httpContext.RequestAborted; + + var request = await Request.BindAsync(httpContext, cancellationToken) + .ConfigureAwait(false); + + httpContext.Items.Add(RequestItemKey, request); + + return await next(context) + .ConfigureAwait(false); + } + } + /// /// Handle a operation. /// @@ -61,8 +81,11 @@ internal static async Task HandleAsync( [FromServices] WebApiConfiguration configuration, CancellationToken cancellationToken) { - var request = await Request.BindAsync(context, cancellationToken) - .ConfigureAwait(false); + if (!context.Items.TryGetValue(RequestItemKey, out var requestObject)) + { + throw new InvalidOperationException($"{RequestItemKey} is missing in request items"); + } + var request = requestObject as Request ?? throw new InvalidOperationException("Request object is not the Request type"); var validationContext = request.Validate(operation.ValidationLevel); if (!validationContext.IsValid) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index f85413d..3587c74 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -26,8 +26,9 @@ internal static WebApplication MapOperations(this WebApplication app) """ : "")}}{{operations.AggregateToString(operation => $""" - app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync){ - authGenerator.GenerateAuthorizationDirective(operation.Operation.Value.Security).Indent(12)}; + app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) + .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>(){ + authGenerator.GenerateAuthorizationDirective(operation.Operation.Value).Indent(12)}; """)}} return app; } From 2a30d906ad757eb0f6813af96e980039e16963e7 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 29 Jan 2026 21:44:04 +0100 Subject: [PATCH 17/39] add missing nullable directive --- .../CodeGeneration/OperationGenerator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 681692c..87fcdd0 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -19,6 +19,7 @@ internal SourceCode Generate(string @namespace, string path, string pathTemplate { var endpointSource = $$""" +#nullable enable using Corvus.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -109,6 +110,7 @@ internal static async Task HandleAsync( response.WriteTo(context.Response); } } +#nullable restore """; var hasImplementedHandleMethod = compilation.GetSymbolsWithName("Operation", SymbolFilter.Type) From 227eca719229a4d641b9d76145a9813d4b79e12c Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 29 Jan 2026 23:48:02 +0100 Subject: [PATCH 18/39] refactor(auth): use a custom endpoint filter for security requirements --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 14 ++- .../CodeGeneration/AuthGenerator.cs | 103 +++++++++++++++++- .../CodeGeneration/OperationGenerator.cs | 25 ++++- .../OperationRouterGenerator.cs | 24 +--- .../CodeGeneration/ResponseGenerator.cs | 20 ++++ 5 files changed, 157 insertions(+), 29 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 759140b..0ddbe81 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -67,7 +67,12 @@ private static void GenerateCode(SourceProductionContext context, var jsonValidationExceptionGenerator = new JsonValidationExceptionGenerator(rootNamespace); jsonValidationExceptionGenerator.GenerateJsonValidationExceptionClass().AddTo(context); - var endpointGenerator = new OperationGenerator(compilation, jsonValidationExceptionGenerator, options); + var authGenerator = new AuthGenerator(openApi); + var endpointGenerator = new OperationGenerator( + compilation, + jsonValidationExceptionGenerator, + authGenerator, + options); var httpRequestExtensionsGenerator = new HttpRequestExtensionsGenerator( openApiVersion, @@ -78,8 +83,6 @@ private static void GenerateCode(SourceProductionContext context, openApiVersion); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); - var authGenerator = new AuthGenerator(openApi); - var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace, authGenerator); apiConfigurationGenerator.GenerateClass().AddTo(context); @@ -109,7 +112,6 @@ private static void GenerateCode(SourceProductionContext context, var operationMetadata = TypeMetadata.From(openApiOperationVisitor.Pointer); var operationDirectory = operationMetadata.Path; var operationNamespace = $"{rootNamespace}.{operationMetadata.Namespace}.{operationMetadata.Name}"; - var operationMethod = openApiOperation.Key; var operation = openApiOperation.Value; var operationParameterGenerators = new Dictionary(pathParameterGenerators); @@ -201,7 +203,7 @@ private static void GenerateCode(SourceProductionContext context, .Generate(operationNamespace, operationDirectory, pathExpression, - operationMethod); + (openApiOperation.Key, openApiOperation.Value)); endpointSource .AddTo(context); } @@ -219,7 +221,7 @@ private static void GenerateCode(SourceProductionContext context, authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecurityRequirementHandler(rootNamespace)?.AddTo(context); - var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); + var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index a50ca3c..bf049d9 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -105,10 +105,10 @@ internal static class {{className}} } """; - internal string GenerateAuthorizationDirective(IList? securityRequirements) + internal string GenerateAuthorizationDirective(OpenApiOperation operation) { var securityRequirementGroups = - GetSecuritySchemeGroups(securityRequirements) ?? _topLevelSecuritySchemeGroups; + GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; if (!securityRequirementGroups.Any()) { return string.Empty; @@ -211,6 +211,105 @@ internal sealed class SecurityRequirement : Dictionary; """); } + internal string GenerateAuthFilter(OpenApiOperation operation) + { + var securityRequirementGroups = + GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; + + return +$$""" +internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConfiguration configuration) : IEndpointFilter +{ + private static readonly SecurityRequirements Requirements = new() + {{{string.Join(", ", + securityRequirementGroups.Select(securityRequirementGroup => + securityRequirementGroup.AggregateToString(securityRequirement => +$$""" + new SecurityRequirement + { + ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] + } +""")))}} + }; + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var cancellationToken = httpContext.RequestAborted; + + var principal = httpContext.User ??= new(); + + var passed = true; + var passedAuthentication = true; + // Only one of the security requirement objects need to be satisfied to authorize a request. + foreach (var securityRequirement in Requirements) + { + var authenticated = true; + var authorized = true; + // Security Requirement Objects that contain multiple schemes require that all schemes MUST be satisfied for a request to be authorized. + foreach (var (scheme, scopes) in securityRequirement) + { + var authenticateResult = await httpContext.AuthenticateAsync(scheme) + .ConfigureAwait(false); + + if (authenticateResult.Succeeded) + { + principal.AddIdentities(authenticateResult.Principal.Identities); + } + else + { + authenticated = false; + break; + } + + authorized &= ClaimContainsScopes(authenticateResult.Principal, configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); + if (!authorized) + break; + } + + passedAuthentication |= authenticated; + passed |= (authenticated && authorized); + } + + if (passed) + { + if (!principal.Identities.Any()) + { + // Anonymous + principal.AddIdentity(new ClaimsIdentity()); + } + return await next(context) + .ConfigureAwait(false); + } + + if (passedAuthentication) + { + operation.HandleForbidden().WriteTo(httpContext.Response); + return null; + } + + operation.HandleUnauthorized().WriteTo(httpContext.Response); + return null; + } + + private static bool ClaimContainsScopes(ClaimsPrincipal? principal, SecuritySchemeOptions.ScopeOptions scopeOptions, params string[] scopes) + { + var foundScopes = scopeOptions.Format switch + { + SecuritySchemeOptions.ScopeOptions.ClaimFormat.SpaceDelimited => principal?.FindFirst(scopeOptions.Claim)?.Value?.Split(' ') ?? [], + SecuritySchemeOptions.ScopeOptions.ClaimFormat.Array => principal?.FindAll(scopeOptions.Claim)?.Select(claim => claim.Value)?.ToArray() ?? [], + _ => throw new InvalidOperationException($"{Enum.GetName(typeof(SecuritySchemeOptions.ScopeOptions.ClaimFormat), scopeOptions.Format)} not supported") + }; + + return scopes.All(scope => foundScopes.Contains(scope)); + } + + private class SecurityRequirements : List, IAuthorizationRequirement; + private class SecurityRequirement : Dictionary; +} +"""; + } + internal SourceCode? GenerateSecuritySchemeOptionsClass(string @namespace) { if (!_securitySchemes.Any()) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 87fcdd0..d6cdf8b 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -5,25 +5,34 @@ using Corvus.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class OperationGenerator(Compilation compilation, JsonValidationExceptionGenerator jsonValidationExceptionGenerator, + AuthGenerator authGenerator, Options options) { private readonly List<(string Namespace, string Path)> _missingHandlers = []; - internal SourceCode Generate(string @namespace, string path, string pathTemplate, HttpMethod method) + internal SourceCode Generate( + string @namespace, + string path, + string pathTemplate, + (HttpMethod Method, OpenApiOperation Operation) operation) { var endpointSource = $$""" #nullable enable using Corvus.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Collections.Immutable; +using System.Security.Claims; using System.Threading; namespace {{@namespace}}; @@ -31,7 +40,7 @@ namespace {{@namespace}}; internal partial class Operation { internal const string PathTemplate = "{{pathTemplate}}"; - internal const string Method = "{{method.Method}}"; + internal const string Method = "{{operation.Method}}"; private const string RequestItemKey = "OpenAPI.WebApiGenerator.Request"; @@ -55,6 +64,16 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; + /// + /// Set a custom delegate to handle unauthorized responses. + /// + private Func HandleUnauthorized { get; } = () => new Response.Unauthorized(); + + /// + /// Set a custom delegate to handle forbidden responses. + /// + private Func HandleForbidden { get; } = () => new Response.Forbidden(); + internal sealed class BindRequestFilter(Operation operation) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -72,6 +91,8 @@ internal sealed class BindRequestFilter(Operation operation) : IEndpointFilter } } +{{authGenerator.GenerateAuthFilter(operation.Operation).Indent(4)}} + /// /// Handle a operation. /// diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 3587c74..e499e57 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -5,7 +5,7 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class OperationRouterGenerator(string @namespace, AuthGenerator authGenerator) +internal sealed class OperationRouterGenerator(string @namespace) { internal SourceCode ForMinimalApi(List<(string Namespace, KeyValuePair Operation)> operations) => new("OperationRouter.g.cs", @@ -18,31 +18,17 @@ namespace {{@namespace}}; internal static class OperationRouter { internal static WebApplication MapOperations(this WebApplication app) - {{{(authGenerator.HasSecuritySchemes ? -""" - - app.UseAuthentication(); - app.UseAuthorization(); - -""" : "")}}{{operations.AggregateToString(operation => + {{{operations.AggregateToString(operation => $""" app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) - .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>(){ - authGenerator.GenerateAuthorizationDirective(operation.Operation.Value).Indent(12)}; + .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>() + .AddEndpointFilter<{operation.Namespace}.Operation.SecurityRequirementsFilter>(); """)}} return app; } internal static WebApplicationBuilder AddOperations(this WebApplicationBuilder builder, WebApiConfiguration? configuration = null) - {{{(authGenerator.HasSecuritySchemes ? -""" - - builder.Services.AddAuthentication(); - builder.Services.AddAuthorization(); - builder.Services.AddHttpContextAccessor(); - builder.Services.AddSingleton(); - -""" : "")}}{{operations.AggregateToString(operation => + {{{operations.AggregateToString(operation => $""" builder.Services.AddScoped<{operation.Namespace}.Operation>(); """)}} diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index eb4beae..13e3df4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -28,6 +28,26 @@ internal abstract partial class Response internal abstract void WriteTo(HttpResponse httpResponse); internal abstract ValidationContext Validate(ValidationLevel validationLevel); + + internal sealed class Unauthorized : Response + { + internal override void WriteTo(HttpResponse httpResponse) + { + httpResponse.StatusCode = 401; + } + + internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; + } + + internal sealed class Forbidden : Response + { + internal override void WriteTo(HttpResponse httpResponse) + { + httpResponse.StatusCode = 403; + } + + internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; + } {{ responseBodyGenerators.AggregateToString(generator => generator.GenerateResponseContentClass()).Indent(4) From 2f7e860ac6bb829a02acc60bebce1530ebcdffa5 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 29 Jan 2026 23:53:00 +0100 Subject: [PATCH 19/39] remove unused auth code --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 1 - .../CodeGeneration/AuthGenerator.cs | 109 +----------------- 2 files changed, 1 insertion(+), 109 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 0ddbe81..8b9e440 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -220,7 +220,6 @@ private static void GenerateCode(SourceProductionContext context, authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); - authGenerator.GenerateSecurityRequirementHandler(rootNamespace)?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index bf049d9..0bf9e6d 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -105,112 +104,6 @@ internal static class {{className}} } """; - internal string GenerateAuthorizationDirective(OpenApiOperation operation) - { - var securityRequirementGroups = - GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; - if (!securityRequirementGroups.Any()) - { - return string.Empty; - } - - var uniqueSecuritySchemes = securityRequirementGroups - .SelectMany(schemes => schemes.Select(pair => pair.Key)) - .Distinct(); - return -$$""" - -.RequireAuthorization(policy => - policy - .AddAuthenticationSchemes({{string.Join(", ", uniqueSecuritySchemes.Select(scheme => $"\"{scheme}\""))}}) - .AddRequirements( - new SecurityRequirements - {{{string.Join(", ", securityRequirementGroups.Select(securityRequirementGroup => - securityRequirementGroup.AggregateToString(securityRequirement => -$$""" - new SecurityRequirement - { - ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] - } -""")))}} - })) -"""; - } - - internal SourceCode? GenerateSecurityRequirementHandler(string @namespace) - { - if (!HasSecuritySchemes) - { - return null; - } - return new SourceCode("SecurityRequirementHandler.g.cs", -$$""" -#nullable enable -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Authorization.Infrastructure; -using System.Security.Claims; - -namespace {{@namespace}}; - -internal sealed class SecurityRequirementHandler(IHttpContextAccessor httpContextAccessor, WebApiConfiguration configuration) - : AuthorizationHandler -{ - protected override async Task HandleRequirementAsync( - AuthorizationHandlerContext context, - SecurityRequirements securityRequirements) - { - var httpContext = httpContextAccessor.HttpContext; - - if (httpContext == null) - { - context.Fail(new AuthorizationFailureReason(this, "No HttpContext available")); - return; - } - - // Only one of the security requirement objects need to be satisfied to authorize a request. - foreach (var securityRequirement in securityRequirements) - { - var allRequirementsPassed = true; - // Security Requirement Objects that contain multiple schemes require that all schemes MUST be satisfied for a request to be authorized. - foreach (var (scheme, scopes) in securityRequirement) - { - var authenticateResult = await httpContext.AuthenticateAsync(scheme) - .ConfigureAwait(false); - allRequirementsPassed = authenticateResult.Succeeded && - ClaimContainsScopes(authenticateResult.Principal, configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); - if (!allRequirementsPassed) - { - break; - } - } - if (allRequirementsPassed) - { - context.Succeed(securityRequirements); - return; - } - } - } - - private static bool ClaimContainsScopes(ClaimsPrincipal? principal, SecuritySchemeOptions.ScopeOptions scopeOptions, params string[] scopes) - { - var foundScopes = scopeOptions.Format switch - { - SecuritySchemeOptions.ScopeOptions.ClaimFormat.SpaceDelimited => principal?.FindFirst(scopeOptions.Claim)?.Value?.Split(' ') ?? [], - SecuritySchemeOptions.ScopeOptions.ClaimFormat.Array => principal?.FindAll(scopeOptions.Claim)?.Select(claim => claim.Value)?.ToArray() ?? [], - _ => throw new InvalidOperationException($"{Enum.GetName(typeof(SecuritySchemeOptions.ScopeOptions.ClaimFormat), scopeOptions.Format)} not supported") - }; - - return scopes.All(scope => foundScopes.Contains(scope)); - } -} - -internal sealed class SecurityRequirements : List, IAuthorizationRequirement; -internal sealed class SecurityRequirement : Dictionary; -#nullable restore -"""); - } - internal string GenerateAuthFilter(OpenApiOperation operation) { var securityRequirementGroups = From c18d0d8dd27a63195233d8563949e2c938ddb1f1 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 30 Jan 2026 19:05:58 +0100 Subject: [PATCH 20/39] generate Anonymous security filter if operation doesn't define security requirements --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 1 + .../CodeGeneration/AuthGenerator.cs | 103 ++++++++++++++---- 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 8b9e440..865159d 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -220,6 +220,7 @@ private static void GenerateCode(SourceProductionContext context, authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); + authGenerator.GenerateSecurityRequirementsFilter(rootNamespace).AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 0bf9e6d..13faaa3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -104,27 +104,47 @@ internal static class {{className}} } """; - internal string GenerateAuthFilter(OpenApiOperation operation) + internal SourceCode GenerateSecurityRequirementsFilter(string @namespace) { - var securityRequirementGroups = - GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; - - return + if (!_securitySchemes.Any()) + { + return new SourceCode("SecurityRequirementsFilter.g.cs", $$""" -internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConfiguration configuration) : IEndpointFilter +#nullable enable +using System.Security.Claims; + +namespace {{@namespace}}; + +internal sealed class AnonymousFilter() : IEndpointFilter { - private static readonly SecurityRequirements Requirements = new() - {{{string.Join(", ", - securityRequirementGroups.Select(securityRequirementGroup => - securityRequirementGroup.AggregateToString(securityRequirement => -$$""" - new SecurityRequirement - { - ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] + internal static readonly AnonymousFilter Instance = new AnonymousFilter(); + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + // Anonymous + context.HttpContext.User ??= new(new ClaimsIdentity());; + return next(context); + } +} +#nullable restore +"""); } -""")))}} - }; + + return new SourceCode("SecurityRequirementsFilter.g.cs", +$$""" +#nullable enable +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; + +namespace {{@namespace}}; + +internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration configuration) : IEndpointFilter +{ + protected abstract SecurityRequirements Requirements { get; } + protected abstract void HandleForbidden(HttpResponse response); + protected abstract void HandleUnauthorized(HttpResponse response); + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var httpContext = context.HttpContext; @@ -177,11 +197,11 @@ internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConf if (passedAuthentication) { - operation.HandleForbidden().WriteTo(httpContext.Response); + HandleForbidden(httpContext.Response); return null; } - operation.HandleUnauthorized().WriteTo(httpContext.Response); + HandleUnauthorized(httpContext.Response); return null; } @@ -197,8 +217,51 @@ private static bool ClaimContainsScopes(ClaimsPrincipal? principal, SecuritySche return scopes.All(scope => foundScopes.Contains(scope)); } - private class SecurityRequirements : List, IAuthorizationRequirement; - private class SecurityRequirement : Dictionary; + internal class SecurityRequirements : List, IAuthorizationRequirement; + internal class SecurityRequirement : Dictionary; +} +#nullable restore +"""); + } + + internal string GenerateAuthFilter(OpenApiOperation operation) + { + var securityRequirementGroups = + GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; + if (!securityRequirementGroups.Any()) + { + return +""" +internal sealed class SecurityRequirementsFilter() : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + // Anonymous + context.HttpContext.User ??= new(new ClaimsIdentity());; + return next(context); + } +} +"""; + } + + return +$$""" +internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConfiguration configuration) : BaseSecurityRequirementsFilter(configuration) +{ + protected override SecurityRequirements Requirements { get; } = new() + {{{string.Join(", ", + securityRequirementGroups.Select(securityRequirementGroup => + securityRequirementGroup.AggregateToString(securityRequirement => +$$""" + new SecurityRequirement + { + ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] + } +""")))}} + }; + + protected override void HandleUnauthorized(HttpResponse response) => operation.HandleUnauthorized().WriteTo(response); + protected override void HandleForbidden(HttpResponse response) => operation.HandleForbidden().WriteTo(response); } """; } From c3fd979b1a6a9fecca2964a5ea5651eac0f539f8 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 30 Jan 2026 21:23:13 +0100 Subject: [PATCH 21/39] only generate auth response handlers if the operation requires auth --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 2 +- .../CodeGeneration/AuthGenerator.cs | 29 ++++--------------- .../CodeGeneration/OperationGenerator.cs | 11 ++++--- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 865159d..7616492 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -220,7 +220,7 @@ private static void GenerateCode(SourceProductionContext context, authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); - authGenerator.GenerateSecurityRequirementsFilter(rootNamespace).AddTo(context); + authGenerator.GenerateSecurityRequirementsFilter(rootNamespace)?.AddTo(context); var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 13faaa3..2d576e4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -104,29 +104,11 @@ internal static class {{className}} } """; - internal SourceCode GenerateSecurityRequirementsFilter(string @namespace) + internal SourceCode? GenerateSecurityRequirementsFilter(string @namespace) { if (!_securitySchemes.Any()) { - return new SourceCode("SecurityRequirementsFilter.g.cs", -$$""" -#nullable enable -using System.Security.Claims; - -namespace {{@namespace}}; - -internal sealed class AnonymousFilter() : IEndpointFilter -{ - internal static readonly AnonymousFilter Instance = new AnonymousFilter(); - public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - // Anonymous - context.HttpContext.User ??= new(new ClaimsIdentity());; - return next(context); - } -} -#nullable restore -"""); + return null; } return new SourceCode("SecurityRequirementsFilter.g.cs", @@ -224,15 +206,16 @@ internal class SecurityRequirement : Dictionary; """); } - internal string GenerateAuthFilter(OpenApiOperation operation) + internal string GenerateAuthFilter(OpenApiOperation operation, out bool requiresAuth) { var securityRequirementGroups = GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; - if (!securityRequirementGroups.Any()) + requiresAuth = securityRequirementGroups.Any(); + if (!requiresAuth) { return """ -internal sealed class SecurityRequirementsFilter() : IEndpointFilter +internal sealed class SecurityRequirementsFilter : IEndpointFilter { public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index d6cdf8b..d2acb84 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -64,16 +64,21 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; +{{authGenerator.GenerateAuthFilter(operation.Operation, out var requiresAuth).Indent(4)}} +{{(requiresAuth ? +""" + /// /// Set a custom delegate to handle unauthorized responses. /// private Func HandleUnauthorized { get; } = () => new Response.Unauthorized(); - + /// /// Set a custom delegate to handle forbidden responses. /// private Func HandleForbidden { get; } = () => new Response.Forbidden(); - + +""" : "")}} internal sealed class BindRequestFilter(Operation operation) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -91,8 +96,6 @@ internal sealed class BindRequestFilter(Operation operation) : IEndpointFilter } } -{{authGenerator.GenerateAuthFilter(operation.Operation).Indent(4)}} - /// /// Handle a operation. /// From 6b2176530bf16f9945b431f5ae18f23a46cb5f95 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 30 Jan 2026 21:38:38 +0100 Subject: [PATCH 22/39] conditionally render default auth response classes --- .../CodeGeneration/OperationGenerator.cs | 25 +++++++++++++++++++ .../CodeGeneration/ResponseGenerator.cs | 18 ------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index d2acb84..ecd1936 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -133,7 +133,32 @@ internal static async Task HandleAsync( } response.WriteTo(context.Response); } +}{{(requiresAuth ? +""" + +internal abstract partial class Response +{ + internal sealed class Unauthorized : Response + { + internal override void WriteTo(HttpResponse httpResponse) + { + httpResponse.StatusCode = 401; + } + + internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; + } + + internal sealed class Forbidden : Response + { + internal override void WriteTo(HttpResponse httpResponse) + { + httpResponse.StatusCode = 403; + } + + internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; + } } +""" : "")}} #nullable restore """; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 13e3df4..63f9994 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -29,25 +29,7 @@ internal abstract partial class Response internal abstract void WriteTo(HttpResponse httpResponse); internal abstract ValidationContext Validate(ValidationLevel validationLevel); - internal sealed class Unauthorized : Response - { - internal override void WriteTo(HttpResponse httpResponse) - { - httpResponse.StatusCode = 401; - } - - internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; - } - internal sealed class Forbidden : Response - { - internal override void WriteTo(HttpResponse httpResponse) - { - httpResponse.StatusCode = 403; - } - - internal override ValidationContext Validate(ValidationLevel validationLevel) => ValidationContext.ValidContext; - } {{ responseBodyGenerators.AggregateToString(generator => generator.GenerateResponseContentClass()).Indent(4) From 7342dd407d9b7d4e6f37cad79e5152a17debe2a5 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 30 Jan 2026 23:38:37 +0100 Subject: [PATCH 23/39] validate auth responses --- .../CodeGeneration/AuthGenerator.cs | 4 +-- .../CodeGeneration/OperationGenerator.cs | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 2d576e4..39061c3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -243,8 +243,8 @@ internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConf """)))}} }; - protected override void HandleUnauthorized(HttpResponse response) => operation.HandleUnauthorized().WriteTo(response); - protected override void HandleForbidden(HttpResponse response) => operation.HandleForbidden().WriteTo(response); + protected override void HandleUnauthorized(HttpResponse response) => operation.Validate(operation.HandleUnauthorized(), configuration).WriteTo(response); + protected override void HandleForbidden(HttpResponse response) => operation.Validate(operation.HandleForbidden(), configuration).WriteTo(response); } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index ecd1936..51c7d14 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -122,16 +122,21 @@ internal static async Task HandleAsync( var response = await operation.HandleAsync(request, cancellationToken) .ConfigureAwait(false); - if (operation.ValidateResponse) - { - validationContext = response.Validate(operation.ValidationLevel); - if (!validationContext.IsValid) - { - var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); - {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; - } - } - response.WriteTo(context.Response); + operation.Validate(response, configuration) + .WriteTo(context.Response); + } + + internal Response Validate(Response response, WebApiConfiguration configuration) + { + if (!ValidateResponse) + return response; + + var validationContext = response.Validate(ValidationLevel); + if (validationContext.IsValid) + return response; + + var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); + {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; } }{{(requiresAuth ? """ From 836cbc82ec4597d5fa155697a199815c107edce6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 3 Feb 2026 21:28:22 +0100 Subject: [PATCH 24/39] feat(auth): add support for api key parameter resolution --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 7 +- .../CodeGeneration/AuthGenerator.cs | 127 ++++++++++++++++-- .../CodeGeneration/OperationGenerator.cs | 12 +- .../OperationRouterGenerator.cs | 7 +- .../CodeGeneration/ParameterGenerator.cs | 6 +- .../Auth/ApiKeyAuthenticationHandler.cs | 15 +-- .../Auth/ApiKeyAuthenticationOptions.cs | 4 +- tests/Example.OpenApi31/Program.cs | 6 +- tests/Example.OpenApi31/openapi.json | 2 +- 9 files changed, 146 insertions(+), 40 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 7616492..a70fd1a 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -90,6 +91,7 @@ private static void GenerateCode(SourceProductionContext context, validationExtensionsGenerator.GenerateClass().AddTo(context); var operations = new List<(string Namespace, KeyValuePair Operation)>(); + var securityParameterGenerators = new ConcurrentDictionary>(); foreach (var path in openApi.Paths) { var pathExpression = path.Key; @@ -203,7 +205,8 @@ private static void GenerateCode(SourceProductionContext context, .Generate(operationNamespace, operationDirectory, pathExpression, - (openApiOperation.Key, openApiOperation.Value)); + (openApiOperation.Key, openApiOperation.Value), + operationParameterGenerators.Values.ToArray()); endpointSource .AddTo(context); } @@ -221,7 +224,7 @@ private static void GenerateCode(SourceProductionContext context, authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); authGenerator.GenerateSecurityRequirementsFilter(rootNamespace)?.AddTo(context); - var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); + var operationRouterGenerator = new OperationRouterGenerator(rootNamespace, authGenerator); operationRouterGenerator.ForMinimalApi(operations).AddTo(context); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 39061c3..6a82d88 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -10,11 +12,15 @@ internal sealed class AuthGenerator private readonly IDictionary _securitySchemes; private readonly Dictionary>[] _topLevelSecuritySchemeGroups; - public AuthGenerator(OpenApiDocument securitySchemes) + private readonly ConcurrentDictionary> _securitySchemeParameters = new(); + + private readonly Dictionary _requestFilters = new(); + + public AuthGenerator(OpenApiDocument openApiDocument) { - _securitySchemes = securitySchemes.Components?.SecuritySchemes ?? + _securitySchemes = openApiDocument.Components?.SecuritySchemes ?? new Dictionary(); - _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(securitySchemes.Security) ?? []; + _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(openApiDocument.Security) ?? []; HasSecuritySchemes = _securitySchemes.Any(); } @@ -34,7 +40,8 @@ namespace {{@namespace}}; internal static class SecuritySchemes {{{_securitySchemes.AggregateToString(pair => { - var className = pair.Key.ToPascalCase(); + var schemeName = pair.Key; + var className = schemeName.ToPascalCase(); var scheme = pair.Value; return scheme.Type == null ? string.Empty : $$""" @@ -44,11 +51,10 @@ internal static class {{className}} { GenerateConst(nameof(scheme.Description), scheme.Description), GenerateConst(nameof(scheme.Type), scheme.Type?.GetDisplayName()), - GenerateConst(nameof(scheme.Name), scheme.Name), - GenerateConst(nameof(scheme.In), scheme.In?.GetDisplayName()), GenerateConst(nameof(scheme.Scheme), scheme.Scheme), GenerateConst(nameof(scheme.BearerFormat), scheme.BearerFormat), GenerateConst(nameof(scheme.OpenIdConnectUrl), scheme.OpenIdConnectUrl?.ToString()), + GenerateGetParameterMethods(schemeName, scheme), $"internal const bool {nameof(scheme.Deprecated)} = {scheme.Deprecated.ToString().ToLowerInvariant()};", GenerateFlowsObject(nameof(scheme.Flows), scheme.Flows) }.RemoveEmptyLines().AggregateToString().Indent(8)}} @@ -66,6 +72,50 @@ private static string GenerateConst(string name, string? value) => internal const string {name} = "{value}"; """; + private string GenerateGetParameterMethods(string schemeName, IOpenApiSecurityScheme scheme) + { + if (scheme.Name == null || scheme.In == null) + { + return string.Empty; + } + return +$$""" +private static bool TryGet(HttpContext context, out T value) where T : struct +{ + if (TryGet(context, out T? nullableValue)) + { + value = (T)nullableValue!; + } + + value = default; + return false; +} + +private static bool TryGet(HttpContext context, out T? value) where T : struct +{ + var itemValue = context.Items["{{GetSecuritySchemeParameterKey(_securitySchemeParameters[schemeName].First())}}"]; + + switch (itemValue) + { + case T typedValue: + value = typedValue; + return true; + case null: + value = null; + return true; + } + + value = null; + return false; +} +{{_securitySchemeParameters[schemeName].AggregateToString(generator => +$""" +internal static bool TryGetParameter(HttpContext context, out {generator.FullyQualifiedTypeName} value) => + TryGet(context, out value); +""")}} +"""; + } + private static string GenerateFlowsObject(string className, OpenApiOAuthFlows? flows) => flows == null ? string.Empty : $$""" @@ -104,6 +154,7 @@ internal static class {{className}} } """; + internal string[] GetSecurityFilterNames(OpenApiOperation operation) => _requestFilters[operation]; internal SourceCode? GenerateSecurityRequirementsFilter(string @namespace) { if (!_securitySchemes.Any()) @@ -206,16 +257,21 @@ internal class SecurityRequirement : Dictionary; """); } - internal string GenerateAuthFilter(OpenApiOperation operation, out bool requiresAuth) + internal string GenerateAuthFilters(OpenApiOperation operation, ParameterGenerator[] parameters, + out bool requiresAuth) { + const string securitySchemeParameterFilterClassName = "SecuritySchemeParameterFilter"; + const string securityRequirementsFilterClassName = "SecurityRequirementsFilter"; + var securityRequirementGroups = GetSecuritySchemeGroups(operation.Security) ?? _topLevelSecuritySchemeGroups; requiresAuth = securityRequirementGroups.Any(); if (!requiresAuth) { + _requestFilters.Add(operation, [securityRequirementsFilterClassName]); return -""" -internal sealed class SecurityRequirementsFilter : IEndpointFilter +$$""" +internal sealed class {{securityRequirementsFilterClassName}} : IEndpointFilter { public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { @@ -227,9 +283,53 @@ internal sealed class SecurityRequirementsFilter : IEndpointFilter """; } - return + var securitySchemeParameters = + operation.Security? + .SelectMany(requirement => + requirement.Where(pair => pair.Key.In != null && pair.Key.Name != null) + .Select(pair => pair.Key)) + .Distinct() + .ToDictionary(reference => reference, + reference => + parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)) ?? + throw new InvalidOperationException( + $"Operation {operation.OperationId} defines security scheme {GetSecuritySchemeName(reference)} that references parameter {reference.Name} in location {reference.In} which is not defined by the operation")) + ?? []; + + foreach (var securitySchemeParameter in securitySchemeParameters) + { + _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(securitySchemeParameter.Key), + _ => [securitySchemeParameter.Value], + (_, list) => + { + list.Add(securitySchemeParameter.Value); + return list; + }); + } + + var hasSecuritySchemeParameters = securitySchemeParameters.Any(); + _requestFilters.Add(operation, + hasSecuritySchemeParameters + ? [securitySchemeParameterFilterClassName, securityRequirementsFilterClassName] + : [securityRequirementsFilterClassName]); + return (hasSecuritySchemeParameters ? $$""" -internal sealed class SecurityRequirementsFilter(Operation operation, WebApiConfiguration configuration) : BaseSecurityRequirementsFilter(configuration) +internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var request = (Request) httpContext.Items[RequestItemKey]!; +{{securitySchemeParameters.Values.Distinct().AggregateToString(parameterGenerator => +$""" + httpContext.Items.Add("{GetSecuritySchemeParameterKey(parameterGenerator)}", request.{parameterGenerator.Location.ToPascalCase()}.{parameterGenerator.PropertyName}); +""")}} + return next(context); + } +} +""" : string.Empty) + +$$""" +internal sealed class {{securityRequirementsFilterClassName}}(Operation operation, WebApiConfiguration configuration) : BaseSecurityRequirementsFilter(configuration) { protected override SecurityRequirements Requirements { get; } = new() {{{string.Join(", ", @@ -309,4 +409,7 @@ internal enum ClaimFormat .ToArray(); private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) => _securitySchemes.First(pair => pair.Value == reference.Target).Key; + + private static string GetSecuritySchemeParameterKey(ParameterGenerator generator) => + $"OpenAPI.WebApiGenerator.SecurityScheme.{generator.Location}.{generator.PropertyName}"; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 51c7d14..2fa1bb2 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -17,11 +17,11 @@ internal sealed class OperationGenerator(Compilation compilation, { private readonly List<(string Namespace, string Path)> _missingHandlers = []; - internal SourceCode Generate( - string @namespace, - string path, - string pathTemplate, - (HttpMethod Method, OpenApiOperation Operation) operation) + internal SourceCode Generate(string @namespace, + string path, + string pathTemplate, + (HttpMethod Method, OpenApiOperation Operation) operation, + ParameterGenerator[] parameters) { var endpointSource = $$""" @@ -64,7 +64,7 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; -{{authGenerator.GenerateAuthFilter(operation.Operation, out var requiresAuth).Indent(4)}} +{{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}} {{(requiresAuth ? """ diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index e499e57..dc8a85e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -5,7 +5,7 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class OperationRouterGenerator(string @namespace) +internal sealed class OperationRouterGenerator(string @namespace, AuthGenerator authGenerator) { internal SourceCode ForMinimalApi(List<(string Namespace, KeyValuePair Operation)> operations) => new("OperationRouter.g.cs", @@ -22,7 +22,10 @@ internal static WebApplication MapOperations(this WebApplication app) $""" app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>() - .AddEndpointFilter<{operation.Namespace}.Operation.SecurityRequirementsFilter>(); +{authGenerator.GetSecurityFilterNames(operation.Operation.Value).AggregateToString(name => +$""" + .AddEndpointFilter<{operation.Namespace}.Operation.{name}>() +""")}; """)}} return app; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs index 6bc3992..c567fa9 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs @@ -11,7 +11,7 @@ internal sealed class ParameterGenerator( IOpenApiParameter parameter, HttpRequestExtensionsGenerator httpRequestExtensionsGenerator) { - private string FullyQualifiedTypeName => + internal string FullyQualifiedTypeName => $"{FullyQualifiedTypeDeclarationIdentifier}{(parameter.Required ? "" : "?")}"; private string FullyQualifiedTypeDeclarationIdentifier => typeDeclaration.FullyQualifiedDotnetTypeName(); @@ -34,4 +34,8 @@ internal string GenerateRequestBindingDirective(string requestVariableName) => FullyQualifiedTypeDeclarationIdentifier, parameter) .Indent(4).TrimStart()}{(IsParameterRequired ? "" : ".AsOptional()")},"; + + internal bool IsSecuritySchemeParameter(IOpenApiSecurityScheme scheme) => + scheme.In == parameter.In && + scheme.Name == parameter.Name; } \ No newline at end of file diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs index c0b8a26..0bf5a30 100644 --- a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs @@ -14,19 +14,10 @@ public class ApiKeyAuthenticationHandler( { protected override Task HandleAuthenticateAsync() { - var authenticated = Options.In switch + var apiKey = Options.GetApiKey(); + if (apiKey != "password1") { - "header" => Request.Headers.TryGetValue(Options.Name, out var apiKey) && - !string.IsNullOrEmpty(apiKey), - "cookie" => Request.Cookies.TryGetValue(Options.Name, out var apiKey) && - !string.IsNullOrEmpty(apiKey), - "query" => Request.Query.TryGetValue(Options.Name, out var apiKey) && - !string.IsNullOrEmpty(apiKey), - _ => throw new InvalidOperationException($"Unknown location {Options.In}") - }; - if (!authenticated) - { - return Task.FromResult(AuthenticateResult.NoResult()); + return Task.FromResult(AuthenticateResult.Fail("Invalid api key")); } var identity = new ClaimsIdentity(Scheme.Name); diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs index 365e812..5b0fe2a 100644 --- a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; namespace Example.OpenApi.Auth; public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { - public string In { get; set; } = "Header"; - public string Name { get; set; } = "X-Api-Key"; + public Func GetApiKey { get; set; } = _ => throw new InvalidOperationException("Missing api key handler"); } \ No newline at end of file diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 2d4dfd6..76ed230 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -19,8 +19,10 @@ SecuritySchemes.SecretKeyKey, options => { - options.In = SecuritySchemes.SecretKey.In; - options.Name = SecuritySchemes.SecretKey.Name; + options.GetApiKey = context => + SecuritySchemes.SecretKey.TryGetParameter(context, out var value) + ? value.GetString()! + : throw new InvalidOperationException(""); }); builder.AddOperations(builder.Configuration.Get()); diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index a49dac3..73e331f 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -163,7 +163,7 @@ "secret_key": { "type": "apiKey", "in": "header", - "name": "SecretKey" + "name": "Bar" } } } From 6978037516651c37ebe4af6abb8af7c059224753 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 17:59:13 +0100 Subject: [PATCH 25/39] handle operations that have not defined security scheme parameters --- .../CodeGeneration/AuthGenerator.cs | 60 +++++++++++-------- .../Auth/ApiKeyAuthenticationHandler.cs | 2 +- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 6a82d88..a871ec6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using Microsoft.OpenApi; @@ -78,7 +77,10 @@ private string GenerateGetParameterMethods(string schemeName, IOpenApiSecuritySc { return string.Empty; } - return + + if (_securitySchemeParameters.TryGetValue(schemeName, out var securitySchemeParameters)) + { + return $$""" private static bool TryGet(HttpContext context, out T value) where T : struct { @@ -93,26 +95,34 @@ private static bool TryGet(HttpContext context, out T value) where T : struct private static bool TryGet(HttpContext context, out T? value) where T : struct { - var itemValue = context.Items["{{GetSecuritySchemeParameterKey(_securitySchemeParameters[schemeName].First())}}"]; - - switch (itemValue) + if (context.Items.TryGetValue("{{GetSecuritySchemeParameterKey(securitySchemeParameters.First())}}", out var itemValue)) { - case T typedValue: - value = typedValue; - return true; - case null: - value = null; - return true; + switch (itemValue) + { + case T typedValue: + value = typedValue; + return true; + case null: + value = null; + return true; + } } - + value = null; return false; } -{{_securitySchemeParameters[schemeName].AggregateToString(generator => +{{securitySchemeParameters.AggregateToString(generator => $""" internal static bool TryGetParameter(HttpContext context, out {generator.FullyQualifiedTypeName} value) => TryGet(context, out value); """)}} +"""; + } + + return +$""" +{GenerateConst(nameof(scheme.Name), scheme.Name)} +{GenerateConst(nameof(scheme.In), scheme.In.GetDisplayName())} """; } @@ -289,20 +299,19 @@ internal sealed class {{securityRequirementsFilterClassName}} : IEndpointFilter requirement.Where(pair => pair.Key.In != null && pair.Key.Name != null) .Select(pair => pair.Key)) .Distinct() - .ToDictionary(reference => reference, - reference => - parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)) ?? - throw new InvalidOperationException( - $"Operation {operation.OperationId} defines security scheme {GetSecuritySchemeName(reference)} that references parameter {reference.Name} in location {reference.In} which is not defined by the operation")) + .Select(reference => (Scheme: reference, + Parameter: parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)))) + .Where(pair => pair.Parameter != null) + .ToArray() ?? []; - foreach (var securitySchemeParameter in securitySchemeParameters) + foreach (var (scheme, parameter) in securitySchemeParameters) { - _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(securitySchemeParameter.Key), - _ => [securitySchemeParameter.Value], + _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(scheme), + _ => [parameter], (_, list) => { - list.Add(securitySchemeParameter.Value); + list.Add(parameter); return list; }); } @@ -320,7 +329,10 @@ internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilt { var httpContext = context.HttpContext; var request = (Request) httpContext.Items[RequestItemKey]!; -{{securitySchemeParameters.Values.Distinct().AggregateToString(parameterGenerator => +{{securitySchemeParameters + .Select(tuple => tuple.Parameter!) + .Distinct() + .AggregateToString(parameterGenerator => $""" httpContext.Items.Add("{GetSecuritySchemeParameterKey(parameterGenerator)}", request.{parameterGenerator.Location.ToPascalCase()}.{parameterGenerator.PropertyName}); """)}} diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs index 0bf5a30..f6c3637 100644 --- a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs @@ -14,7 +14,7 @@ public class ApiKeyAuthenticationHandler( { protected override Task HandleAuthenticateAsync() { - var apiKey = Options.GetApiKey(); + var apiKey = Options.GetApiKey(Context); if (apiKey != "password1") { return Task.FromResult(AuthenticateResult.Fail("Invalid api key")); From b585c93d4a95ceb1c63c459ab087cd0fd7980de6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 18:12:56 +0100 Subject: [PATCH 26/39] extract finding security scheme parameters to separate method --- .../CodeGeneration/AuthGenerator.cs | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index a871ec6..47f1093 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -293,30 +293,7 @@ internal sealed class {{securityRequirementsFilterClassName}} : IEndpointFilter """; } - var securitySchemeParameters = - operation.Security? - .SelectMany(requirement => - requirement.Where(pair => pair.Key.In != null && pair.Key.Name != null) - .Select(pair => pair.Key)) - .Distinct() - .Select(reference => (Scheme: reference, - Parameter: parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)))) - .Where(pair => pair.Parameter != null) - .ToArray() - ?? []; - - foreach (var (scheme, parameter) in securitySchemeParameters) - { - _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(scheme), - _ => [parameter], - (_, list) => - { - list.Add(parameter); - return list; - }); - } - - var hasSecuritySchemeParameters = securitySchemeParameters.Any(); + var hasSecuritySchemeParameters = TryGetSecuritySchemeParameters(operation, parameters, out var securitySchemeParameters); _requestFilters.Add(operation, hasSecuritySchemeParameters ? [securitySchemeParameterFilterClassName, securityRequirementsFilterClassName] @@ -330,7 +307,7 @@ internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilt var httpContext = context.HttpContext; var request = (Request) httpContext.Items[RequestItemKey]!; {{securitySchemeParameters - .Select(tuple => tuple.Parameter!) + .Select(tuple => tuple.ParameterGenerator) .Distinct() .AggregateToString(parameterGenerator => $""" @@ -424,4 +401,33 @@ private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) private static string GetSecuritySchemeParameterKey(ParameterGenerator generator) => $"OpenAPI.WebApiGenerator.SecurityScheme.{generator.Location}.{generator.PropertyName}"; + + private bool TryGetSecuritySchemeParameters(OpenApiOperation operation, ParameterGenerator[] parameters, + out (OpenApiSecuritySchemeReference Scheme, ParameterGenerator ParameterGenerator)[] securitySchemeParameters) + { + securitySchemeParameters = + operation.Security? + .SelectMany(requirement => + requirement.Where(pair => pair.Key.In != null && pair.Key.Name != null) + .Select(pair => pair.Key)) + .Distinct() + .Select(reference => (Scheme: reference, + Parameter: parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)))) + .Where(pair => pair.Parameter != null) + .ToArray() + ?? []; + + foreach (var (scheme, parameter) in securitySchemeParameters) + { + _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(scheme), + _ => [parameter], + (_, list) => + { + list.Add(parameter); + return list; + }); + } + + return securitySchemeParameters.Any(); + } } From 849478a4d395e359f918e97e3abd19bd0805e283 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 19:18:03 +0100 Subject: [PATCH 27/39] generate GetParameter for the auth scheme if there is exactly one parameter scheme defined by all operations that reference the scheme, otherwise fall back on TryGetParameter should there be multiple schemes and/or generate parameter name and location if any operation is missing the parameter specification --- .../CodeGeneration/AuthGenerator.cs | 76 +++++++++++++------ tests/Example.OpenApi31/Program.cs | 4 +- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 47f1093..42d88a0 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -1,6 +1,8 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Reflection.Metadata; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -11,7 +13,7 @@ internal sealed class AuthGenerator private readonly IDictionary _securitySchemes; private readonly Dictionary>[] _topLevelSecuritySchemeGroups; - private readonly ConcurrentDictionary> _securitySchemeParameters = new(); + private readonly ConcurrentDictionary> _securitySchemeParameters = new(); private readonly Dictionary _requestFilters = new(); @@ -77,11 +79,39 @@ private string GenerateGetParameterMethods(string schemeName, IOpenApiSecuritySc { return string.Empty; } - + + var hasNonDefinedParameters = true; + var parameterGenerators = Array.Empty(); + var parameterFullyQualifiedTypeNames = Array.Empty(); if (_securitySchemeParameters.TryGetValue(schemeName, out var securitySchemeParameters)) { - return + hasNonDefinedParameters = securitySchemeParameters.Any(tuple => tuple.Parameter == null); + parameterGenerators = securitySchemeParameters + .Where(tuple => tuple.Parameter != null) + .Select(tuple => tuple.Parameter!) + .ToArray(); + parameterFullyQualifiedTypeNames = parameterGenerators.Select(generator => generator.FullyQualifiedTypeName) + .Distinct() + .ToArray(); + } + + var securitySchemeParameterKey = parameterGenerators.Any() + ? GetSecuritySchemeParameterKey(parameterGenerators.First()) + : null; + + return +$$""" +{{(hasNonDefinedParameters ? +$""" +{GenerateConst(nameof(scheme.Name), scheme.Name)} +{GenerateConst(nameof(scheme.In), scheme.In.GetDisplayName())} + +""" : "")}}{{(parameterFullyQualifiedTypeNames.Length == 1 ? $$""" +internal static {{parameterFullyQualifiedTypeNames.First()}} GetParameter(HttpContext context) => + ({{parameterFullyQualifiedTypeNames.First()}})context.Items["{{securitySchemeParameterKey}}"]; + +""" : $$""" private static bool TryGet(HttpContext context, out T value) where T : struct { if (TryGet(context, out T? nullableValue)) @@ -95,7 +125,7 @@ private static bool TryGet(HttpContext context, out T value) where T : struct private static bool TryGet(HttpContext context, out T? value) where T : struct { - if (context.Items.TryGetValue("{{GetSecuritySchemeParameterKey(securitySchemeParameters.First())}}", out var itemValue)) + if (context.Items.TryGetValue("{{securitySchemeParameterKey}}", out var itemValue)) { switch (itemValue) { @@ -111,18 +141,12 @@ private static bool TryGet(HttpContext context, out T? value) where T : struc value = null; return false; } -{{securitySchemeParameters.AggregateToString(generator => +{{parameterFullyQualifiedTypeNames.AggregateToString(fullyQualifiedTypeName => $""" -internal static bool TryGetParameter(HttpContext context, out {generator.FullyQualifiedTypeName} value) => +internal static bool TryGetParameter(HttpContext context, out {fullyQualifiedTypeName} value) => TryGet(context, out value); """)}} -"""; - } - - return -$""" -{GenerateConst(nameof(scheme.Name), scheme.Name)} -{GenerateConst(nameof(scheme.In), scheme.In.GetDisplayName())} +""")}} """; } @@ -293,7 +317,8 @@ internal sealed class {{securityRequirementsFilterClassName}} : IEndpointFilter """; } - var hasSecuritySchemeParameters = TryGetSecuritySchemeParameters(operation, parameters, out var securitySchemeParameters); + var securitySchemeParameters = GetSecuritySchemeParameters(operation, parameters); + var hasSecuritySchemeParameters = securitySchemeParameters.Any(); _requestFilters.Add(operation, hasSecuritySchemeParameters ? [securitySchemeParameterFilterClassName, securityRequirementsFilterClassName] @@ -307,7 +332,8 @@ internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilt var httpContext = context.HttpContext; var request = (Request) httpContext.Items[RequestItemKey]!; {{securitySchemeParameters - .Select(tuple => tuple.ParameterGenerator) + .Where(tuple => tuple.Value != null) + .Select(tuple => tuple.Value!) .Distinct() .AggregateToString(parameterGenerator => $""" @@ -402,32 +428,32 @@ private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) private static string GetSecuritySchemeParameterKey(ParameterGenerator generator) => $"OpenAPI.WebApiGenerator.SecurityScheme.{generator.Location}.{generator.PropertyName}"; - private bool TryGetSecuritySchemeParameters(OpenApiOperation operation, ParameterGenerator[] parameters, - out (OpenApiSecuritySchemeReference Scheme, ParameterGenerator ParameterGenerator)[] securitySchemeParameters) + private Dictionary GetSecuritySchemeParameters(OpenApiOperation operation, ParameterGenerator[] parameters) { - securitySchemeParameters = + var nullableSecuritySchemeParameters = operation.Security? .SelectMany(requirement => requirement.Where(pair => pair.Key.In != null && pair.Key.Name != null) .Select(pair => pair.Key)) .Distinct() .Select(reference => (Scheme: reference, - Parameter: parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)))) - .Where(pair => pair.Parameter != null) + Parameter: parameters.FirstOrDefault(generator => generator.IsSecuritySchemeParameter(reference)) ?? null)) .ToArray() ?? []; - foreach (var (scheme, parameter) in securitySchemeParameters) + foreach (var (scheme, parameter) in nullableSecuritySchemeParameters) { _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(scheme), - _ => [parameter], + _ => [(operation, parameter)], (_, list) => { - list.Add(parameter); + list.Add((operation, parameter)); return list; }); } - return securitySchemeParameters.Any(); + return nullableSecuritySchemeParameters + .Where(pair => pair.Parameter != null) + .ToDictionary(pair => pair.Scheme, pair => pair.Parameter!); } } diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 76ed230..ff2ebe6 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -20,9 +20,7 @@ options => { options.GetApiKey = context => - SecuritySchemes.SecretKey.TryGetParameter(context, out var value) - ? value.GetString()! - : throw new InvalidOperationException(""); + SecuritySchemes.SecretKey.GetParameter(context).GetString()!; }); builder.AddOperations(builder.Configuration.Get()); From abe37f5e58f20c945a942f83e8efc642c99d5ce5 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 19:43:42 +0100 Subject: [PATCH 28/39] remove duplicated semicolon --- src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 42d88a0..1515746 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -310,7 +310,7 @@ internal sealed class {{securityRequirementsFilterClassName}} : IEndpointFilter public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // Anonymous - context.HttpContext.User ??= new(new ClaimsIdentity());; + context.HttpContext.User ??= new(new ClaimsIdentity()); return next(context); } } From 9b4be1cb03d11fd8335eb7f77bc1896601d69774 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 23:20:56 +0100 Subject: [PATCH 29/39] test(auth): add api key validation --- .../Auth/ApiKeyAuthenticationHandler.cs | 11 ++++++++--- .../Auth/ApiKeyAuthenticationOptions.cs | 2 +- tests/Example.OpenApi31/Program.cs | 8 +++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs index f6c3637..91a6782 100644 --- a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs @@ -14,10 +14,15 @@ public class ApiKeyAuthenticationHandler( { protected override Task HandleAuthenticateAsync() { - var apiKey = Options.GetApiKey(Context); - if (apiKey != "password1") + var (isValid, value) = Options.GetApiKey(Context); + if (!isValid) { - return Task.FromResult(AuthenticateResult.Fail("Invalid api key")); + return Task.FromResult(AuthenticateResult.Fail("invalid api key format")); + } + + if (value != "password1") + { + return Task.FromResult(AuthenticateResult.Fail("incorrect api key")); } var identity = new ClaimsIdentity(Scheme.Name); diff --git a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs index 5b0fe2a..f93c20d 100644 --- a/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs @@ -5,5 +5,5 @@ namespace Example.OpenApi.Auth; public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { - public Func GetApiKey { get; set; } = _ => throw new InvalidOperationException("Missing api key handler"); + public Func GetApiKey { get; set; } = _ => throw new InvalidOperationException("Missing api key handler"); } \ No newline at end of file diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index ff2ebe6..0709157 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -1,3 +1,4 @@ +using Corvus.Json; using Example.OpenApi.Auth; using Example.OpenApi31; using Microsoft.IdentityModel.Tokens; @@ -20,7 +21,12 @@ options => { options.GetApiKey = context => - SecuritySchemes.SecretKey.GetParameter(context).GetString()!; + { + var parameter = SecuritySchemes.SecretKey.GetParameter(context); + return parameter.Validate(ValidationContext.ValidContext).IsValid + ? (true, parameter.GetString()!) + : (false, null); + }; }); builder.AddOperations(builder.Configuration.Get()); From 3a5a1ab4e8d602f3ab68b42ebc4227deca7bcf47 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 23:21:39 +0100 Subject: [PATCH 30/39] doc(auth): add code comments about how to consume api key parameters --- .../CodeGeneration/AuthGenerator.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 1515746..1fb7034 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -108,6 +108,12 @@ private string GenerateGetParameterMethods(string schemeName, IOpenApiSecuritySc """ : "")}}{{(parameterFullyQualifiedTypeNames.Length == 1 ? $$""" +/// +/// Get the security scheme parameter for the current operation. +/// Make sure to validate the parameter before using it. +/// +/// Http context +/// The security scheme parameter internal static {{parameterFullyQualifiedTypeNames.First()}} GetParameter(HttpContext context) => ({{parameterFullyQualifiedTypeNames.First()}})context.Items["{{securitySchemeParameterKey}}"]; @@ -143,6 +149,13 @@ private static bool TryGet(HttpContext context, out T? value) where T : struc } {{parameterFullyQualifiedTypeNames.AggregateToString(fullyQualifiedTypeName => $""" +/// +/// Get the security scheme parameter for the current operation if it has defined a parameter specification corresponding to the type. +/// Make sure to validate the parameter before using it. +/// +/// Http context +/// The security scheme parameter +/// true if the typed security scheme parameter is found internal static bool TryGetParameter(HttpContext context, out {fullyQualifiedTypeName} value) => TryGet(context, out value); """)}} From a31b0ff50f9f5bda1394985f73324ef7558046eb Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 23:38:22 +0100 Subject: [PATCH 31/39] adjust code formatting --- src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs | 4 +++- .../CodeGeneration/OperationGenerator.cs | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 1fb7034..9c18f78 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -355,12 +355,14 @@ internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilt return next(context); } } + """ : string.Empty) + $$""" + internal sealed class {{securityRequirementsFilterClassName}}(Operation operation, WebApiConfiguration configuration) : BaseSecurityRequirementsFilter(configuration) { protected override SecurityRequirements Requirements { get; } = new() - {{{string.Join(", ", + {{{string.Join(",", securityRequirementGroups.Select(securityRequirementGroup => securityRequirementGroup.AggregateToString(securityRequirement => $$""" diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 2fa1bb2..242ea59 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -106,11 +106,7 @@ internal static async Task HandleAsync( [FromServices] WebApiConfiguration configuration, CancellationToken cancellationToken) { - if (!context.Items.TryGetValue(RequestItemKey, out var requestObject)) - { - throw new InvalidOperationException($"{RequestItemKey} is missing in request items"); - } - var request = requestObject as Request ?? throw new InvalidOperationException("Request object is not the Request type"); + var request = (Request) context.Items[RequestItemKey]!; var validationContext = request.Validate(operation.ValidationLevel); if (!validationContext.IsValid) From 52f580d0137a985299f3bfab0d90af17983f6e07 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 4 Feb 2026 23:54:16 +0100 Subject: [PATCH 32/39] doc(auth): add section about configuring authentication and authorization --- README.md | 6 ++++++ src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fe311f3..eb01b05 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ These handlers will not be generated in subsequent compilations as the generator ``` + +## Authentication and Authorization +OpenAPI defines [security scheme objects](https://spec.openapis.org/oas/latest#security-scheme-object) for authentication and authorization mechanisms. The generator implement endpoint filters that corresponds to the security declaration of each operation. Do _not_ call `UseAuthentication` or similar when configuring the application. + +The security schemes for the [security requirements](https://spec.openapis.org/oas/latest#security-requirement-object) declared by the operations must be implemented. Use the familiar `AddAuthentication` builder method to register each scheme. Security scheme object configurations are generated to the `SecuritySchemes` class and can be used to configure the scheme implementations. + ## Dependency Injection Operations are registered as scoped dependencies. Any dependencies can be injected into them as usual via the app builder's `IServiceCollection`. diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 9c18f78..0090bfc 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -345,8 +345,7 @@ internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilt var httpContext = context.HttpContext; var request = (Request) httpContext.Items[RequestItemKey]!; {{securitySchemeParameters - .Where(tuple => tuple.Value != null) - .Select(tuple => tuple.Value!) + .Select(tuple => tuple.Value) .Distinct() .AggregateToString(parameterGenerator => $""" @@ -441,7 +440,7 @@ private string GetSecuritySchemeName(OpenApiSecuritySchemeReference reference) => _securitySchemes.First(pair => pair.Value == reference.Target).Key; private static string GetSecuritySchemeParameterKey(ParameterGenerator generator) => - $"OpenAPI.WebApiGenerator.SecurityScheme.{generator.Location}.{generator.PropertyName}"; + $"OpenAPI.WebApiGenerator.SecurityScheme.{generator.Location.ToPascalCase()}.{generator.PropertyName}"; private Dictionary GetSecuritySchemeParameters(OpenApiOperation operation, ParameterGenerator[] parameters) { From de9a33bad9e2ea1ee0bad23044081b20679a5356 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 16:18:47 +0100 Subject: [PATCH 33/39] format empty lines --- .../CodeGeneration/OperationRouterGenerator.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index dc8a85e..bbdc860 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -21,12 +21,14 @@ internal static WebApplication MapOperations(this WebApplication app) {{{operations.AggregateToString(operation => $""" app.MapMethods({operation.Namespace}.Operation.PathTemplate, ["{operation.Operation.Key.Method}"], {operation.Namespace}.Operation.HandleAsync) - .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>() -{authGenerator.GetSecurityFilterNames(operation.Operation.Value).AggregateToString(name => + .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>(){ + authGenerator.GetSecurityFilterNames(operation.Operation.Value).AggregateToString(name => $""" .AddEndpointFilter<{operation.Namespace}.Operation.{name}>() """)}; + """)}} + return app; } From 7ed503b05e75b16d0ccaa54ddb7da83d214411aa Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 17:24:55 +0100 Subject: [PATCH 34/39] test: add all variants of security scheme objects --- .../OpenAPI.WebApiGenerator.csproj | 4 +- .../Auth/BasicAuthenticationHandler.cs | 65 +++++++++++++++++++ .../Example.OpenApi31.csproj | 1 + tests/Example.OpenApi31/Program.cs | 19 +++++- tests/Example.OpenApi31/openapi.json | 18 ++++- .../OpenApiSpecs/openapi-v2.json | 4 ++ .../OpenApiSpecs/openapi-v3.1.json | 14 ++++ .../OpenApiSpecs/openapi-v3.2.json | 14 ++++ .../OpenApiSpecs/openapi-v3.json | 10 +++ 9 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 tests/Example.OpenApi/Auth/BasicAuthenticationHandler.cs diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index 8d71b1f..e558c25 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -59,8 +59,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers diff --git a/tests/Example.OpenApi/Auth/BasicAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..bbbb00c --- /dev/null +++ b/tests/Example.OpenApi/Auth/BasicAuthenticationHandler.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Example.OpenApi.Auth; + +public class BasicAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (!AuthenticationHeaderValue.TryParse(authHeader, out var headerValue) || + !string.Equals(headerValue.Scheme, "Basic", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (string.IsNullOrEmpty(headerValue.Parameter)) + { + return Task.FromResult(AuthenticateResult.Fail("Missing credentials")); + } + + try + { + var credentialBytes = Convert.FromBase64String(headerValue.Parameter); + var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':', 2); + + if (credentials.Length != 2) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid credentials format")); + } + + var username = credentials[0]; + var password = credentials[1]; + + if (username != "admin" || password != "password") + { + return Task.FromResult(AuthenticateResult.Fail("Invalid username or password")); + } + + var claims = new[] { new Claim(ClaimTypes.Name, username) }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); + } + catch (FormatException) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Base64 encoding")); + } + } +} + +public class BasicAuthenticationOptions : AuthenticationSchemeOptions; \ No newline at end of file diff --git a/tests/Example.OpenApi31/Example.OpenApi31.csproj b/tests/Example.OpenApi31/Example.OpenApi31.csproj index 926d379..76ae79d 100644 --- a/tests/Example.OpenApi31/Example.OpenApi31.csproj +++ b/tests/Example.OpenApi31/Example.OpenApi31.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 0709157..6b40083 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -1,6 +1,7 @@ using Corvus.Json; using Example.OpenApi.Auth; using Example.OpenApi31; +using Microsoft.AspNetCore.Authentication.Certificate; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -17,7 +18,7 @@ }; }) .AddScheme( - SecuritySchemes.SecretKeyKey, + SecuritySchemes.SecretKeyKey, options => { options.GetApiKey = context => @@ -27,7 +28,21 @@ ? (true, parameter.GetString()!) : (false, null); }; - }); + }) + .AddScheme( + SecuritySchemes.BasicAuthKey, + _ => { }) + .AddCertificate(SecuritySchemes.MutualTLSKey, options => + { + options.AllowedCertificateTypes = CertificateTypes.All; + }) + .AddCookie() + .AddOpenIdConnect(SecuritySchemes.OpenIdConnectKey, options => + { + options.Authority = SecuritySchemes.OpenIdConnect.OpenIdConnectUrl; + options.ClientId = "example-client"; + options.SignInScheme = "Cookies"; + }); builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index 73e331f..bc08d01 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -70,7 +70,12 @@ "200": { "description": "Successfully deleted" } - } + }, + "security": [ + {"basicAuth": []}, + {"mutualTLS": []}, + {"openIdConnect": []} + ] }, "parameters": [ { @@ -164,6 +169,17 @@ "type": "apiKey", "in": "header", "name": "Bar" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" } } } diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json index 88e965a..1c62a29 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json @@ -497,6 +497,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "parameters": [ { "name": "body", @@ -784,6 +785,9 @@ } }, "securityDefinitions": { + "basicAuth": { + "type": "basic" + }, "bearerAuth": { "type": "apiKey", "name": "Authorization", diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json index 157f302..4c06ea7 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json @@ -196,6 +196,7 @@ "operationId": "uploadPetImage", "summary": "Upload pet image", "tags": ["pets"], + "security": [{"mutualTLS": []}], "parameters": [ { "name": "petId", @@ -355,6 +356,7 @@ "operationId": "getUser", "summary": "Get user by username", "tags": ["users"], + "security": [{"openIdConnect": ["profile", "email"]}], "responses": { "200": {"$ref": "#/components/responses/UserResponse"}, "404": {"$ref": "#/components/responses/NotFound"} @@ -401,6 +403,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -850,6 +853,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -860,6 +867,13 @@ "in": "header", "name": "X-Api-Key" }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, "oauth2": { "type": "oauth2", "flows": { diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json index 5e89455..a0f8b7c 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json @@ -201,6 +201,7 @@ "operationId": "uploadPetImage", "summary": "Upload pet image", "tags": ["pets"], + "security": [{"mutualTLS": []}], "parameters": [ { "name": "petId", @@ -365,6 +366,7 @@ "operationId": "getUser", "summary": "Get user by username", "tags": ["users"], + "security": [{"openIdConnect": ["profile", "email"]}], "responses": { "200": {"$ref": "#/components/responses/UserResponse"}, "404": {"$ref": "#/components/responses/NotFound"} @@ -411,6 +413,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -860,6 +863,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -870,6 +877,13 @@ "in": "header", "name": "X-Api-Key" }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, "oauth2": { "type": "oauth2", "flows": { diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json index 2eb563a..d266043 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json @@ -506,6 +506,7 @@ "operationId": "getUser", "summary": "Get user by username", "tags": ["users"], + "security": [{"openIdConnect": ["profile", "email"]}], "parameters": [ { "name": "username", @@ -597,6 +598,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -893,6 +895,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -903,6 +909,10 @@ "in": "header", "name": "X-Api-Key" }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, "oauth2": { "type": "oauth2", "flows": { From a7ced34fd20a12478a43868e6b2d8cdd58e0a228 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 18:12:32 +0100 Subject: [PATCH 35/39] fix(auth): authentication should fail if no auth schemes succeeds with authenticating --- .../Auth/HttpClientAuthExtensions.cs | 7 ++++++ .../CodeGeneration/AuthGenerator.cs | 4 ++-- .../DeleteFooTests.cs | 3 ++- .../UpdateFooTests.cs | 22 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs index 4b53249..cf89594 100644 --- a/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs +++ b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs @@ -10,4 +10,11 @@ public static HttpClient WithOAuth2ImplicitFlowAuthentication(this HttpClient cl AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.Jwt}"); return client; } + + public static HttpClient WithValidBasicAuthCredentials(this HttpClient client) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String("admin:password"u8.ToArray())); + return client; + } } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 0090bfc..0c9c170 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -232,8 +232,8 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi var principal = httpContext.User ??= new(); - var passed = true; - var passedAuthentication = true; + var passed = Requirements.Count == 0; + var passedAuthentication = passed; // Only one of the security requirement objects need to be satisfied to authorize a request. foreach (var securityRequirement in Requirements) { diff --git a/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs index 50a1773..766ba0d 100644 --- a/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs +++ b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs @@ -1,5 +1,6 @@ using System.Net; using AwesomeAssertions; +using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi31.IntegrationTests; @@ -8,7 +9,7 @@ public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Deleting_Foo_It_Should_Return_Ok() { - using var client = app.CreateClient(); + using var client = app.CreateClient().WithValidBasicAuthCredentials(); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), diff --git a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs index 5b2faa6..b732f37 100644 --- a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs @@ -67,4 +67,26 @@ public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); } + + [Fact] + public async Task Given_unauthenticated_request_When_Updating_Foo_It_Should_Return_401() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } } \ No newline at end of file From bda336ee86acaadbf61a66b2cbcb190648fe7e89 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 18:14:57 +0100 Subject: [PATCH 36/39] test: let delete operation be anonymous --- .../DeleteFooTests.cs | 3 +-- tests/Example.OpenApi31/openapi.json | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs index 766ba0d..50a1773 100644 --- a/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs +++ b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs @@ -1,6 +1,5 @@ using System.Net; using AwesomeAssertions; -using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi31.IntegrationTests; @@ -9,7 +8,7 @@ public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Deleting_Foo_It_Should_Return_Ok() { - using var client = app.CreateClient().WithValidBasicAuthCredentials(); + using var client = app.CreateClient(); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index bc08d01..ca5f88e 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -61,7 +61,10 @@ }, "security": [ {"petstore_auth": []}, - {"secret_key": []} + {"secret_key": []}, + {"basicAuth": []}, + {"mutualTLS": []}, + {"openIdConnect": []} ] }, "delete": { @@ -70,12 +73,7 @@ "200": { "description": "Successfully deleted" } - }, - "security": [ - {"basicAuth": []}, - {"mutualTLS": []}, - {"openIdConnect": []} - ] + } }, "parameters": [ { From 49e06adc518378f476d5799010805656c12ee7d2 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 18:28:06 +0100 Subject: [PATCH 37/39] test(auth): assert 403 response when not authorized --- .../Auth/HttpClientAuthExtensions.cs | 4 +-- .../Auth/OIDCAuthHttpHandler.cs | 15 ++++++----- .../UpdateFooTests.cs | 26 +++++++++++++++++-- tests/Example.OpenApi31/openapi.json | 2 +- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs index cf89594..8623d7f 100644 --- a/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs +++ b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs @@ -4,10 +4,10 @@ namespace OpenAPI.IntegrationTestHelpers.Auth; public static class HttpClientAuthExtensions { - public static HttpClient WithOAuth2ImplicitFlowAuthentication(this HttpClient client) + public static HttpClient WithOAuth2ImplicitFlowAuthentication(this HttpClient client, params string[] scopes) { client.DefaultRequestHeaders.Authorization = - AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.Jwt}"); + AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.GetJwt(scopes)}"); return client; } diff --git a/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs index 7c9dd83..aed9d7c 100644 --- a/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs +++ b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs @@ -8,6 +8,7 @@ namespace OpenAPI.IntegrationTestHelpers.Auth; public sealed class OIDCAuthHttpHandler : HttpMessageHandler { + private static readonly SigningCredentials _privateKey; private const string Kid = "test"; private static readonly RSAParameters PrivateRsaParameters; private static string OidcConfigurationContent { get; } @@ -26,14 +27,13 @@ static OIDCAuthHttpHandler() KeyId = Base64UrlEncoder.Encode(Kid) }; - var privateKey = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature); + _privateKey = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature); - Jwt = GenerateJwtToken(privateKey); OidcConfigurationContent = CreateOidcConfigurationContent(); JwksContent = CreateJwksContent(); } - public static readonly string Jwt; + public static string GetJwt(params string[] scopes) => GenerateJwtToken(scopes); internal const string Issuer = "https://localhost/"; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -58,7 +58,7 @@ protected override Task SendAsync(HttpRequestMessage reques }; } - private static string GenerateJwtToken(SigningCredentials privateKey) + private static string GenerateJwtToken(params string[] scopes) { var securityTokenDescriptor = new SecurityTokenDescriptor { @@ -67,8 +67,11 @@ private static string GenerateJwtToken(SigningCredentials privateKey) Subject = new ClaimsIdentity(), Expires = DateTime.UtcNow.AddHours(1), IssuedAt = DateTime.UtcNow, - SigningCredentials = privateKey, - Claims = new Dictionary() { { "scope", "short" } } + SigningCredentials = _privateKey, + Claims = new Dictionary + { + ["scope"] = string.Join(" ", scopes) + } }; var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); diff --git a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs index b732f37..67a068c 100644 --- a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs @@ -13,7 +13,7 @@ public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, I public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() { using var client = app.CreateClient() - .WithOAuth2ImplicitFlowAuthentication(); + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), @@ -44,7 +44,7 @@ public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() { using var client = app.CreateClient() - .WithOAuth2ImplicitFlowAuthentication(); + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/test"), @@ -89,4 +89,26 @@ public async Task Given_unauthenticated_request_When_Updating_Foo_It_Should_Retu }, CancellationToken); result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + + [Fact] + public async Task Given_unauthorized_request_When_Updating_Foo_It_Should_Return_403() + { + using var client = app.CreateClient().WithOAuth2ImplicitFlowAuthentication(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } \ No newline at end of file diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index ca5f88e..f9f1100 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -60,7 +60,7 @@ } }, "security": [ - {"petstore_auth": []}, + {"petstore_auth": ["update"]}, {"secret_key": []}, {"basicAuth": []}, {"mutualTLS": []}, From 2f4cfddd7f10e5d0c0f7d2d4c65518456240763f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 18:52:48 +0100 Subject: [PATCH 38/39] test(auth): include all security schemes and run 401 and 403 scenarios --- .../Example.OpenApi20.IntegrationTests.csproj | 1 + .../FooApplicationFactory.cs | 19 ++++++- .../UpdateFooTests.cs | 51 ++++++++++++++++++- .../Example.OpenApi20.csproj | 2 + tests/Example.OpenApi20/Program.cs | 33 +++++++++++- tests/Example.OpenApi20/openapi.json | 26 +++++++++- .../Example.OpenApi30.IntegrationTests.csproj | 1 + .../FooApplicationFactory.cs | 17 ++++++- .../UpdateFooTests.cs | 51 ++++++++++++++++++- .../Example.OpenApi30.csproj | 3 ++ tests/Example.OpenApi30/Program.cs | 40 ++++++++++++++- tests/Example.OpenApi30/openapi.json | 35 ++++++++++++- .../Example.OpenApi32.IntegrationTests.csproj | 1 + .../FooApplicationFactory.cs | 17 ++++++- .../UpdateFooTests.cs | 51 ++++++++++++++++++- .../Example.OpenApi32.csproj | 4 ++ tests/Example.OpenApi32/Program.cs | 43 ++++++++++++++++ tests/Example.OpenApi32/openapi.json | 39 +++++++++++++- 18 files changed, 419 insertions(+), 15 deletions(-) diff --git a/tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj b/tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj index 7171e05..9ba43dc 100644 --- a/tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj +++ b/tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs index 29594a4..6cd5eb3 100644 --- a/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs @@ -1,7 +1,22 @@ -using JetBrains.Annotations; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using OpenAPI.IntegrationTestHelpers.Auth; +using OpenAPI.IntegrationTestHelpers.Observability; namespace Example.OpenApi20.IntegrationTests; [UsedImplicitly] -public class FooApplicationFactory : WebApplicationFactory; \ No newline at end of file +public class FooApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.InjectJwtBackChannelHandler(); + }); + + builder.AddLogging(); + } +} diff --git a/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs index ca1d4d7..01e1398 100644 --- a/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs @@ -3,6 +3,7 @@ using AwesomeAssertions; using Example.OpenApi20.IntegrationTests.Http; using Example.OpenApi20.IntegrationTests.Json; +using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi20.IntegrationTests; @@ -11,7 +12,8 @@ public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), @@ -41,7 +43,8 @@ public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() [Fact] public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/test"), @@ -64,4 +67,48 @@ public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/parameters/FooId/type"); } + + [Fact] + public async Task Given_unauthenticated_request_When_Updating_Foo_It_Should_Return_401() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Given_unauthorized_request_When_Updating_Foo_It_Should_Return_403() + { + using var client = app.CreateClient().WithOAuth2ImplicitFlowAuthentication(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } \ No newline at end of file diff --git a/tests/Example.OpenApi20/Example.OpenApi20.csproj b/tests/Example.OpenApi20/Example.OpenApi20.csproj index 0335268..f332436 100644 --- a/tests/Example.OpenApi20/Example.OpenApi20.csproj +++ b/tests/Example.OpenApi20/Example.OpenApi20.csproj @@ -9,10 +9,12 @@ + + diff --git a/tests/Example.OpenApi20/Program.cs b/tests/Example.OpenApi20/Program.cs index a45305d..f33ae56 100644 --- a/tests/Example.OpenApi20/Program.cs +++ b/tests/Example.OpenApi20/Program.cs @@ -1,9 +1,40 @@ +using Corvus.Json; +using Example.OpenApi.Auth; using Example.OpenApi20; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAuthentication() + .AddJwtBearer(SecuritySchemes.PetstoreAuthKey, options => + { + var authority = + new Uri(SecuritySchemes.PetstoreAuth.Flows.Implicit.AuthorizationUrl).GetLeftPart(UriPartial.Authority); + options.Authority = authority; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authority, + ValidAudience = authority, + }; + }) + .AddScheme( + SecuritySchemes.SecretKeyKey, + options => + { + options.GetApiKey = context => + { + var parameter = SecuritySchemes.SecretKey.GetParameter(context); + return parameter.Validate(ValidationContext.ValidContext).IsValid + ? (true, parameter.GetString()!) + : (false, null); + }; + }) + .AddScheme( + SecuritySchemes.BasicAuthKey, + _ => { }); + builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); app.MapOperations(); app.Run(); -public abstract partial class Program; \ No newline at end of file +public abstract partial class Program; diff --git a/tests/Example.OpenApi20/openapi.json b/tests/Example.OpenApi20/openapi.json index 85a773f..1b9aa65 100644 --- a/tests/Example.OpenApi20/openapi.json +++ b/tests/Example.OpenApi20/openapi.json @@ -55,7 +55,12 @@ "400": { "$ref": "#/responses/BadRequest" } - } + }, + "security": [ + {"petstore_auth": ["update"]}, + {"secret_key": []}, + {"basicAuth": []} + ] }, "delete": { "operationId": "Delete_Foo", @@ -126,5 +131,24 @@ } } } + }, + "securityDefinitions": { + "petstore_auth": { + "type": "oauth2", + "flow": "implicit", + "authorizationUrl": "https://localhost/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + }, + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "Bar" + }, + "basicAuth": { + "type": "basic" + } } } diff --git a/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj b/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj index ba609f5..97535f7 100644 --- a/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj +++ b/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs index 7421815..6e63be3 100644 --- a/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs @@ -1,7 +1,22 @@ using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using OpenAPI.IntegrationTestHelpers.Auth; +using OpenAPI.IntegrationTestHelpers.Observability; namespace Example.OpenApi30.IntegrationTests; [UsedImplicitly] -public class FooApplicationFactory : WebApplicationFactory; +public class FooApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.InjectJwtBackChannelHandler(); + }); + + builder.AddLogging(); + } +} diff --git a/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs index 29fed4b..608d1b9 100644 --- a/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs @@ -3,6 +3,7 @@ using AwesomeAssertions; using Example.OpenApi30.IntegrationTests.Http; using Example.OpenApi30.IntegrationTests.Json; +using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi30.IntegrationTests; @@ -11,7 +12,8 @@ public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), @@ -41,7 +43,8 @@ public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() [Fact] public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/test"), @@ -64,4 +67,48 @@ public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); } + + [Fact] + public async Task Given_unauthenticated_request_When_Updating_Foo_It_Should_Return_401() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Given_unauthorized_request_When_Updating_Foo_It_Should_Return_403() + { + using var client = app.CreateClient().WithOAuth2ImplicitFlowAuthentication(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } diff --git a/tests/Example.OpenApi30/Example.OpenApi30.csproj b/tests/Example.OpenApi30/Example.OpenApi30.csproj index e654656..fef665a 100644 --- a/tests/Example.OpenApi30/Example.OpenApi30.csproj +++ b/tests/Example.OpenApi30/Example.OpenApi30.csproj @@ -9,10 +9,13 @@ + + + diff --git a/tests/Example.OpenApi30/Program.cs b/tests/Example.OpenApi30/Program.cs index bd9e298..a54f3e7 100644 --- a/tests/Example.OpenApi30/Program.cs +++ b/tests/Example.OpenApi30/Program.cs @@ -1,9 +1,47 @@ +using Corvus.Json; +using Example.OpenApi.Auth; using Example.OpenApi30; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAuthentication() + .AddJwtBearer(SecuritySchemes.PetstoreAuthKey, options => + { + var authority = + new Uri(SecuritySchemes.PetstoreAuth.Flows.Implicit.AuthorizationUrl).GetLeftPart(UriPartial.Authority); + options.Authority = authority; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authority, + ValidAudience = authority, + }; + }) + .AddScheme( + SecuritySchemes.SecretKeyKey, + options => + { + options.GetApiKey = context => + { + var parameter = SecuritySchemes.SecretKey.GetParameter(context); + return parameter.Validate(ValidationContext.ValidContext).IsValid + ? (true, parameter.GetString()!) + : (false, null); + }; + }) + .AddScheme( + SecuritySchemes.BasicAuthKey, + _ => { }) + .AddCookie() + .AddOpenIdConnect(SecuritySchemes.OpenIdConnectKey, options => + { + options.Authority = SecuritySchemes.OpenIdConnect.OpenIdConnectUrl; + options.ClientId = "example-client"; + options.SignInScheme = "Cookies"; + }); + builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); app.MapOperations(); app.Run(); -public abstract partial class Program; \ No newline at end of file +public abstract partial class Program; diff --git a/tests/Example.OpenApi30/openapi.json b/tests/Example.OpenApi30/openapi.json index 48611c6..6988941 100644 --- a/tests/Example.OpenApi30/openapi.json +++ b/tests/Example.OpenApi30/openapi.json @@ -58,7 +58,13 @@ "400": { "$ref": "#/components/responses/BadRequest" } - } + }, + "security": [ + {"petstore_auth": ["update"]}, + {"secret_key": []}, + {"basicAuth": []}, + {"openIdConnect": []} + ] }, "delete": { "operationId": "Delete_Foo", @@ -142,6 +148,33 @@ } } } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://localhost/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "Bar" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + } } } } \ No newline at end of file diff --git a/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj b/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj index 2fe1e62..87b67fe 100644 --- a/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj +++ b/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs index 383b35b..eb36a1d 100644 --- a/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs @@ -1,7 +1,22 @@ using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using OpenAPI.IntegrationTestHelpers.Auth; +using OpenAPI.IntegrationTestHelpers.Observability; namespace Example.OpenApi32.IntegrationTests; [UsedImplicitly] -public class FooApplicationFactory : WebApplicationFactory; +public class FooApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.InjectJwtBackChannelHandler(); + }); + + builder.AddLogging(); + } +} diff --git a/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs index 576443f..4b94acf 100644 --- a/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs @@ -3,6 +3,7 @@ using AwesomeAssertions; using Example.OpenApi32.IntegrationTests.Http; using Example.OpenApi32.IntegrationTests.Json; +using OpenAPI.IntegrationTestHelpers.Auth; namespace Example.OpenApi32.IntegrationTests; @@ -11,7 +12,8 @@ public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, I [Fact] public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/1"), @@ -41,7 +43,8 @@ public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() [Fact] public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() { - using var client = app.CreateClient(); + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); var result = await client.SendAsync(new HttpRequestMessage() { RequestUri = new Uri(client.BaseAddress!, "/foo/test"), @@ -64,4 +67,48 @@ public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); } + + [Fact] + public async Task Given_unauthenticated_request_When_Updating_Foo_It_Should_Return_401() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Given_unauthorized_request_When_Updating_Foo_It_Should_Return_403() + { + using var client = app.CreateClient().WithOAuth2ImplicitFlowAuthentication(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } diff --git a/tests/Example.OpenApi32/Example.OpenApi32.csproj b/tests/Example.OpenApi32/Example.OpenApi32.csproj index 34c3039..0c77fc8 100644 --- a/tests/Example.OpenApi32/Example.OpenApi32.csproj +++ b/tests/Example.OpenApi32/Example.OpenApi32.csproj @@ -9,10 +9,14 @@ + + + + diff --git a/tests/Example.OpenApi32/Program.cs b/tests/Example.OpenApi32/Program.cs index 4d99941..1599f23 100644 --- a/tests/Example.OpenApi32/Program.cs +++ b/tests/Example.OpenApi32/Program.cs @@ -1,6 +1,49 @@ +using Corvus.Json; +using Example.OpenApi.Auth; using Example.OpenApi32; +using Microsoft.AspNetCore.Authentication.Certificate; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAuthentication() + .AddJwtBearer(SecuritySchemes.PetstoreAuthKey, options => + { + var authority = + new Uri(SecuritySchemes.PetstoreAuth.Flows.Implicit.AuthorizationUrl).GetLeftPart(UriPartial.Authority); + options.Authority = authority; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authority, + ValidAudience = authority, + }; + }) + .AddScheme( + SecuritySchemes.SecretKeyKey, + options => + { + options.GetApiKey = context => + { + var parameter = SecuritySchemes.SecretKey.GetParameter(context); + return parameter.Validate(ValidationContext.ValidContext).IsValid + ? (true, parameter.GetString()!) + : (false, null); + }; + }) + .AddScheme( + SecuritySchemes.BasicAuthKey, + _ => { }) + .AddCertificate(SecuritySchemes.MutualTLSKey, options => + { + options.AllowedCertificateTypes = CertificateTypes.All; + }) + .AddCookie() + .AddOpenIdConnect(SecuritySchemes.OpenIdConnectKey, options => + { + options.Authority = SecuritySchemes.OpenIdConnect.OpenIdConnectUrl; + options.ClientId = "example-client"; + options.SignInScheme = "Cookies"; + }); + builder.AddOperations(builder.Configuration.Get()); var app = builder.Build(); app.MapOperations(); diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index df8f3a6..fbfccf5 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -58,7 +58,14 @@ "400": { "$ref": "#/components/responses/BadRequest" } - } + }, + "security": [ + {"petstore_auth": ["update"]}, + {"secret_key": []}, + {"basicAuth": []}, + {"mutualTLS": []}, + {"openIdConnect": []} + ] }, "delete": { "operationId": "Delete_Foo", @@ -142,6 +149,36 @@ } } } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://localhost/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "Bar" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + } } } } From 7c661a42975bd542c2271e456e8490c12ab7f8df Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 5 Feb 2026 19:26:55 +0100 Subject: [PATCH 39/39] test(auth): verify that the correct apikey parameter methods are generated depending on the construct of the specification --- .../CodeGeneration/AuthGenerator.cs | 2 +- .../ApiGeneratorTests.SecuritySchemes.cs | 77 +++++++++ ...ecuritySchemeWithMatchingParameterSpecs.cs | 156 ++++++++++++++++++ ...eySecuritySchemeWithMixedParameterSpecs.cs | 54 ++++++ ...tySchemeWithMultipleParameterTypesSpecs.cs | 62 +++++++ ...piKeySecuritySchemeWithNoParameterSpecs.cs | 124 ++++++++++++++ 6 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.SecuritySchemes.cs create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMatchingParameterSpecs.cs create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMixedParameterSpecs.cs create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMultipleParameterTypesSpecs.cs create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithNoParameterSpecs.cs diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 0c9c170..9846f48 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -106,7 +106,7 @@ private string GenerateGetParameterMethods(string schemeName, IOpenApiSecuritySc {GenerateConst(nameof(scheme.Name), scheme.Name)} {GenerateConst(nameof(scheme.In), scheme.In.GetDisplayName())} -""" : "")}}{{(parameterFullyQualifiedTypeNames.Length == 1 ? +""" : "")}}{{(parameterFullyQualifiedTypeNames.Length == 1 && !hasNonDefinedParameters ? $$""" /// /// Get the security scheme parameter for the current operation. diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.SecuritySchemes.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.SecuritySchemes.cs new file mode 100644 index 0000000..2981aa5 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.SecuritySchemes.cs @@ -0,0 +1,77 @@ +using System.IO; +using System.Linq; +using AwesomeAssertions; +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + [Theory] + [MemberData(nameof(ApiKeySecuritySchemeWithMatchingParameterSpecs))] + public void GivenApiKeySecuritySchemeWithMatchingParameter_WhenGenerating_SecuritySchemesClassHasGetParameterMethod( + string _, string openApiSpec) + { + var sourceCode = GetSecuritySchemesSourceCode(openApiSpec); + + sourceCode.Should().Contain("internal const string SecretKeyKey = \"secret_key\";"); + sourceCode.Should().Contain("internal static class SecretKey"); + sourceCode.Should().Contain("GetParameter(HttpContext context)"); + sourceCode.Should().NotContain("TryGetParameter(HttpContext context, out"); + } + + [Theory] + [MemberData(nameof(ApiKeySecuritySchemeWithMultipleParameterTypesSpecs))] + public void GivenApiKeySecuritySchemeWithMultipleParameterTypes_WhenGenerating_SecuritySchemesClassHasTryGetParameterMethods( + string _, string openApiSpec) + { + var sourceCode = GetSecuritySchemesSourceCode(openApiSpec); + + sourceCode.Should().Contain("internal const string SecretKeyKey = \"secret_key\";"); + sourceCode.Should().Contain("internal static class SecretKey"); + sourceCode.Should().Contain("TryGetParameter(HttpContext context, out", Exactly.Twice()); + sourceCode.Should().NotContain("GetParameter(HttpContext context)"); + } + + [Theory] + [MemberData(nameof(ApiKeySecuritySchemeWithNoParameterSpecs))] + public void GivenApiKeySecuritySchemeWithNoParameter_WhenGenerating_SecuritySchemesClassHasNameAndInConstants( + string _, string openApiSpec) + { + var sourceCode = GetSecuritySchemesSourceCode(openApiSpec); + + sourceCode.Should().Contain("internal const string SecretKeyKey = \"secret_key\";"); + sourceCode.Should().Contain("internal static class SecretKey"); + sourceCode.Should().Contain("internal const string Name = \"X-API-Key\";"); + sourceCode.Should().Contain("internal const string In = \"header\";"); + sourceCode.Should().NotContain("GetParameter(HttpContext context)"); + sourceCode.Should().NotContain("TryGetParameter(HttpContext context, out"); + } + + [Theory] + [MemberData(nameof(ApiKeySecuritySchemeWithMixedParameterSpecs))] + public void GivenApiKeySecuritySchemeWithMixedParameters_WhenGenerating_SecuritySchemesClassHasBothNameInConstantsAndTryGetParameterMethod( + string _, string openApiSpec) + { + var sourceCode = GetSecuritySchemesSourceCode(openApiSpec); + + sourceCode.Should().Contain("internal const string SecretKeyKey = \"secret_key\";"); + sourceCode.Should().Contain("internal static class SecretKey"); + sourceCode.Should().Contain("internal const string Name = \"X-API-Key\";"); + sourceCode.Should().Contain("internal const string In = \"header\";"); + sourceCode.Should().Contain("TryGetParameter(HttpContext context, out", Exactly.Once()); + sourceCode.Should().NotContain("GetParameter(HttpContext context)"); + } + + private string GetSecuritySchemesSourceCode(string openApiSpec) + { + var compilation = SetupGenerator(openApiSpec, out var diagnostics); + HasOnlyMissingHandler(diagnostics); + + var securitySchemesSyntaxTree = compilation.SyntaxTrees + .FirstOrDefault(t => Path.GetFileName(t.FilePath) == "SecuritySchemes.g.cs"); + securitySchemesSyntaxTree.Should().NotBeNull(); + + return securitySchemesSyntaxTree!.ToString(); + } +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMatchingParameterSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMatchingParameterSpecs.cs new file mode 100644 index 0000000..08fd84e --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMatchingParameterSpecs.cs @@ -0,0 +1,156 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData ApiKeySecuritySchemeWithMatchingParameterSpecs => new() + { + { + "Swagger 2.0", + """ + { + "swagger": "2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "type": "string", + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "securityDefinitions": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + """ + }, + { + "OpenAPI 3.0", + """ + { + "openapi": "3.0.3", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "string" }, + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + }, + { + "OpenAPI 3.1", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "string" }, + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + }, + { + "OpenAPI 3.2", + """ + { + "openapi": "3.2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "string" }, + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + } + }; +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMixedParameterSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMixedParameterSpecs.cs new file mode 100644 index 0000000..21ab9ae --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMixedParameterSpecs.cs @@ -0,0 +1,54 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData ApiKeySecuritySchemeWithMixedParameterSpecs => new() + { + { + "OpenAPI 3.1 - One operation with parameter, one without", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "string" }, + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + }, + "delete": { + "operationId": "DeleteFoo", + "responses": { + "204": { "description": "Deleted" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + } + }; +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMultipleParameterTypesSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMultipleParameterTypesSpecs.cs new file mode 100644 index 0000000..f7f9b13 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithMultipleParameterTypesSpecs.cs @@ -0,0 +1,62 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData ApiKeySecuritySchemeWithMultipleParameterTypesSpecs => new() + { + { + "OpenAPI 3.1 - Multiple operations with different parameter types", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "string" }, + "required": true + } + ], + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + }, + "post": { + "operationId": "CreateFoo", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "schema": { "type": "integer" }, + "required": true + } + ], + "responses": { + "201": { "description": "Created" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + } + }; +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithNoParameterSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithNoParameterSpecs.cs new file mode 100644 index 0000000..6a26e29 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ApiKeySecuritySchemeWithNoParameterSpecs.cs @@ -0,0 +1,124 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData ApiKeySecuritySchemeWithNoParameterSpecs => new() + { + { + "Swagger 2.0", + """ + { + "swagger": "2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "securityDefinitions": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + """ + }, + { + "OpenAPI 3.0", + """ + { + "openapi": "3.0.3", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + }, + { + "OpenAPI 3.1", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + }, + { + "OpenAPI 3.2", + """ + { + "openapi": "3.2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { "description": "Success" } + }, + "security": [{ "secret_key": [] }] + } + } + }, + "components": { + "securitySchemes": { + "secret_key": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } + } + """ + } + }; +}