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/HttpClientAuthExtensions.cs b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs new file mode 100644 index 0000000..8623d7f --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/HttpClientAuthExtensions.cs @@ -0,0 +1,20 @@ +using System.Net.Http.Headers; + +namespace OpenAPI.IntegrationTestHelpers.Auth; + +public static class HttpClientAuthExtensions +{ + public static HttpClient WithOAuth2ImplicitFlowAuthentication(this HttpClient client, params string[] scopes) + { + client.DefaultRequestHeaders.Authorization = + AuthenticationHeaderValue.Parse($"Bearer {OIDCAuthHttpHandler.GetJwt(scopes)}"); + 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/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs new file mode 100644 index 0000000..aed9d7c --- /dev/null +++ b/OpenAPI.IntegrationTestHelpers/Auth/OIDCAuthHttpHandler.cs @@ -0,0 +1,156 @@ +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 static readonly SigningCredentials _privateKey; + 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) + }; + + _privateKey = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature); + + OidcConfigurationContent = CreateOidcConfigurationContent(); + JwksContent = CreateJwksContent(); + } + + public static string GetJwt(params string[] scopes) => GenerateJwtToken(scopes); + 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(params string[] scopes) + { + 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"] = string.Join(" ", scopes) + } + }; + + 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/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/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..3db1389 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -28,6 +28,12 @@ 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 +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 @@ -158,10 +164,35 @@ 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 + {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/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/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 3c41a40..a70fd1a 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; 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; @@ -66,7 +68,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, @@ -77,13 +84,14 @@ private static void GenerateCode(SourceProductionContext context, openApiVersion); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); - var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace); + var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace, authGenerator); apiConfigurationGenerator.GenerateClass().AddTo(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)>(); + var securityParameterGenerators = new ConcurrentDictionary>(); foreach (var path in openApi.Paths) { var pathExpression = path.Key; @@ -106,7 +114,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); @@ -193,12 +200,13 @@ private static void GenerateCode(SourceProductionContext context, operationDirectory); responseSourceCode.AddTo(context); - operations.Add((operationNamespace, operationMethod)); + operations.Add((operationNamespace, openApiOperation)); var endpointSource = endpointGenerator .Generate(operationNamespace, operationDirectory, pathExpression, - operationMethod); + (openApiOperation.Key, openApiOperation.Value), + operationParameterGenerators.Values.ToArray()); endpointSource .AddTo(context); } @@ -213,7 +221,10 @@ private static void GenerateCode(SourceProductionContext context, } } - var operationRouterGenerator = new OperationRouterGenerator(rootNamespace); + authGenerator.GenerateSecuritySchemeClass(rootNamespace)?.AddTo(context); + authGenerator.GenerateSecuritySchemeOptionsClass(rootNamespace)?.AddTo(context); + authGenerator.GenerateSecurityRequirementsFilter(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 new file mode 100644 index 0000000..9846f48 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection.Metadata; +using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.Extensions; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; + +internal sealed class AuthGenerator +{ + private readonly IDictionary _securitySchemes; + private readonly Dictionary>[] _topLevelSecuritySchemeGroups; + + private readonly ConcurrentDictionary> _securitySchemeParameters = new(); + + private readonly Dictionary _requestFilters = new(); + + public AuthGenerator(OpenApiDocument openApiDocument) + { + _securitySchemes = openApiDocument.Components?.SecuritySchemes ?? + new Dictionary(); + _topLevelSecuritySchemeGroups = GetSecuritySchemeGroups(openApiDocument.Security) ?? []; + HasSecuritySchemes = _securitySchemes.Any(); + } + + internal bool HasSecuritySchemes { get; } + internal SourceCode? GenerateSecuritySchemeClass(string @namespace) + { + if (!_securitySchemes.Any()) + { + return null; + } + return new SourceCode("SecuritySchemes.g.cs", +$$""" +using System.Collections.Immutable; + +namespace {{@namespace}}; + +internal static class SecuritySchemes +{{{_securitySchemes.AggregateToString(pair => + { + var schemeName = pair.Key; + var className = schemeName.ToPascalCase(); + var scheme = pair.Value; + return scheme.Type == null ? string.Empty : +$$""" + internal const string {{className}}Key = "{{pair.Key}}"; + internal static class {{className}} + {{{new [] + { + GenerateConst(nameof(scheme.Description), scheme.Description), + GenerateConst(nameof(scheme.Type), scheme.Type?.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)}} + } +"""; + })}} +} +"""); + } + + private static string GenerateConst(string name, string? value) => + value == null + ? string.Empty + : $""" + internal const string {name} = "{value}"; + """; + + private string GenerateGetParameterMethods(string schemeName, IOpenApiSecurityScheme scheme) + { + if (scheme.Name == null || scheme.In == null) + { + return string.Empty; + } + + var hasNonDefinedParameters = true; + var parameterGenerators = Array.Empty(); + var parameterFullyQualifiedTypeNames = Array.Empty(); + if (_securitySchemeParameters.TryGetValue(schemeName, out var securitySchemeParameters)) + { + 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 && !hasNonDefinedParameters ? +$$""" +/// +/// 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}}"]; + +""" : $$""" +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 +{ + if (context.Items.TryGetValue("{{securitySchemeParameterKey}}", out var itemValue)) + { + switch (itemValue) + { + case T typedValue: + value = typedValue; + return true; + case null: + value = null; + return true; + } + } + + value = null; + return false; +} +{{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); +""")}} +""")}} +"""; + } + + 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 ImmutableDictionary {{nameof(flow.Scopes)}} = + ImmutableDictionary.CreateRange([{{flow.Scopes.AggregateToString(scope => +$""" + new("{scope.Key}", "{scope.Value}"), +""").TrimEnd(',')}} +]); +""" +}.RemoveEmptyLines().AggregateToString().Indent(4)}} +} +"""; + + internal string[] GetSecurityFilterNames(OpenApiOperation operation) => _requestFilters[operation]; + internal SourceCode? GenerateSecurityRequirementsFilter(string @namespace) + { + if (!_securitySchemes.Any()) + { + return null; + } + + 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; + var cancellationToken = httpContext.RequestAborted; + + var principal = httpContext.User ??= new(); + + 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) + { + 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) + { + HandleForbidden(httpContext.Response); + return null; + } + + HandleUnauthorized(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)); + } + + internal class SecurityRequirements : List, IAuthorizationRequirement; + internal class SecurityRequirement : Dictionary; +} +#nullable restore +"""); + } + + 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 {{securityRequirementsFilterClassName}} : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + // Anonymous + context.HttpContext.User ??= new(new ClaimsIdentity()); + return next(context); + } +} +"""; + } + + var securitySchemeParameters = GetSecuritySchemeParameters(operation, parameters); + var hasSecuritySchemeParameters = securitySchemeParameters.Any(); + _requestFilters.Add(operation, + hasSecuritySchemeParameters + ? [securitySchemeParameterFilterClassName, securityRequirementsFilterClassName] + : [securityRequirementsFilterClassName]); + return (hasSecuritySchemeParameters ? +$$""" +internal sealed class {{securitySchemeParameterFilterClassName}} : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var request = (Request) httpContext.Items[RequestItemKey]!; +{{securitySchemeParameters + .Select(tuple => tuple.Value) + .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(",", + securityRequirementGroups.Select(securityRequirementGroup => + securityRequirementGroup.AggregateToString(securityRequirement => +$$""" + new SecurityRequirement + { + ["{{securityRequirement.Key}}"] = [{{string.Join(", ", securityRequirement.Value.Select(scope => $"\"{scope}\""))}}] + } +""")))}} + }; + + 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); +} +"""; + } + + internal SourceCode? GenerateSecuritySchemeOptionsClass(string @namespace) + { + if (!_securitySchemes.Any()) + { + return null; + } + return new SourceCode("SecuritySchemeOptions.g.cs", +$$""" +#nullable enable +namespace {{@namespace}}; + +internal sealed class SecuritySchemeOptions +{{{_securitySchemes.AggregateToString(pair => + $$""" + 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 + { + public ScopeOptions Scope {get; init; } = new() + { + Claim = "scope", + Format = ScopeOptions.ClaimFormat.SpaceDelimited + }; + } + + internal sealed class ScopeOptions + { + public required string Claim { get; init; } + public required ClaimFormat Format { get; init; } + + internal enum ClaimFormat + { + SpaceDelimited, + Array + } + } +} +#nullable restore +"""); + } + + private Dictionary>[]? GetSecuritySchemeGroups(IList? securityRequirements) => + securityRequirements? + .Select(requirement => + requirement.ToDictionary( + pair => GetSecuritySchemeName(pair.Key), + pair => pair.Value)) + .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.ToPascalCase()}.{generator.PropertyName}"; + + private Dictionary GetSecuritySchemeParameters(OpenApiOperation operation, ParameterGenerator[] parameters) + { + 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)) ?? null)) + .ToArray() + ?? []; + + foreach (var (scheme, parameter) in nullableSecuritySchemeParameters) + { + _securitySchemeParameters.AddOrUpdate(GetSecuritySchemeName(scheme), + _ => [(operation, parameter)], + (_, list) => + { + list.Add((operation, parameter)); + return list; + }); + } + + return nullableSecuritySchemeParameters + .Where(pair => pair.Parameter != null) + .ToDictionary(pair => pair.Scheme, pair => pair.Parameter!); + } +} diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 145447b..242ea59 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -5,23 +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, + ParameterGenerator[] parameters) { 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}}; @@ -29,8 +40,10 @@ 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"; + /// /// Set validation level for requests and responses /// @@ -51,6 +64,38 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; +{{authGenerator.GenerateAuthFilters(operation.Operation, parameters, 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) + { + 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 +106,7 @@ internal static async Task HandleAsync( [FromServices] WebApiConfiguration configuration, CancellationToken cancellationToken) { - var request = await Request.BindAsync(context, cancellationToken) - .ConfigureAwait(false); + var request = (Request) context.Items[RequestItemKey]!; var validationContext = request.Validate(operation.ValidationLevel); if (!validationContext.IsValid) @@ -74,18 +118,49 @@ internal static async Task HandleAsync( var response = await operation.HandleAsync(request, cancellationToken) .ConfigureAwait(false); - if (operation.ValidateResponse) + 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 ? +""" + +internal abstract partial class Response +{ + internal sealed class Unauthorized : Response + { + internal override void WriteTo(HttpResponse httpResponse) { - validationContext = response.Validate(operation.ValidationLevel); - if (!validationContext.IsValid) - { - var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); - {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; - } + httpResponse.StatusCode = 401; } - response.WriteTo(context.Response); + + 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 """; var hasImplementedHandleMethod = compilation.GetSymbolsWithName("Operation", SymbolFilter.Type) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs index 32535ef..bbdc860 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationRouterGenerator.cs @@ -1,15 +1,18 @@ 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 @@ -17,8 +20,15 @@ internal static class OperationRouter internal static WebApplication MapOperations(this WebApplication app) {{{operations.AggregateToString(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) + .AddEndpointFilter<{operation.Namespace}.Operation.BindRequestFilter>(){ + 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/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index eb4beae..63f9994 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -28,6 +28,8 @@ internal abstract partial class Response internal abstract void WriteTo(HttpResponse httpResponse); internal abstract ValidationContext Validate(ValidationLevel validationLevel); + + {{ responseBodyGenerators.AggregateToString(generator => generator.GenerateResponseContentClass()).Indent(4) diff --git a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs index eb0a13d..093684e 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,17 @@ 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)); + + 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 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/ApiKeyAuthenticationHandler.cs b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..91a6782 --- /dev/null +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,32 @@ +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 (isValid, value) = Options.GetApiKey(Context); + if (!isValid) + { + 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); + 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..f93c20d --- /dev/null +++ b/tests/Example.OpenApi/Auth/ApiKeyAuthenticationOptions.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Example.OpenApi.Auth; + +public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions +{ + public Func GetApiKey { get; set; } = _ => throw new InvalidOperationException("Missing api key handler"); +} \ No newline at end of file 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.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.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.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..b4314b6 100644 --- a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi31.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.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.InjectJwtBackChannelHandler(); + }); + + builder.AddLogging(); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs index 248a95e..67a068c 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("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); + } } \ No newline at end of file diff --git a/tests/Example.OpenApi31/Example.OpenApi31.csproj b/tests/Example.OpenApi31/Example.OpenApi31.csproj index 9c5c66c..76ae79d 100644 --- a/tests/Example.OpenApi31/Example.OpenApi31.csproj +++ b/tests/Example.OpenApi31/Example.OpenApi31.csproj @@ -9,10 +9,14 @@ + + + + diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs index 334940a..6b40083 100644 --- a/tests/Example.OpenApi31/Program.cs +++ b/tests/Example.OpenApi31/Program.cs @@ -1,6 +1,49 @@ +using Corvus.Json; +using Example.OpenApi.Auth; using Example.OpenApi31; +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.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index f8d7cba..f9f1100 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/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" + } } } } \ 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" + } } } } 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" + } + } + } + } + """ + } + }; +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json index c2e9925..1c62a29 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", @@ -492,6 +497,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "parameters": [ { "name": "body", @@ -779,6 +785,9 @@ } }, "securityDefinitions": { + "basicAuth": { + "type": "basic" + }, "bearerAuth": { "type": "apiKey", "name": "Authorization", @@ -788,6 +797,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..4c06ea7 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", @@ -193,6 +196,7 @@ "operationId": "uploadPetImage", "summary": "Upload pet image", "tags": ["pets"], + "security": [{"mutualTLS": []}], "parameters": [ { "name": "petId", @@ -244,6 +248,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 +310,7 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [{"oauth2": ["read:pets"]}, {"apiKey": []}], "responses": { "200": { "description": "Inventory counts by status", @@ -350,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"} @@ -396,6 +403,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -845,6 +853,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -854,6 +866,26 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, + "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..a0f8b7c 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", @@ -193,6 +201,7 @@ "operationId": "uploadPetImage", "summary": "Upload pet image", "tags": ["pets"], + "security": [{"mutualTLS": []}], "parameters": [ { "name": "petId", @@ -244,6 +253,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 +317,10 @@ "operationId": "getInventory", "summary": "Get store inventory", "tags": ["store"], + "security": [ + {"oauth2": ["read:pets"]}, + {"apiKey": []} + ], "responses": { "200": { "description": "Inventory counts by status", @@ -350,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"} @@ -396,6 +413,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -845,6 +863,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -854,6 +876,27 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "mutualTLS": { + "type": "mutualTLS" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, + "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..d266043 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", @@ -501,6 +506,7 @@ "operationId": "getUser", "summary": "Get user by username", "tags": ["users"], + "security": [{"openIdConnect": ["profile", "email"]}], "parameters": [ { "name": "username", @@ -592,6 +598,7 @@ "operationId": "loginUser", "summary": "User login", "tags": ["users"], + "security": [{"basicAuth": []}], "requestBody": { "required": true, "content": { @@ -888,6 +895,10 @@ } }, "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, "bearerAuth": { "type": "http", "scheme": "bearer", @@ -897,6 +908,23 @@ "type": "apiKey", "in": "header", "name": "X-Api-Key" + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, + "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" + } + } + } } } },