From 172fc7a96f4e3b7e150003da964ea18079bd0a1c Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:02:47 +0100 Subject: [PATCH 1/6] Include authentication in generated OpenAPI specs. Closes #1147 Extract auth detection from TypeSpecGeneratorPlugin into a shared AuthDetector and use it in OpenApiSpecGeneratorPlugin to emit components.securitySchemes and operation security requirements for API keys (header/query), bearer tokens, and OAuth2 (Entra ID and generic JWT issuers). Scopes are merged across recorded requests and detected API key query parameters are no longer emitted as regular parameters. TypeSpec output is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 293 +++ DevProxy.Plugins/Generation/AuthDetector.cs | 196 ++ .../Generation/OpenApiSpecGeneratorPlugin.cs | 1807 +++++++++-------- .../Generation/TypeSpecGeneratorPlugin.cs | 169 +- 4 files changed, 1479 insertions(+), 986 deletions(-) create mode 100644 DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs create mode 100644 DevProxy.Plugins/Generation/AuthDetector.cs diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs new file mode 100644 index 000000000..3730856bd --- /dev/null +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Text.Json.Nodes; +using DevProxy.Abstractions.Models; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Generation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Coverage for authentication detection in the API spec generator plugins +/// (OpenAPI security schemes and TypeSpec @useAuth). +/// +public sealed class GenerationAuthIntegrationTests +{ + private const string EntraIssuerV2 = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000001/v2.0"; + private const string EntraIssuerV1 = "https://sts.windows.net/00000000-0000-0000-0000-000000000001/"; + private const string EntraBaseUrl = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000001/"; + + private static ISet Watch => KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com"); + + private static RecordingArgs Recording(IEnumerable logs) => + new(logs) + { + GlobalData = new() { [ProxyUtils.ReportsKey] = new Dictionary() }, + }; + + private static RequestLog Exchange(string url, params (string Name, string Value)[] headers) => + TestExchange + .Request("GET", url, headers: [("Accept", "application/json"), .. headers]) + .WithResponse( + HttpStatusCode.OK, + headers: [("Content-Type", "application/json")], + body: """[ { "id": 1, "name": "alpha" } ]""") + .AsRequestLog(MessageType.InterceptedResponse); + + private static string Jwt(string issuer, string? version = null, string? scp = null, params string[] roles) + { + var claims = new List(); + if (version is not null) + { + claims.Add(new("ver", version)); + } + if (scp is not null) + { + claims.Add(new("scp", scp)); + } + claims.AddRange(roles.Select(r => new Claim("roles", r))); + return new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(issuer, claims: claims)); + } + + private static async Task GenerateOpenApiAsync(IEnumerable logs, IConfigurationSection? config = null) + { + var plugin = new OpenApiSpecGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new DisabledLanguageModelClient(), + new TestProxyConfiguration(), + config ?? PluginConfig.Empty()); + return await GenerateAsync(plugin.AfterRecordingStopAsync, logs, "*.json"); + } + + private static async Task GenerateOpenApiJsonAsync(IEnumerable logs, IConfigurationSection? config = null) => + JsonNode.Parse(await GenerateOpenApiAsync(logs, config))!; + + private static async Task GenerateTypeSpecAsync(IEnumerable logs) + { + var plugin = new TypeSpecGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new DisabledLanguageModelClient(), + new TestProxyConfiguration(), + PluginConfig.Empty()); + return await GenerateAsync(plugin.AfterRecordingStopAsync, logs, "*.tsp"); + } + + private static async Task GenerateAsync( + Func generate, + IEnumerable logs, + string pattern) + { + var dir = Directory.CreateTempSubdirectory("devproxy-gen-auth-"); + var originalCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(dir.FullName); + try + { + await generate(Recording(logs), CancellationToken.None); + return await File.ReadAllTextAsync(Assert.Single(dir.GetFiles(pattern)).FullName); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + dir.Delete(recursive: true); + } + } + + private static JsonNode Operation(JsonNode doc, string path = "/users") => doc["paths"]![path]!["get"]!; + + private static JsonObject SecurityScheme(JsonNode doc, string name) => + doc["components"]!["securitySchemes"]![name]!.AsObject(); + + private static string[] RequirementScopes(JsonNode operation, string schemeName) => + [.. operation["security"]!.AsArray() + .Select(r => r![schemeName]) + .Single(s => s is not null)! + .AsArray() + .Select(s => s!.GetValue())]; + + [Fact] + public async Task OpenApiSpecGenerator_NoAuth_EmitsNoSecurity() + { + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users")]); + + Assert.Null(doc["components"]?["securitySchemes"]); + Assert.Null(Operation(doc)["security"]); + } + + [Fact] + public async Task OpenApiSpecGenerator_ApiKeyHeader_EmitsApiKeyScheme() + { + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value"))]); + + var scheme = SecurityScheme(doc, "ApiKeyAuth_header_x-api-key"); + Assert.Equal("apiKey", scheme["type"]!.GetValue()); + Assert.Equal("x-api-key", scheme["name"]!.GetValue()); + Assert.Equal("header", scheme["in"]!.GetValue()); + Assert.Empty(RequirementScopes(Operation(doc), "ApiKeyAuth_header_x-api-key")); + Assert.DoesNotContain("secret-value", doc.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task OpenApiSpecGenerator_ApiKeyQuery_EmitsApiKeySchemeAndSkipsParameter() + { + var config = PluginConfig.FromJson("""{ "includeParameters": ["code"] }"""); + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users?code=secret-value&top=10")], config); + + var scheme = SecurityScheme(doc, "ApiKeyAuth_query_code"); + Assert.Equal("apiKey", scheme["type"]!.GetValue()); + Assert.Equal("code", scheme["name"]!.GetValue()); + Assert.Equal("query", scheme["in"]!.GetValue()); + var parameters = Operation(doc)["parameters"]?.AsArray().Select(p => p!["name"]!.GetValue()) ?? []; + Assert.DoesNotContain("code", parameters); + Assert.DoesNotContain("secret-value", doc.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task OpenApiSpecGenerator_BasicAuthorization_EmitsApiKeyHeaderScheme() + { + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", "Basic dXNlcjpwYXNz"))]); + + var scheme = SecurityScheme(doc, "ApiKeyAuth_header_Authorization"); + Assert.Equal("apiKey", scheme["type"]!.GetValue()); + Assert.Equal("Authorization", scheme["name"]!.GetValue()); + Assert.Equal("header", scheme["in"]!.GetValue()); + } + + [Fact] + public async Task OpenApiSpecGenerator_OpaqueBearer_EmitsHttpBearerScheme() + { + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", "Bearer opaque-token"))]); + + var scheme = SecurityScheme(doc, "BearerAuth"); + Assert.Equal("http", scheme["type"]!.GetValue()); + Assert.Equal("bearer", scheme["scheme"]!.GetValue()); + Assert.Empty(RequirementScopes(Operation(doc), "BearerAuth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_EntraDelegatedToken_EmitsAuthorizationCodeFlow() + { + var token = Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read Mail.Read"); + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + var scheme = SecurityScheme(doc, "EntraOAuth2Auth"); + Assert.Equal("oauth2", scheme["type"]!.GetValue()); + var flow = scheme["flows"]!["authorizationCode"]!; + Assert.Equal($"{EntraBaseUrl}oauth2/v2.0/authorize", flow["authorizationUrl"]!.GetValue()); + Assert.Equal($"{EntraBaseUrl}oauth2/v2.0/token", flow["tokenUrl"]!.GetValue()); + Assert.Equal($"{EntraBaseUrl}oauth2/v2.0/token", flow["refreshUrl"]!.GetValue()); + Assert.Equal(["User.Read", "Mail.Read"], flow["scopes"]!.AsObject().Select(s => s.Key)); + Assert.Null(scheme["flows"]!["clientCredentials"]); + Assert.Equal(["User.Read", "Mail.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_EntraAppToken_EmitsClientCredentialsFlow() + { + var token = Jwt(EntraIssuerV1, roles: ["User.Read.All", "Mail.Read"]); + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + var scheme = SecurityScheme(doc, "EntraOAuth2Auth"); + var flow = scheme["flows"]!["clientCredentials"]!; + Assert.Null(flow["authorizationUrl"]); + Assert.Equal($"{EntraIssuerV1}oauth2/token", flow["tokenUrl"]!.GetValue()); + Assert.Equal(["User.Read.All", "Mail.Read"], flow["scopes"]!.AsObject().Select(s => s.Key)); + Assert.Null(scheme["flows"]!["authorizationCode"]); + Assert.Equal(["User.Read.All", "Mail.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_GenericJwt_EmitsGenericOAuth2Scheme() + { + var token = Jwt("https://auth.contoso.com/", scp: "read"); + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + var flow = SecurityScheme(doc, "APIOAuth2Auth")["flows"]!["authorizationCode"]!; + Assert.Equal("https://auth.contoso.com/", flow["authorizationUrl"]!.GetValue()); + Assert.Equal("https://auth.contoso.com/", flow["tokenUrl"]!.GetValue()); + Assert.Null(flow["refreshUrl"]); + Assert.Equal(["read"], RequirementScopes(Operation(doc), "APIOAuth2Auth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_MergesScopesAcrossRequests() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read")}")), + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read User.ReadBasic.All")}")), + Exchange("https://api.contoso.com/groups", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "Group.Read.All")}")), + ]); + + var flow = SecurityScheme(doc, "EntraOAuth2Auth")["flows"]!["authorizationCode"]!; + Assert.Equal(["User.Read", "User.ReadBasic.All", "Group.Read.All"], flow["scopes"]!.AsObject().Select(s => s.Key)); + _ = Assert.Single(Operation(doc)["security"]!.AsArray()); + Assert.Equal(["User.Read", "User.ReadBasic.All"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + Assert.Equal(["Group.Read.All"], RequirementScopes(Operation(doc, "/groups"), "EntraOAuth2Auth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_DifferentAuthOnSameOperation_EmitsAlternativeRequirements() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value")), + Exchange("https://api.contoso.com/users", ("Authorization", "Bearer opaque-token")), + ]); + + var security = Operation(doc)["security"]!.AsArray(); + Assert.Equal(2, security.Count); + Assert.NotNull(security[0]!["ApiKeyAuth_header_x-api-key"]); + Assert.NotNull(security[1]!["BearerAuth"]); + } + + [Fact] + public async Task OpenApiSpecGenerator_V2_EmitsSecurityDefinitions() + { + var token = Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read"); + var doc = await GenerateOpenApiJsonAsync( + [Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))], + PluginConfig.FromJson("""{ "specVersion": "v2_0" }""")); + + var scheme = doc["securityDefinitions"]!["EntraOAuth2Auth"]!; + Assert.Equal("oauth2", scheme["type"]!.GetValue()); + Assert.Equal("accessCode", scheme["flow"]!.GetValue()); + Assert.Equal(["User.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + } + + [Fact] + public async Task TypeSpecGenerator_EntraDelegatedToken_UsesOAuth2Auth() + { + var token = Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read Mail.Read"); + var tsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + Assert.Contains("alias EntraOAuth2Auth = OAuth2Auth<", tsp, StringComparison.Ordinal); + Assert.Contains("type: OAuth2FlowType.authorizationCode;", tsp, StringComparison.Ordinal); + Assert.Contains($"authorizationUrl: \"{EntraBaseUrl}oauth2/v2.0/authorize\";", tsp, StringComparison.Ordinal); + Assert.Contains($"tokenUrl: \"{EntraBaseUrl}oauth2/v2.0/token\";", tsp, StringComparison.Ordinal); + Assert.Contains("@useAuth(EntraOAuth2Auth<[\"User.Read\", \"Mail.Read\"]>)", tsp, StringComparison.Ordinal); + } + + [Fact] + public async Task TypeSpecGenerator_ApiKeyAndBearer_UseMatchingAuth() + { + var apiKeyTsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value"))]); + Assert.Contains("@useAuth(ApiKeyAuth)", apiKeyTsp, StringComparison.Ordinal); + + var queryTsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users?code=secret-value")]); + Assert.Contains("@useAuth(ApiKeyAuth)", queryTsp, StringComparison.Ordinal); + + var bearerTsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("Authorization", "Bearer opaque-token"))]); + Assert.Contains("@useAuth(BearerAuth)", bearerTsp, StringComparison.Ordinal); + } +} diff --git a/DevProxy.Plugins/Generation/AuthDetector.cs b/DevProxy.Plugins/Generation/AuthDetector.cs new file mode 100644 index 000000000..2371e7cef --- /dev/null +++ b/DevProxy.Plugins/Generation/AuthDetector.cs @@ -0,0 +1,196 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Proxy.Http; +using Microsoft.Extensions.Logging; +using System.IdentityModel.Tokens.Jwt; +using System.Web; + +namespace DevProxy.Plugins.Generation; + +internal enum DetectedApiKeyLocation +{ + Header, + Query +} + +internal enum DetectedOAuth2FlowType +{ + AuthorizationCode, + ClientCredentials +} + +internal abstract record DetectedAuth; + +internal sealed record DetectedApiKeyAuth(string Name, DetectedApiKeyLocation In) : DetectedAuth; + +internal sealed record DetectedBearerAuth : DetectedAuth; + +internal sealed record DetectedOAuth2Auth( + string Name, + DetectedOAuth2FlowType FlowType, + string AuthorizationUrl, + string TokenUrl, + string RefreshUrl, + IReadOnlyList Scopes) : DetectedAuth +{ + public const string EntraName = "EntraOAuth2Auth"; + public const string GenericName = "APIOAuth2Auth"; +} + +/// +/// Detects how a recorded request authenticates, based on its headers and +/// query string. Shared by the API spec generator plugins. +/// +internal static class AuthDetector +{ + public static DetectedAuth? Detect(IHttpRequest httpRequest, ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpRequest); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogTrace("Entered {Name}", nameof(Detect)); + + var authHeaders = httpRequest.Headers + .Where(h => Models.Http.AuthHeaders.Contains(h.Name.ToLowerInvariant())) + .Select(h => (h.Name, h.Value)); + + foreach (var (name, value) in authHeaders) + { + if (name.Equals("cookie", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!name.Equals("authorization", StringComparison.OrdinalIgnoreCase) || + !value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + logger.LogTrace("Left {Name}", nameof(Detect)); + return new DetectedApiKeyAuth(name, DetectedApiKeyLocation.Header); + } + + var bearerToken = value["Bearer ".Length..].Trim(); + logger.LogTrace("Left {Name}", nameof(Detect)); + return DetectAuthorizationAuth(bearerToken, logger); + } + + var authQueryParam = GetAuthQueryParameter(httpRequest.RequestUri); + if (authQueryParam is not null) + { + logger.LogDebug("Found auth query parameter: {AuthQueryParam}", authQueryParam); + logger.LogTrace("Left {Name}", nameof(Detect)); + return new DetectedApiKeyAuth(authQueryParam, DetectedApiKeyLocation.Query); + } + + logger.LogDebug("No auth headers or query parameters found"); + logger.LogTrace("Left {Name}", nameof(Detect)); + return null; + } + + public static string? GetAuthQueryParameter(Uri requestUri) + { + ArgumentNullException.ThrowIfNull(requestUri); + + var query = HttpUtility.ParseQueryString(requestUri.Query); + return query.AllKeys + .FirstOrDefault(k => k is not null && Models.Http.AuthHeaders.Contains(k.ToLowerInvariant())); + } + + private static DetectedAuth DetectAuthorizationAuth(string bearerToken, ILogger logger) + { + logger.LogTrace("Entered {Name}", nameof(DetectAuthorizationAuth)); + + if (!IsJwtToken(bearerToken, logger, out var jwtToken)) + { + logger.LogTrace("Left {Name}", nameof(DetectAuthorizationAuth)); + return new DetectedBearerAuth(); + } + + var issuer = jwtToken.Issuer; + logger.LogDebug("Issuer: {Issuer}", issuer); + var scopes = jwtToken.Claims + .Where(c => c.Type == "scp") + .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Distinct() + .ToList(); + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Scopes: {Scopes}", string.Join(", ", scopes)); + } + var roles = jwtToken.Claims + .Where(c => c.Type == "roles") + .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Distinct() + .ToList(); + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Roles: {Roles}", string.Join(", ", roles)); + } + scopes.AddRange(roles); + + DetectedOAuth2Auth auth; + if (IsEntraToken(issuer, logger)) + { + var version = jwtToken.Claims + .FirstOrDefault(c => c.Type == "ver")?.Value ?? "1.0"; + var baseUrl = issuer.Contains("v2.0", StringComparison.OrdinalIgnoreCase) ? + issuer.Replace("v2.0", "", StringComparison.OrdinalIgnoreCase) : issuer; + var tokenUrl = version == "1.0" ? $"{baseUrl}oauth2/token" : $"{baseUrl}oauth2/v2.0/token"; + auth = new( + DetectedOAuth2Auth.EntraName, + roles.Count != 0 ? DetectedOAuth2FlowType.ClientCredentials : DetectedOAuth2FlowType.AuthorizationCode, + version == "1.0" ? $"{baseUrl}oauth2/authorize" : $"{baseUrl}oauth2/v2.0/authorize", + tokenUrl, + tokenUrl, + scopes); + } + else + { + auth = new( + DetectedOAuth2Auth.GenericName, + DetectedOAuth2FlowType.AuthorizationCode, + issuer, + issuer, + string.Empty, + scopes); + } + + logger.LogTrace("Left {Name}", nameof(DetectAuthorizationAuth)); + return auth; + } + + private static bool IsEntraToken(string issuer, ILogger logger) + { + logger.LogTrace("Entered {Name}", nameof(IsEntraToken)); + + var isEntraToken = issuer.Contains("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase) || + issuer.Contains("https://sts.windows.net/", StringComparison.OrdinalIgnoreCase); + + logger.LogDebug("Is token from Entra? {IsEntraToken}", isEntraToken); + logger.LogTrace("Left {Name}", nameof(IsEntraToken)); + + return isEntraToken; + } + + private static bool IsJwtToken(string bearerToken, ILogger logger, out JwtSecurityToken jwtToken) + { + logger.LogTrace("Entered {Name}", nameof(IsJwtToken)); + + jwtToken = new(); + + try + { + jwtToken = new(bearerToken); + logger.LogTrace("Left {Name}", nameof(IsJwtToken)); + return true; + } + catch (Exception ex) + { + logger.LogDebug("Failed to parse JWT token: {Ex}", ex.Message); + } + + logger.LogTrace("Left {Name}", nameof(IsJwtToken)); + return false; + } +} diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 9963c0383..4b8c29f96 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -1,841 +1,966 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.LanguageModel; -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Proxy.Http; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi; -using System.Text.Json.Nodes; -using System.Collections.Specialized; -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Web; - -namespace DevProxy.Plugins.Generation; - -sealed class GeneratedByOpenApiExtension : IOpenApiExtension -{ - public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) - { - writer.WriteStartObject(); - writer.WriteProperty("toolName", "Dev Proxy"); - writer.WriteProperty("toolVersion", ProxyUtils.ProductVersion); - writer.WriteEndObject(); - } -} - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SpecVersion -{ -#pragma warning disable CA1707 - v2_0, - v3_0, - v3_1, - v3_2 -#pragma warning restore CA1707 -} - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SpecFormat -{ - Json, - Yaml -} - -public sealed class OpenApiSpecGeneratorPluginConfiguration -{ - public bool IncludeOptionsRequests { get; set; } - public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; - public SpecVersion SpecVersion { get; set; } = SpecVersion.v3_0; - public bool IgnoreResponseTypes { get; set; } - public IEnumerable IncludeParameters { get; set; } = []; -} - -public sealed class OpenApiSpecGeneratorPlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - ILanguageModelClient languageModelClient, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BaseReportingPlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - public static readonly string GeneratedOpenApiSpecsKey = "GeneratedOpenApiSpecs"; - - public override string Name => nameof(OpenApiSpecGeneratorPlugin); - - public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); - - ArgumentNullException.ThrowIfNull(e); - - if (!e.RequestLogs.Any()) - { - Logger.LogDebug("No requests to process"); - return; - } - - Logger.LogInformation("Creating OpenAPI spec from recorded requests..."); - - var openApiDocs = new List(); - - foreach (var request in e.RequestLogs) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (request.MessageType != MessageType.InterceptedResponse || - request.Context is null || - request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) - { - continue; - } - - if (!Configuration.IncludeOptionsRequests && - string.Equals(request.Context.Session.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.Request.RequestUri); - continue; - } - - var methodAndUrlString = request.Message; - Logger.LogDebug("Processing request {MethodAndUrlString}...", methodAndUrlString); - - try - { - var pathItem = GetOpenApiPathItem(request.Context.Session); - var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.Request.RequestUri); - if (pathItem.Operations is null) - { - Logger.LogDebug("No operations found for request {MethodAndUrlString}. Skipping...", methodAndUrlString); - continue; - } - var operationInfo = pathItem.Operations.First(); - operationInfo.Value.OperationId = await GetOperationIdAsync( - operationInfo.Key.ToString(), - request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath, - cancellationToken - ); - operationInfo.Value.Description = await GetOperationDescriptionAsync( - operationInfo.Key.ToString(), - request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath, - cancellationToken - ); - AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.Request.RequestUri, parametrizedPath); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error processing request {MethodAndUrl}", methodAndUrlString); - } - } - - Logger.LogDebug("Serializing OpenAPI docs..."); - var generatedOpenApiSpecs = new Dictionary(); - foreach (var openApiDoc in openApiDocs) - { - if (openApiDoc.Servers is null || !openApiDoc.Servers.Any()) - { - Logger.LogDebug("OpenAPI doc has no servers. Skipping..."); - continue; - } - var server = openApiDoc.Servers.First(); - if (string.IsNullOrEmpty(server.Url)) - { - Logger.LogDebug("Server URL is null or empty. Skipping..."); - continue; - } - var fileName = GetFileNameFromServerUrl(server.Url, Configuration.SpecFormat); - - var openApiSpecVersion = Configuration.SpecVersion switch - { - SpecVersion.v2_0 => OpenApiSpecVersion.OpenApi2_0, - SpecVersion.v3_0 => OpenApiSpecVersion.OpenApi3_0, - SpecVersion.v3_1 => OpenApiSpecVersion.OpenApi3_1, - SpecVersion.v3_2 => OpenApiSpecVersion.OpenApi3_2, - _ => OpenApiSpecVersion.OpenApi3_0 - }; - - var docString = Configuration.SpecFormat switch - { - SpecFormat.Json => await openApiDoc.SerializeAsJsonAsync(openApiSpecVersion, cancellationToken), - SpecFormat.Yaml => await openApiDoc.SerializeAsYamlAsync(openApiSpecVersion, cancellationToken), - _ => await openApiDoc.SerializeAsJsonAsync(openApiSpecVersion, cancellationToken) - }; - - Logger.LogDebug(" Writing OpenAPI spec to {FileName}...", fileName); - await File.WriteAllTextAsync(fileName, docString, cancellationToken); - - generatedOpenApiSpecs.Add(server.Url, fileName); - - Logger.LogInformation("Created OpenAPI spec file {FileName}", fileName); - } - - StoreReport(new OpenApiSpecGeneratorPluginReport( - generatedOpenApiSpecs - .Select(kvp => new OpenApiSpecGeneratorPluginReportItem - { - ServerUrl = kvp.Key, - FileName = kvp.Value - })), e); - - // store the generated OpenAPI specs in the global data - // for use by other plugins - e.GlobalData[GeneratedOpenApiSpecsKey] = generatedOpenApiSpecs; - - Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); - } - - private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath, CancellationToken cancellationToken) - { - var id = await languageModelClient.GenerateChatCompletionAsync("api_operation_id", new() - { - { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } - }, cancellationToken); - return id?.Response ?? $"{method}{parametrizedPath.Replace('/', '.')}"; - } - - private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath, CancellationToken cancellationToken) - { - var description = await languageModelClient.GenerateChatCompletionAsync("api_operation_description", new() - { - { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } - }, - cancellationToken); - return description?.Response ?? $"{method} {parametrizedPath}"; - } - - /** - * Creates an OpenAPI PathItem from an intercepted request and response pair. - * @param session The intercepted session. - */ - private OpenApiPathItem GetOpenApiPathItem(IProxySession session) - { - var request = session.Request; - var response = session.Response!; - - var resource = GetLastNonTokenSegment(request.RequestUri.Segments); - var path = new OpenApiPathItem - { - Operations = [] - }; - - var method = request.Method?.ToUpperInvariant() switch - { - "DELETE" => HttpMethod.Delete, - "GET" => HttpMethod.Get, - "HEAD" => HttpMethod.Head, - "OPTIONS" => HttpMethod.Options, - "PATCH" => HttpMethod.Patch, - "POST" => HttpMethod.Post, - "PUT" => HttpMethod.Put, - "QUERY" => HttpMethod.Query, - "TRACE" => HttpMethod.Trace, - _ => throw new NotSupportedException($"Method {request.Method} is not supported") - }; - var operation = new OpenApiOperation - { - // will be replaced later after the path has been parametrized - Description = $"{method} {resource}", - // will be replaced later after the path has been parametrized - OperationId = $"{method}.{resource}" - }; - SetParametersFromQueryString(operation, HttpUtility.ParseQueryString(request.RequestUri.Query)); - SetParametersFromRequestHeaders(operation, request.Headers); - SetRequestBody(operation, request); - SetResponseFromSession(operation, response); - - path.Operations?.Add(method, operation); - - return path; - } - - private void SetRequestBody(OpenApiOperation operation, IHttpRequest request) - { - if (!request.HasBody) - { - Logger.LogDebug(" Request has no body"); - return; - } - - if (request.ContentType is null) - { - Logger.LogDebug(" Request has no content type"); - return; - } - - Logger.LogDebug(" Processing request body..."); - operation.RequestBody = new OpenApiRequestBody - { - Content = new Dictionary - { - { - GetMediaType(request.ContentType), - new OpenApiMediaType - { - Schema = GetSchemaFromBody(GetMediaType(request.ContentType), request.BodyString) - } - } - } - }; - } - - private void SetParametersFromRequestHeaders(OpenApiOperation operation, IHeaderCollection headers) - { - if (headers is null || - headers.Count == 0) - { - Logger.LogDebug(" Request has no headers"); - return; - } - - Logger.LogDebug(" Processing request headers..."); - foreach (var header in headers) - { - var lowerCaseHeaderName = header.Name.ToLowerInvariant(); - if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name); - continue; - } - - if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name); - continue; - } - - operation.Parameters?.Add(new OpenApiParameter() - { - Name = header.Name, - In = ParameterLocation.Header, - Required = false, - Schema = new OpenApiSchema() { Type = JsonSchemaType.String } - }); - Logger.LogDebug(" Added header {HeaderName}", header.Name); - } - } - - private void SetParametersFromQueryString(OpenApiOperation operation, NameValueCollection queryParams) - { - if (queryParams.AllKeys is null || - queryParams.AllKeys.Length == 0) - { - Logger.LogDebug(" Request has no query string parameters"); - return; - } - - Logger.LogDebug(" Processing query string parameters..."); - var dictionary = queryParams.AllKeys - .Where(k => k is not null).Cast() - .ToDictionary(k => k, k => queryParams[k] as object); - - foreach (var (key, value) in dictionary) - { - var isRequired = Configuration.IncludeParameters.Any(p => string.Equals(p, key, StringComparison.Ordinal)); - - OpenApiParameter parameter = new() - { - Name = key, - In = ParameterLocation.Query, - Required = isRequired, - Schema = new OpenApiSchema() { Type = JsonSchemaType.String } - }; - SetParameterDefault(parameter, value); - - operation.Parameters?.Add(parameter); - Logger.LogDebug(" Added query string parameter {ParameterKey}", key); - } - } - - private static void SetParameterDefault(OpenApiParameter parameter, object? value) - { - if (!parameter.Required || value is null) - { - return; - } - if (parameter.Schema is OpenApiSchema schema) - { - schema.Default = JsonValue.Create(value.ToString()); - } - } - - private void SetResponseFromSession(OpenApiOperation operation, IHttpResponse response) - { - if (response is null) - { - Logger.LogDebug(" No response to process"); - return; - } - - Logger.LogDebug(" Processing response..."); - - var openApiResponse = new OpenApiResponse - { - Description = response.StatusDescription - }; - var responseCode = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture); - if (response.HasBody) - { - Logger.LogDebug(" Response has body"); - - openApiResponse.Content?.Add(GetMediaType(response.ContentType), new OpenApiMediaType() - { - Schema = GetSchemaFromBody(GetMediaType(response.ContentType), response.BodyString) - }); - } - else - { - Logger.LogDebug(" Response doesn't have body"); - } - - if (response.Headers is not null && response.Headers.Count > 0) - { - Logger.LogDebug(" Response has headers"); - - foreach (var header in response.Headers) - { - var lowerCaseHeaderName = header.Name.ToLowerInvariant(); - if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name); - continue; - } - - if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name); - continue; - } - - if (openApiResponse.Headers?.ContainsKey(header.Name) == true) - { - Logger.LogDebug(" Header {HeaderName} already exists in response", header.Name); - continue; - } - - openApiResponse.Headers?.Add(header.Name, new OpenApiHeader() - { - Schema = new OpenApiSchema() { Type = JsonSchemaType.String } - }); - Logger.LogDebug(" Added header {HeaderName}", header.Name); - } - } - else - { - Logger.LogDebug(" Response doesn't have headers"); - } - - operation.Responses?.Add(responseCode, openApiResponse); - } - - private OpenApiSchema? GetSchemaFromBody(string? contentType, string body) - { - if (contentType is null) - { - Logger.LogDebug(" No content type to process"); - return null; - } - - if (contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug(" Processing JSON body..."); - if (Configuration.IgnoreResponseTypes) - { - Logger.LogDebug(" Ignoring response types"); - return new() - { - Type = JsonSchemaType.String - }; - } - - return GetSchemaFromJsonString(body); - } - - return null; - } - - private void AddOrMergePathItem(List openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) - { - var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); - var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers?.Any(s => s.Url == serverUrl) == true); - - if (openApiDoc is null) - { - Logger.LogDebug(" Creating OpenAPI spec for {ServerUrl}...", serverUrl); - - openApiDoc = new OpenApiDocument - { - Info = new() - { - Version = "v1.0", - Title = $"{serverUrl} API", - Description = $"{serverUrl} API", - }, - Servers = - [ - new() { Url = serverUrl } - ], - Paths = [], - Extensions = new Dictionary - { - { "x-ms-generated-by", new GeneratedByOpenApiExtension() } - } - }; - openApiDocs.Add(openApiDoc); - } - else - { - Logger.LogDebug(" Found OpenAPI spec for {ServerUrl}...", serverUrl); - } - - if (!openApiDoc.Paths.TryGetValue(parametrizedPath, out var value)) - { - Logger.LogDebug(" Adding path {ParametrizedPath} to OpenAPI spec...", parametrizedPath); - value = pathItem; - openApiDoc.Paths.Add(parametrizedPath, value); - // since we've just added the path, we're done - return; - } - - Logger.LogDebug(" Merging path {ParametrizedPath} into OpenAPI spec...", parametrizedPath); - var operation = pathItem.Operations?.First(); - if (operation is not null) - { - AddOrMergeOperation((OpenApiPathItem)value, operation.Value.Key, operation.Value.Value); - } - } - - private void AddOrMergeOperation(OpenApiPathItem pathItem, HttpMethod httpMethod, OpenApiOperation apiOperation) - { - if (pathItem.Operations?.TryGetValue(httpMethod, out var value) != true) - { - Logger.LogDebug(" Adding operation {OperationType} to path...", httpMethod); - - pathItem.AddOperation(httpMethod, apiOperation); - // since we've just added the operation, we're done - return; - } - - Logger.LogDebug(" Merging operation {OperationType} into path...", httpMethod); - - var operation = value!; - - AddOrMergeParameters(operation, apiOperation.Parameters); - AddOrMergeRequestBody(operation, (OpenApiRequestBody?)apiOperation.RequestBody); - AddOrMergeResponse(operation, apiOperation.Responses); - } - - private void AddOrMergeParameters(OpenApiOperation operation, IList? parameters) - { - if (parameters is null || !parameters.Any()) - { - Logger.LogDebug(" No parameters to process"); - return; - } - - Logger.LogDebug(" Processing parameters for operation..."); - - foreach (var parameter in parameters) - { - var paramFromOperation = operation.Parameters?.FirstOrDefault(p => p.Name == parameter.Name && p.In == parameter.In); - if (paramFromOperation is null) - { - Logger.LogDebug(" Adding parameter {ParameterName} to operation...", parameter.Name); - operation.Parameters?.Add(parameter); - continue; - } - - Logger.LogDebug(" Merging parameter {ParameterName}...", parameter.Name); - MergeSchema(parameter?.Schema, paramFromOperation.Schema); - } - } - - private void MergeSchema(IOpenApiSchema? source, IOpenApiSchema? target) - { - if (source is null || target is null) - { - Logger.LogDebug(" Source or target is null. Skipping..."); - return; - } - - if (source.Type != JsonSchemaType.Object || target.Type != JsonSchemaType.Object) - { - Logger.LogDebug(" Source or target schema is not an object. Skipping..."); - return; - } - - if (source.Properties is null || !source.Properties.Any()) - { - Logger.LogDebug(" Source has no properties. Skipping..."); - return; - } - - if (target.Properties is null || !target.Properties.Any()) - { - Logger.LogDebug(" Target has no properties. Skipping..."); - return; - } - - foreach (var property in source.Properties) - { - var propertyFromTarget = target.Properties.FirstOrDefault(p => p.Key == property.Key); - if (propertyFromTarget.Value is null) - { - Logger.LogDebug(" Adding property {PropertyKey} to schema...", property.Key); - target.Properties.Add(property); - continue; - } - - if (property.Value.Type != JsonSchemaType.Object) - { - Logger.LogDebug(" Property already found but is not an object. Skipping..."); - continue; - } - - Logger.LogDebug(" Merging property {PropertyKey}...", property.Key); - MergeSchema(property.Value, propertyFromTarget.Value); - } - } - - private void AddOrMergeRequestBody(OpenApiOperation operation, OpenApiRequestBody? requestBody) - { - if (requestBody is null || requestBody.Content is null || !requestBody.Content.Any()) - { - Logger.LogDebug(" No request body to process"); - return; - } - - var requestBodyType = requestBody.Content.FirstOrDefault().Key; - IOpenApiMediaType? bodyFromOperation = null; - if (operation.RequestBody?.Content is not null) - { - _ = operation.RequestBody.Content.TryGetValue(requestBodyType, out bodyFromOperation); - } - - if (bodyFromOperation is null) - { - Logger.LogDebug(" Adding request body to operation..."); - - operation.RequestBody ??= new OpenApiRequestBody - { - Content = new Dictionary() - }; - operation.RequestBody.Content!.Add(requestBody.Content.FirstOrDefault()); - // since we've just added the request body, we're done - return; - } - - Logger.LogDebug(" Merging request body into operation..."); - MergeSchema(bodyFromOperation.Schema, requestBody.Content.FirstOrDefault().Value.Schema); - } - - private void AddOrMergeResponse(OpenApiOperation operation, OpenApiResponses? apiResponses) - { - if (apiResponses is null) - { - Logger.LogDebug(" No response to process"); - return; - } - - var apiResponseInfo = apiResponses.FirstOrDefault(); - var apiResponseStatusCode = apiResponseInfo.Key; - var apiResponse = apiResponseInfo.Value; - IOpenApiResponse? responseFromOperation = null; - if (operation.Responses is not null) - { - _ = operation.Responses.TryGetValue(apiResponseStatusCode, out responseFromOperation); - } - - if (responseFromOperation is null) - { - Logger.LogDebug(" Adding response {ApiResponseStatusCode} to operation...", apiResponseStatusCode); - - operation.Responses?.Add(apiResponseStatusCode, apiResponse); - // since we've just added the response, we're done - return; - } - - if (apiResponse.Content is null || !apiResponse.Content.Any()) - { - Logger.LogDebug(" No response content to process"); - return; - } - - var apiResponseContentType = apiResponse.Content.First().Key; - IOpenApiMediaType? contentFromOperation = null; - if (responseFromOperation.Content is not null) - { - _ = responseFromOperation.Content.TryGetValue(apiResponseContentType, out contentFromOperation); - } - - if (contentFromOperation is null) - { - Logger.LogDebug(" Adding response {ApiResponseContentType} to {ApiResponseStatusCode} to response...", apiResponseContentType, apiResponseStatusCode); - - responseFromOperation.Content?.Add(apiResponse.Content.First()); - // since we've just added the content, we're done - return; - } - - Logger.LogDebug(" Merging response {ApiResponseStatusCode}/{ApiResponseContentType} into operation...", apiResponseStatusCode, apiResponseContentType); - MergeSchema(contentFromOperation.Schema, apiResponse.Content.First().Value.Schema); - } - - /** - * Replaces segments in the request URI, that match predefined patters, - * with parameters and adds them to the OpenAPI PathItem. - * @param pathItem The OpenAPI PathItem to parametrize. - * @param requestUri The request URI. - * @returns The parametrized server-relative URL - */ - private static string ParametrizePath(OpenApiPathItem pathItem, Uri requestUri) - { - var segments = requestUri.Segments; - var previousSegment = "item"; - - for (var i = 0; i < segments.Length; i++) - { - var segment = requestUri.Segments[i].Trim('/'); - if (string.IsNullOrEmpty(segment)) - { - continue; - } - - if (IsParametrizable(segment)) - { - var parameterName = $"{previousSegment}-id"; - segments[i] = $"{{{parameterName}}}{(requestUri.Segments[i].EndsWith('/') ? "/" : "")}"; - - pathItem.Parameters?.Add(new OpenApiParameter - { - Name = parameterName, - In = ParameterLocation.Path, - Required = true, - Schema = new OpenApiSchema { Type = JsonSchemaType.String } - }); - } - else - { - previousSegment = segment; - } - } - - return string.Join(string.Empty, segments); - } - - private static bool IsParametrizable(string segment) - { - return Guid.TryParse(segment.Trim('/'), out _) || - int.TryParse(segment.Trim('/'), out _); - } - - private static string GetLastNonTokenSegment(string[] segments) - { - for (var i = segments.Length - 1; i >= 0; i--) - { - var segment = segments[i].Trim('/'); - if (string.IsNullOrEmpty(segment)) - { - continue; - } - - if (!IsParametrizable(segment)) - { - return segment; - } - } - - return "item"; - } - - private static string GetMediaType(string? contentType) - { - if (string.IsNullOrEmpty(contentType)) - { - return contentType ?? ""; - } - - var mediaType = contentType.Split(';').First().Trim(); - return mediaType; - } - - private static string GetFileNameFromServerUrl(string serverUrl, SpecFormat format) - { - var uri = new Uri(serverUrl); - var ext = format switch - { - SpecFormat.Json => "json", - SpecFormat.Yaml => "yaml", - _ => "json" - }; - var fileName = $"{uri.Host}-{uri.Port}-{DateTime.Now:yyyyMMddHHmmss}.{ext}"; - return fileName; - } - - private static OpenApiSchema GetSchemaFromJsonString(string jsonString) - { - try - { - using var doc = JsonDocument.Parse(jsonString, ProxyUtils.JsonDocumentOptions); - var root = doc.RootElement; - var schema = GetSchemaFromJsonElement(root); - return schema; - } - catch - { - return new() - { - Type = JsonSchemaType.Object - }; - } - } - - private static OpenApiSchema GetSchemaFromJsonElement(JsonElement jsonElement) - { - var schema = new OpenApiSchema(); - -#pragma warning disable IDE0010 - switch (jsonElement.ValueKind) -#pragma warning restore IDE0010 - { - case JsonValueKind.String: - schema.Type = JsonSchemaType.String; - break; - case JsonValueKind.Number: - schema.Type = JsonSchemaType.Number; - break; - case JsonValueKind.True: - case JsonValueKind.False: - schema.Type = JsonSchemaType.Boolean; - break; - case JsonValueKind.Object: - schema.Type = JsonSchemaType.Object; - schema.Properties = jsonElement.EnumerateObject() - .ToDictionary(p => p.Name, p => (IOpenApiSchema)GetSchemaFromJsonElement(p.Value)); - break; - case JsonValueKind.Array: - schema.Type = JsonSchemaType.Array; - schema.Items = GetSchemaFromJsonElement(jsonElement.EnumerateArray().FirstOrDefault()); - break; - default: - schema.Type = JsonSchemaType.Object; - break; - } - - return schema; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.LanguageModel; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using System.Text.Json.Nodes; +using System.Collections.Specialized; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; + +namespace DevProxy.Plugins.Generation; + +sealed class GeneratedByOpenApiExtension : IOpenApiExtension +{ + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + writer.WriteStartObject(); + writer.WriteProperty("toolName", "Dev Proxy"); + writer.WriteProperty("toolVersion", ProxyUtils.ProductVersion); + writer.WriteEndObject(); + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SpecVersion +{ +#pragma warning disable CA1707 + v2_0, + v3_0, + v3_1, + v3_2 +#pragma warning restore CA1707 +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SpecFormat +{ + Json, + Yaml +} + +public sealed class OpenApiSpecGeneratorPluginConfiguration +{ + public bool IncludeOptionsRequests { get; set; } + public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; + public SpecVersion SpecVersion { get; set; } = SpecVersion.v3_0; + public bool IgnoreResponseTypes { get; set; } + public IEnumerable IncludeParameters { get; set; } = []; +} + +public sealed class OpenApiSpecGeneratorPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + ILanguageModelClient languageModelClient, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + public static readonly string GeneratedOpenApiSpecsKey = "GeneratedOpenApiSpecs"; + + public override string Name => nameof(OpenApiSpecGeneratorPlugin); + + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + if (!e.RequestLogs.Any()) + { + Logger.LogDebug("No requests to process"); + return; + } + + Logger.LogInformation("Creating OpenAPI spec from recorded requests..."); + + var openApiDocs = new List(); + + foreach (var request in e.RequestLogs) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (request.MessageType != MessageType.InterceptedResponse || + request.Context is null || + request.Context.Session is null || + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) + { + continue; + } + + if (!Configuration.IncludeOptionsRequests && + string.Equals(request.Context.Session.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.Request.RequestUri); + continue; + } + + var methodAndUrlString = request.Message; + Logger.LogDebug("Processing request {MethodAndUrlString}...", methodAndUrlString); + + try + { + var detectedAuth = AuthDetector.Detect(request.Context.Session.Request, Logger); + var pathItem = GetOpenApiPathItem(request.Context.Session, detectedAuth); + var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.Request.RequestUri); + if (pathItem.Operations is null) + { + Logger.LogDebug("No operations found for request {MethodAndUrlString}. Skipping...", methodAndUrlString); + continue; + } + var operationInfo = pathItem.Operations.First(); + operationInfo.Value.OperationId = await GetOperationIdAsync( + operationInfo.Key.ToString(), + request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath, + cancellationToken + ); + operationInfo.Value.Description = await GetOperationDescriptionAsync( + operationInfo.Key.ToString(), + request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath, + cancellationToken + ); + AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.Request.RequestUri, parametrizedPath, detectedAuth); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing request {MethodAndUrl}", methodAndUrlString); + } + } + + Logger.LogDebug("Serializing OpenAPI docs..."); + var generatedOpenApiSpecs = new Dictionary(); + foreach (var openApiDoc in openApiDocs) + { + if (openApiDoc.Servers is null || !openApiDoc.Servers.Any()) + { + Logger.LogDebug("OpenAPI doc has no servers. Skipping..."); + continue; + } + var server = openApiDoc.Servers.First(); + if (string.IsNullOrEmpty(server.Url)) + { + Logger.LogDebug("Server URL is null or empty. Skipping..."); + continue; + } + var fileName = GetFileNameFromServerUrl(server.Url, Configuration.SpecFormat); + + var openApiSpecVersion = Configuration.SpecVersion switch + { + SpecVersion.v2_0 => OpenApiSpecVersion.OpenApi2_0, + SpecVersion.v3_0 => OpenApiSpecVersion.OpenApi3_0, + SpecVersion.v3_1 => OpenApiSpecVersion.OpenApi3_1, + SpecVersion.v3_2 => OpenApiSpecVersion.OpenApi3_2, + _ => OpenApiSpecVersion.OpenApi3_0 + }; + + var docString = Configuration.SpecFormat switch + { + SpecFormat.Json => await openApiDoc.SerializeAsJsonAsync(openApiSpecVersion, cancellationToken), + SpecFormat.Yaml => await openApiDoc.SerializeAsYamlAsync(openApiSpecVersion, cancellationToken), + _ => await openApiDoc.SerializeAsJsonAsync(openApiSpecVersion, cancellationToken) + }; + + Logger.LogDebug(" Writing OpenAPI spec to {FileName}...", fileName); + await File.WriteAllTextAsync(fileName, docString, cancellationToken); + + generatedOpenApiSpecs.Add(server.Url, fileName); + + Logger.LogInformation("Created OpenAPI spec file {FileName}", fileName); + } + + StoreReport(new OpenApiSpecGeneratorPluginReport( + generatedOpenApiSpecs + .Select(kvp => new OpenApiSpecGeneratorPluginReportItem + { + ServerUrl = kvp.Key, + FileName = kvp.Value + })), e); + + // store the generated OpenAPI specs in the global data + // for use by other plugins + e.GlobalData[GeneratedOpenApiSpecsKey] = generatedOpenApiSpecs; + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + } + + private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath, CancellationToken cancellationToken) + { + var id = await languageModelClient.GenerateChatCompletionAsync("api_operation_id", new() + { + { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } + }, cancellationToken); + return id?.Response ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + } + + private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath, CancellationToken cancellationToken) + { + var description = await languageModelClient.GenerateChatCompletionAsync("api_operation_description", new() + { + { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } + }, + cancellationToken); + return description?.Response ?? $"{method} {parametrizedPath}"; + } + + /** + * Creates an OpenAPI PathItem from an intercepted request and response pair. + * @param session The intercepted session. + */ + private OpenApiPathItem GetOpenApiPathItem(IProxySession session, DetectedAuth? detectedAuth) + { + var request = session.Request; + var response = session.Response!; + + var resource = GetLastNonTokenSegment(request.RequestUri.Segments); + var path = new OpenApiPathItem + { + Operations = [] + }; + + var method = request.Method?.ToUpperInvariant() switch + { + "DELETE" => HttpMethod.Delete, + "GET" => HttpMethod.Get, + "HEAD" => HttpMethod.Head, + "OPTIONS" => HttpMethod.Options, + "PATCH" => HttpMethod.Patch, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "QUERY" => HttpMethod.Query, + "TRACE" => HttpMethod.Trace, + _ => throw new NotSupportedException($"Method {request.Method} is not supported") + }; + var operation = new OpenApiOperation + { + // will be replaced later after the path has been parametrized + Description = $"{method} {resource}", + // will be replaced later after the path has been parametrized + OperationId = $"{method}.{resource}" + }; + var authQueryParameter = detectedAuth is DetectedApiKeyAuth { In: DetectedApiKeyLocation.Query } apiKeyAuth ? + apiKeyAuth.Name : null; + SetParametersFromQueryString(operation, HttpUtility.ParseQueryString(request.RequestUri.Query), authQueryParameter); + SetParametersFromRequestHeaders(operation, request.Headers); + SetRequestBody(operation, request); + SetResponseFromSession(operation, response); + + path.Operations?.Add(method, operation); + + return path; + } + + private void SetRequestBody(OpenApiOperation operation, IHttpRequest request) + { + if (!request.HasBody) + { + Logger.LogDebug(" Request has no body"); + return; + } + + if (request.ContentType is null) + { + Logger.LogDebug(" Request has no content type"); + return; + } + + Logger.LogDebug(" Processing request body..."); + operation.RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + { + GetMediaType(request.ContentType), + new OpenApiMediaType + { + Schema = GetSchemaFromBody(GetMediaType(request.ContentType), request.BodyString) + } + } + } + }; + } + + private void SetParametersFromRequestHeaders(OpenApiOperation operation, IHeaderCollection headers) + { + if (headers is null || + headers.Count == 0) + { + Logger.LogDebug(" Request has no headers"); + return; + } + + Logger.LogDebug(" Processing request headers..."); + foreach (var header in headers) + { + var lowerCaseHeaderName = header.Name.ToLowerInvariant(); + if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name); + continue; + } + + if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name); + continue; + } + + operation.Parameters?.Add(new OpenApiParameter() + { + Name = header.Name, + In = ParameterLocation.Header, + Required = false, + Schema = new OpenApiSchema() { Type = JsonSchemaType.String } + }); + Logger.LogDebug(" Added header {HeaderName}", header.Name); + } + } + + private void SetParametersFromQueryString(OpenApiOperation operation, NameValueCollection queryParams, string? authQueryParameter) + { + if (queryParams.AllKeys is null || + queryParams.AllKeys.Length == 0) + { + Logger.LogDebug(" Request has no query string parameters"); + return; + } + + Logger.LogDebug(" Processing query string parameters..."); + var dictionary = queryParams.AllKeys + .Where(k => k is not null).Cast() + .ToDictionary(k => k, k => queryParams[k] as object); + + foreach (var (key, value) in dictionary) + { + if (string.Equals(key, authQueryParameter, StringComparison.Ordinal)) + { + Logger.LogDebug(" Skipping auth query string parameter {ParameterKey}", key); + continue; + } + + var isRequired = Configuration.IncludeParameters.Any(p => string.Equals(p, key, StringComparison.Ordinal)); + + OpenApiParameter parameter = new() + { + Name = key, + In = ParameterLocation.Query, + Required = isRequired, + Schema = new OpenApiSchema() { Type = JsonSchemaType.String } + }; + SetParameterDefault(parameter, value); + + operation.Parameters?.Add(parameter); + Logger.LogDebug(" Added query string parameter {ParameterKey}", key); + } + } + + private static void SetParameterDefault(OpenApiParameter parameter, object? value) + { + if (!parameter.Required || value is null) + { + return; + } + if (parameter.Schema is OpenApiSchema schema) + { + schema.Default = JsonValue.Create(value.ToString()); + } + } + + private void SetResponseFromSession(OpenApiOperation operation, IHttpResponse response) + { + if (response is null) + { + Logger.LogDebug(" No response to process"); + return; + } + + Logger.LogDebug(" Processing response..."); + + var openApiResponse = new OpenApiResponse + { + Description = response.StatusDescription + }; + var responseCode = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture); + if (response.HasBody) + { + Logger.LogDebug(" Response has body"); + + openApiResponse.Content?.Add(GetMediaType(response.ContentType), new OpenApiMediaType() + { + Schema = GetSchemaFromBody(GetMediaType(response.ContentType), response.BodyString) + }); + } + else + { + Logger.LogDebug(" Response doesn't have body"); + } + + if (response.Headers is not null && response.Headers.Count > 0) + { + Logger.LogDebug(" Response has headers"); + + foreach (var header in response.Headers) + { + var lowerCaseHeaderName = header.Name.ToLowerInvariant(); + if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name); + continue; + } + + if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name); + continue; + } + + if (openApiResponse.Headers?.ContainsKey(header.Name) == true) + { + Logger.LogDebug(" Header {HeaderName} already exists in response", header.Name); + continue; + } + + openApiResponse.Headers?.Add(header.Name, new OpenApiHeader() + { + Schema = new OpenApiSchema() { Type = JsonSchemaType.String } + }); + Logger.LogDebug(" Added header {HeaderName}", header.Name); + } + } + else + { + Logger.LogDebug(" Response doesn't have headers"); + } + + operation.Responses?.Add(responseCode, openApiResponse); + } + + private OpenApiSchema? GetSchemaFromBody(string? contentType, string body) + { + if (contentType is null) + { + Logger.LogDebug(" No content type to process"); + return null; + } + + if (contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug(" Processing JSON body..."); + if (Configuration.IgnoreResponseTypes) + { + Logger.LogDebug(" Ignoring response types"); + return new() + { + Type = JsonSchemaType.String + }; + } + + return GetSchemaFromJsonString(body); + } + + return null; + } + + private void AddOrMergePathItem(List openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath, DetectedAuth? detectedAuth) + { + var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); + var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers?.Any(s => s.Url == serverUrl) == true); + + if (openApiDoc is null) + { + Logger.LogDebug(" Creating OpenAPI spec for {ServerUrl}...", serverUrl); + + openApiDoc = new OpenApiDocument + { + Info = new() + { + Version = "v1.0", + Title = $"{serverUrl} API", + Description = $"{serverUrl} API", + }, + Servers = + [ + new() { Url = serverUrl } + ], + Paths = [], + Extensions = new Dictionary + { + { "x-ms-generated-by", new GeneratedByOpenApiExtension() } + } + }; + openApiDocs.Add(openApiDoc); + } + else + { + Logger.LogDebug(" Found OpenAPI spec for {ServerUrl}...", serverUrl); + } + + if (!openApiDoc.Paths.TryGetValue(parametrizedPath, out var value)) + { + Logger.LogDebug(" Adding path {ParametrizedPath} to OpenAPI spec...", parametrizedPath); + value = pathItem; + openApiDoc.Paths.Add(parametrizedPath, value); + } + else + { + Logger.LogDebug(" Merging path {ParametrizedPath} into OpenAPI spec...", parametrizedPath); + var newOperation = pathItem.Operations?.First(); + if (newOperation is not null) + { + AddOrMergeOperation((OpenApiPathItem)value, newOperation.Value.Key, newOperation.Value.Value); + } + } + + var method = pathItem.Operations?.FirstOrDefault().Key; + if (detectedAuth is not null && + method is not null && + value.Operations?.TryGetValue(method, out var operation) == true) + { + AddOrMergeSecurity(openApiDoc, operation, detectedAuth); + } + } + + private void AddOrMergeSecurity(OpenApiDocument openApiDoc, OpenApiOperation operation, DetectedAuth detectedAuth) + { + var (schemeName, scopes) = AddOrMergeSecurityScheme(openApiDoc, detectedAuth); + + operation.Security ??= []; + var existingRequirement = operation.Security + .SelectMany(r => r) + .FirstOrDefault(r => r.Key.Reference.Id == schemeName); + if (existingRequirement.Key is null) + { + Logger.LogDebug(" Adding security requirement {SchemeName} to operation...", schemeName); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(schemeName, openApiDoc)] = [.. scopes] + }); + return; + } + + Logger.LogDebug(" Merging security requirement {SchemeName} into operation...", schemeName); + foreach (var scope in scopes.Where(s => !existingRequirement.Value.Contains(s))) + { + existingRequirement.Value.Add(scope); + } + } + + private (string SchemeName, IReadOnlyList Scopes) AddOrMergeSecurityScheme(OpenApiDocument openApiDoc, DetectedAuth detectedAuth) + { + var (schemeName, scheme, scopes) = detectedAuth switch + { + DetectedApiKeyAuth apiKey => ( + GetApiKeySchemeName(apiKey), + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + Name = apiKey.Name, + In = apiKey.In == DetectedApiKeyLocation.Query ? ParameterLocation.Query : ParameterLocation.Header + }, + []), + DetectedBearerAuth => ( + "BearerAuth", + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer" + }, + []), + DetectedOAuth2Auth oauth2 => ( + oauth2.Name, + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows() + }, + oauth2.Scopes), + _ => throw new NotSupportedException($"Auth type {detectedAuth.GetType().Name} is not supported") + }; + + openApiDoc.Components ??= new OpenApiComponents(); + openApiDoc.Components.SecuritySchemes ??= new Dictionary(); + if (openApiDoc.Components.SecuritySchemes.TryGetValue(schemeName, out var existingScheme)) + { + Logger.LogDebug(" Found security scheme {SchemeName}", schemeName); + scheme = existingScheme as OpenApiSecurityScheme ?? scheme; + } + else + { + Logger.LogDebug(" Adding security scheme {SchemeName}...", schemeName); + _ = openApiDoc.AddComponent(schemeName, scheme); + } + + if (detectedAuth is DetectedOAuth2Auth oauth2Auth) + { + MergeOAuth2Flow(scheme, oauth2Auth); + } + + return (schemeName, scopes); + } + + private static void MergeOAuth2Flow(OpenApiSecurityScheme scheme, DetectedOAuth2Auth oauth2) + { + scheme.Flows ??= new OpenApiOAuthFlows(); + var flow = oauth2.FlowType == DetectedOAuth2FlowType.ClientCredentials ? + scheme.Flows.ClientCredentials ??= new OpenApiOAuthFlow() : + scheme.Flows.AuthorizationCode ??= new OpenApiOAuthFlow(); + + if (oauth2.FlowType == DetectedOAuth2FlowType.AuthorizationCode) + { + flow.AuthorizationUrl ??= ToAbsoluteUri(oauth2.AuthorizationUrl); + } + flow.TokenUrl ??= ToAbsoluteUri(oauth2.TokenUrl); + flow.RefreshUrl ??= ToAbsoluteUri(oauth2.RefreshUrl); + flow.Scopes ??= new Dictionary(); + foreach (var scope in oauth2.Scopes) + { + _ = flow.Scopes.TryAdd(scope, scope); + } + } + + private static Uri? ToAbsoluteUri(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null; + + private static string GetApiKeySchemeName(DetectedApiKeyAuth apiKey) + { + var location = apiKey.In == DetectedApiKeyLocation.Query ? "query" : "header"; + var name = new string([.. apiKey.Name.Select(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '_')]); + return $"ApiKeyAuth_{location}_{name}"; + } + + private void AddOrMergeOperation(OpenApiPathItem pathItem, HttpMethod httpMethod, OpenApiOperation apiOperation) + { + if (pathItem.Operations?.TryGetValue(httpMethod, out var value) != true) + { + Logger.LogDebug(" Adding operation {OperationType} to path...", httpMethod); + + pathItem.AddOperation(httpMethod, apiOperation); + // since we've just added the operation, we're done + return; + } + + Logger.LogDebug(" Merging operation {OperationType} into path...", httpMethod); + + var operation = value!; + + AddOrMergeParameters(operation, apiOperation.Parameters); + AddOrMergeRequestBody(operation, (OpenApiRequestBody?)apiOperation.RequestBody); + AddOrMergeResponse(operation, apiOperation.Responses); + } + + private void AddOrMergeParameters(OpenApiOperation operation, IList? parameters) + { + if (parameters is null || !parameters.Any()) + { + Logger.LogDebug(" No parameters to process"); + return; + } + + Logger.LogDebug(" Processing parameters for operation..."); + + foreach (var parameter in parameters) + { + var paramFromOperation = operation.Parameters?.FirstOrDefault(p => p.Name == parameter.Name && p.In == parameter.In); + if (paramFromOperation is null) + { + Logger.LogDebug(" Adding parameter {ParameterName} to operation...", parameter.Name); + operation.Parameters?.Add(parameter); + continue; + } + + Logger.LogDebug(" Merging parameter {ParameterName}...", parameter.Name); + MergeSchema(parameter?.Schema, paramFromOperation.Schema); + } + } + + private void MergeSchema(IOpenApiSchema? source, IOpenApiSchema? target) + { + if (source is null || target is null) + { + Logger.LogDebug(" Source or target is null. Skipping..."); + return; + } + + if (source.Type != JsonSchemaType.Object || target.Type != JsonSchemaType.Object) + { + Logger.LogDebug(" Source or target schema is not an object. Skipping..."); + return; + } + + if (source.Properties is null || !source.Properties.Any()) + { + Logger.LogDebug(" Source has no properties. Skipping..."); + return; + } + + if (target.Properties is null || !target.Properties.Any()) + { + Logger.LogDebug(" Target has no properties. Skipping..."); + return; + } + + foreach (var property in source.Properties) + { + var propertyFromTarget = target.Properties.FirstOrDefault(p => p.Key == property.Key); + if (propertyFromTarget.Value is null) + { + Logger.LogDebug(" Adding property {PropertyKey} to schema...", property.Key); + target.Properties.Add(property); + continue; + } + + if (property.Value.Type != JsonSchemaType.Object) + { + Logger.LogDebug(" Property already found but is not an object. Skipping..."); + continue; + } + + Logger.LogDebug(" Merging property {PropertyKey}...", property.Key); + MergeSchema(property.Value, propertyFromTarget.Value); + } + } + + private void AddOrMergeRequestBody(OpenApiOperation operation, OpenApiRequestBody? requestBody) + { + if (requestBody is null || requestBody.Content is null || !requestBody.Content.Any()) + { + Logger.LogDebug(" No request body to process"); + return; + } + + var requestBodyType = requestBody.Content.FirstOrDefault().Key; + IOpenApiMediaType? bodyFromOperation = null; + if (operation.RequestBody?.Content is not null) + { + _ = operation.RequestBody.Content.TryGetValue(requestBodyType, out bodyFromOperation); + } + + if (bodyFromOperation is null) + { + Logger.LogDebug(" Adding request body to operation..."); + + operation.RequestBody ??= new OpenApiRequestBody + { + Content = new Dictionary() + }; + operation.RequestBody.Content!.Add(requestBody.Content.FirstOrDefault()); + // since we've just added the request body, we're done + return; + } + + Logger.LogDebug(" Merging request body into operation..."); + MergeSchema(bodyFromOperation.Schema, requestBody.Content.FirstOrDefault().Value.Schema); + } + + private void AddOrMergeResponse(OpenApiOperation operation, OpenApiResponses? apiResponses) + { + if (apiResponses is null) + { + Logger.LogDebug(" No response to process"); + return; + } + + var apiResponseInfo = apiResponses.FirstOrDefault(); + var apiResponseStatusCode = apiResponseInfo.Key; + var apiResponse = apiResponseInfo.Value; + IOpenApiResponse? responseFromOperation = null; + if (operation.Responses is not null) + { + _ = operation.Responses.TryGetValue(apiResponseStatusCode, out responseFromOperation); + } + + if (responseFromOperation is null) + { + Logger.LogDebug(" Adding response {ApiResponseStatusCode} to operation...", apiResponseStatusCode); + + operation.Responses?.Add(apiResponseStatusCode, apiResponse); + // since we've just added the response, we're done + return; + } + + if (apiResponse.Content is null || !apiResponse.Content.Any()) + { + Logger.LogDebug(" No response content to process"); + return; + } + + var apiResponseContentType = apiResponse.Content.First().Key; + IOpenApiMediaType? contentFromOperation = null; + if (responseFromOperation.Content is not null) + { + _ = responseFromOperation.Content.TryGetValue(apiResponseContentType, out contentFromOperation); + } + + if (contentFromOperation is null) + { + Logger.LogDebug(" Adding response {ApiResponseContentType} to {ApiResponseStatusCode} to response...", apiResponseContentType, apiResponseStatusCode); + + responseFromOperation.Content?.Add(apiResponse.Content.First()); + // since we've just added the content, we're done + return; + } + + Logger.LogDebug(" Merging response {ApiResponseStatusCode}/{ApiResponseContentType} into operation...", apiResponseStatusCode, apiResponseContentType); + MergeSchema(contentFromOperation.Schema, apiResponse.Content.First().Value.Schema); + } + + /** + * Replaces segments in the request URI, that match predefined patters, + * with parameters and adds them to the OpenAPI PathItem. + * @param pathItem The OpenAPI PathItem to parametrize. + * @param requestUri The request URI. + * @returns The parametrized server-relative URL + */ + private static string ParametrizePath(OpenApiPathItem pathItem, Uri requestUri) + { + var segments = requestUri.Segments; + var previousSegment = "item"; + + for (var i = 0; i < segments.Length; i++) + { + var segment = requestUri.Segments[i].Trim('/'); + if (string.IsNullOrEmpty(segment)) + { + continue; + } + + if (IsParametrizable(segment)) + { + var parameterName = $"{previousSegment}-id"; + segments[i] = $"{{{parameterName}}}{(requestUri.Segments[i].EndsWith('/') ? "/" : "")}"; + + pathItem.Parameters?.Add(new OpenApiParameter + { + Name = parameterName, + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + }); + } + else + { + previousSegment = segment; + } + } + + return string.Join(string.Empty, segments); + } + + private static bool IsParametrizable(string segment) + { + return Guid.TryParse(segment.Trim('/'), out _) || + int.TryParse(segment.Trim('/'), out _); + } + + private static string GetLastNonTokenSegment(string[] segments) + { + for (var i = segments.Length - 1; i >= 0; i--) + { + var segment = segments[i].Trim('/'); + if (string.IsNullOrEmpty(segment)) + { + continue; + } + + if (!IsParametrizable(segment)) + { + return segment; + } + } + + return "item"; + } + + private static string GetMediaType(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return contentType ?? ""; + } + + var mediaType = contentType.Split(';').First().Trim(); + return mediaType; + } + + private static string GetFileNameFromServerUrl(string serverUrl, SpecFormat format) + { + var uri = new Uri(serverUrl); + var ext = format switch + { + SpecFormat.Json => "json", + SpecFormat.Yaml => "yaml", + _ => "json" + }; + var fileName = $"{uri.Host}-{uri.Port}-{DateTime.Now:yyyyMMddHHmmss}.{ext}"; + return fileName; + } + + private static OpenApiSchema GetSchemaFromJsonString(string jsonString) + { + try + { + using var doc = JsonDocument.Parse(jsonString, ProxyUtils.JsonDocumentOptions); + var root = doc.RootElement; + var schema = GetSchemaFromJsonElement(root); + return schema; + } + catch + { + return new() + { + Type = JsonSchemaType.Object + }; + } + } + + private static OpenApiSchema GetSchemaFromJsonElement(JsonElement jsonElement) + { + var schema = new OpenApiSchema(); + +#pragma warning disable IDE0010 + switch (jsonElement.ValueKind) +#pragma warning restore IDE0010 + { + case JsonValueKind.String: + schema.Type = JsonSchemaType.String; + break; + case JsonValueKind.Number: + schema.Type = JsonSchemaType.Number; + break; + case JsonValueKind.True: + case JsonValueKind.False: + schema.Type = JsonSchemaType.Boolean; + break; + case JsonValueKind.Object: + schema.Type = JsonSchemaType.Object; + schema.Properties = jsonElement.EnumerateObject() + .ToDictionary(p => p.Name, p => (IOpenApiSchema)GetSchemaFromJsonElement(p.Value)); + break; + case JsonValueKind.Array: + schema.Type = JsonSchemaType.Array; + schema.Items = GetSchemaFromJsonElement(jsonElement.EnumerateArray().FirstOrDefault()); + break; + default: + schema.Type = JsonSchemaType.Object; + break; + } + + return schema; + } +} diff --git a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs index 5731ac562..095f06d2c 100644 --- a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs @@ -11,7 +11,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Diagnostics; -using System.IdentityModel.Tokens.Jwt; using System.Text.Json; using System.Text.RegularExpressions; using System.Web; @@ -176,157 +175,37 @@ private void ProcessAuth(IHttpRequest httpRequest, TypeSpecFile doc, Operation o { Logger.LogTrace("Entered {Name}", nameof(ProcessAuth)); - var authHeaders = httpRequest.Headers - .Where(h => Models.Http.AuthHeaders.Contains(h.Name.ToLowerInvariant())) - .Select(h => (h.Name, h.Value)); - - foreach (var (name, value) in authHeaders) - { - if (name.Equals("cookie", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!name.Equals("authorization", StringComparison.OrdinalIgnoreCase) || - !value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - AddApiKeyAuth(op, name, ApiKeyLocation.Header); - return; - } - - var bearerToken = value["Bearer ".Length..].Trim(); - AddAuthorizationAuth(op, bearerToken, doc); - return; - } - - var query = HttpUtility.ParseQueryString(httpRequest.RequestUri.Query); - var authQueryParam = query.AllKeys - .FirstOrDefault(k => k is not null && Models.Http.AuthHeaders.Contains(k.ToLowerInvariant())); - if (authQueryParam is not null) - { - Logger.LogDebug("Found auth query parameter: {AuthQueryParam}", authQueryParam); - AddApiKeyAuth(op, authQueryParam, ApiKeyLocation.Query); - } - else +#pragma warning disable IDE0010 + switch (AuthDetector.Detect(httpRequest, Logger)) +#pragma warning restore IDE0010 { - Logger.LogDebug("No auth headers or query parameters found"); - } - - Logger.LogTrace("Left {Name}", nameof(ProcessAuth)); - } - - private void AddAuthorizationAuth(Operation op, string bearerToken, TypeSpecFile doc) - { - Logger.LogTrace("Entered {Name}", nameof(AddAuthorizationAuth)); - - if (IsJwtToken(bearerToken, out var jwtToken)) - { - var issuer = jwtToken.Issuer; - Logger.LogDebug("Issuer: {Issuer}", issuer); - var scopes = jwtToken.Claims - .Where(c => c.Type == "scp") - .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Distinct() - .ToList(); - if (Logger.IsEnabled(LogLevel.Debug)) - { - Logger.LogDebug("Scopes: {Scopes}", string.Join(", ", scopes)); - } - var roles = jwtToken.Claims - .Where(c => c.Type == "roles") - .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Distinct(); - if (Logger.IsEnabled(LogLevel.Debug)) - { - Logger.LogDebug("Roles: {Roles}", string.Join(", ", roles)); - } - scopes.AddRange(roles); - - OAuth2Auth? auth = null; - if (IsEntraToken(issuer)) - { - var version = jwtToken.Claims - .FirstOrDefault(c => c.Type == "ver")?.Value ?? "1.0"; - var baseUrl = issuer.Contains("v2.0", StringComparison.OrdinalIgnoreCase) ? - issuer.Replace("v2.0", "", StringComparison.OrdinalIgnoreCase) : issuer; - auth = new() + case DetectedApiKeyAuth apiKey: + op.Auth = new ApiKeyAuth { - Name = $"EntraOAuth2Auth", - FlowType = roles.Any() ? FlowType.ClientCredentials : FlowType.AuthorizationCode, - TokenUrl = version == "1.0" ? $"{baseUrl}oauth2/token" : $"{baseUrl}oauth2/v2.0/token", - AuthorizationUrl = version == "1.0" ? $"{baseUrl}oauth2/authorize" : $"{baseUrl}oauth2/v2.0/authorize", - RefreshUrl = version == "1.0" ? $"{baseUrl}oauth2/token" : $"{baseUrl}oauth2/v2.0/token", - Scopes = [.. scopes] + Name = apiKey.Name, + In = apiKey.In == DetectedApiKeyLocation.Query ? ApiKeyLocation.Query : ApiKeyLocation.Header }; - } - else - { - auth = new() + break; + case DetectedBearerAuth: + op.Auth = new BearerAuth(); + break; + case DetectedOAuth2Auth oauth2: + var auth = new OAuth2Auth { - Name = $"APIOAuth2Auth", - FlowType = FlowType.AuthorizationCode, - TokenUrl = jwtToken.Issuer, - AuthorizationUrl = jwtToken.Issuer, - Scopes = [.. scopes] + Name = oauth2.Name, + FlowType = oauth2.FlowType == DetectedOAuth2FlowType.ClientCredentials ? + FlowType.ClientCredentials : FlowType.AuthorizationCode, + TokenUrl = oauth2.TokenUrl, + AuthorizationUrl = oauth2.AuthorizationUrl, + RefreshUrl = oauth2.RefreshUrl, + Scopes = [.. oauth2.Scopes] }; - } - doc.Service.Namespace.Auth = auth; - op.Auth = auth; - } - else - { - op.Auth = new BearerAuth(); + doc.Service.Namespace.Auth = auth; + op.Auth = auth; + break; } - Logger.LogTrace("Left {Name}", nameof(AddAuthorizationAuth)); - } - - private bool IsEntraToken(string issuer) - { - Logger.LogTrace("Entered {Name}", nameof(IsEntraToken)); - - var isEntraToken = issuer.Contains("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase) || - issuer.Contains("https://sts.windows.net/", StringComparison.OrdinalIgnoreCase); - - Logger.LogDebug("Is token from Entra? {IsEntraToken}", isEntraToken); - Logger.LogTrace("Left {Name}", nameof(IsEntraToken)); - - return isEntraToken; - } - - private void AddApiKeyAuth(Operation op, string name, ApiKeyLocation location) - { - Logger.LogTrace("Entered {Name}", nameof(AddApiKeyAuth)); - - var apiKeyAuth = new ApiKeyAuth - { - Name = name, - In = location - }; - op.Auth = apiKeyAuth; - - Logger.LogTrace("Left {Name}", nameof(AddApiKeyAuth)); - } - - private bool IsJwtToken(string bearerToken, out JwtSecurityToken jwtToken) - { - Logger.LogTrace("Entered {Name}", nameof(IsJwtToken)); - - jwtToken = new(); - - try - { - jwtToken = new(bearerToken); - Logger.LogTrace("Left {Name}", nameof(IsJwtToken)); - return true; - } - catch (Exception ex) - { - Logger.LogDebug("Failed to parse JWT token: {Ex}", ex.Message); - } - - Logger.LogTrace("Left {Name}", nameof(IsJwtToken)); - return false; + Logger.LogTrace("Left {Name}", nameof(ProcessAuth)); } private async Task ProcessRequestBodyAsync(IHttpRequest httpRequest, TypeSpecFile doc, Operation op, string lastSegment) From 7dff8bf42c6cce2fb846727a3af32bcf6e6d8b13 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:19:08 +0100 Subject: [PATCH 2/6] Address review feedback for OpenAPI auth generation - Normalize Entra v1 issuer to login.microsoftonline.com endpoints - Prefer authorizationCode flow when token has scp claim - Emit separate requirements per OAuth2 flow - Mark auth optional when successful anonymous calls were recorded - Emit http bearer (bearerFormat JWT) for non-Entra JWTs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 98 +++++++++++++++-- DevProxy.Plugins/Generation/AuthDetector.cs | 57 +++++----- .../Generation/OpenApiSpecGeneratorPlugin.cs | 102 ++++++++++++++---- 3 files changed, 201 insertions(+), 56 deletions(-) diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs index 3730856bd..4d4642fda 100644 --- a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -35,10 +35,13 @@ private static RecordingArgs Recording(IEnumerable logs) => }; private static RequestLog Exchange(string url, params (string Name, string Value)[] headers) => + Exchange(url, HttpStatusCode.OK, headers); + + private static RequestLog Exchange(string url, HttpStatusCode statusCode, params (string Name, string Value)[] headers) => TestExchange .Request("GET", url, headers: [("Accept", "application/json"), .. headers]) .WithResponse( - HttpStatusCode.OK, + statusCode, headers: [("Content-Type", "application/json")], body: """[ { "id": 1, "name": "alpha" } ]""") .AsRequestLog(MessageType.InterceptedResponse); @@ -202,23 +205,81 @@ public async Task OpenApiSpecGenerator_EntraAppToken_EmitsClientCredentialsFlow( var scheme = SecurityScheme(doc, "EntraOAuth2Auth"); var flow = scheme["flows"]!["clientCredentials"]!; Assert.Null(flow["authorizationUrl"]); - Assert.Equal($"{EntraIssuerV1}oauth2/token", flow["tokenUrl"]!.GetValue()); + Assert.Equal($"{EntraBaseUrl}oauth2/token", flow["tokenUrl"]!.GetValue()); Assert.Equal(["User.Read.All", "Mail.Read"], flow["scopes"]!.AsObject().Select(s => s.Key)); Assert.Null(scheme["flows"]!["authorizationCode"]); Assert.Equal(["User.Read.All", "Mail.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); } [Fact] - public async Task OpenApiSpecGenerator_GenericJwt_EmitsGenericOAuth2Scheme() + public async Task OpenApiSpecGenerator_EntraTokenWithScopesAndRoles_EmitsAuthorizationCodeFlow() + { + var token = Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read", roles: ["Admin"]); + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + var flows = SecurityScheme(doc, "EntraOAuth2Auth")["flows"]!; + Assert.NotNull(flows["authorizationCode"]); + Assert.Null(flows["clientCredentials"]); + Assert.Equal(["User.Read", "Admin"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + } + + [Fact] + public async Task OpenApiSpecGenerator_NonEntraJwt_EmitsHttpBearerScheme() { var token = Jwt("https://auth.contoso.com/", scp: "read"); var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); - var flow = SecurityScheme(doc, "APIOAuth2Auth")["flows"]!["authorizationCode"]!; - Assert.Equal("https://auth.contoso.com/", flow["authorizationUrl"]!.GetValue()); - Assert.Equal("https://auth.contoso.com/", flow["tokenUrl"]!.GetValue()); - Assert.Null(flow["refreshUrl"]); - Assert.Equal(["read"], RequirementScopes(Operation(doc), "APIOAuth2Auth")); + var scheme = SecurityScheme(doc, "BearerAuth"); + Assert.Equal("http", scheme["type"]!.GetValue()); + Assert.Equal("bearer", scheme["scheme"]!.GetValue()); + Assert.Equal("JWT", scheme["bearerFormat"]!.GetValue()); + Assert.Empty(RequirementScopes(Operation(doc), "BearerAuth")); + Assert.Null(doc["components"]!["securitySchemes"]!["APIOAuth2Auth"]); + } + + [Fact] + public async Task OpenApiSpecGenerator_DelegatedAndAppTokens_EmitSeparateRequirements() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read")}")), + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", roles: ["User.Read.All"])}")), + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "User.ReadBasic.All")}")), + ]); + + var flows = SecurityScheme(doc, "EntraOAuth2Auth")["flows"]!; + Assert.Equal(["User.Read", "User.ReadBasic.All"], flows["authorizationCode"]!["scopes"]!.AsObject().Select(s => s.Key)); + Assert.Equal(["User.Read.All"], flows["clientCredentials"]!["scopes"]!.AsObject().Select(s => s.Key)); + var security = Operation(doc)["security"]!.AsArray(); + Assert.Equal(2, security.Count); + Assert.Equal(["User.Read", "User.ReadBasic.All"], security[0]!["EntraOAuth2Auth"]!.AsArray().Select(s => s!.GetValue())); + Assert.Equal(["User.Read.All"], security[1]!["EntraOAuth2Auth"]!.AsArray().Select(s => s!.GetValue())); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task OpenApiSpecGenerator_SuccessfulAnonymousCall_MakesAuthOptional(bool anonymousFirst) + { + var anonymous = Exchange("https://api.contoso.com/users"); + var authenticated = Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value")); + var doc = await GenerateOpenApiJsonAsync(anonymousFirst ? [anonymous, authenticated] : [authenticated, anonymous]); + + var security = Operation(doc)["security"]!.AsArray(); + Assert.Equal(2, security.Count); + Assert.NotNull(security[0]!["ApiKeyAuth_header_x-api-key"]); + Assert.Empty(security[1]!.AsObject()); + } + + [Fact] + public async Task OpenApiSpecGenerator_FailedAnonymousCall_KeepsAuthRequired() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users", HttpStatusCode.Unauthorized), + Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value")), + ]); + + var security = Operation(doc)["security"]!.AsArray(); + Assert.NotNull(Assert.Single(security)!["ApiKeyAuth_header_x-api-key"]); } [Fact] @@ -290,4 +351,25 @@ public async Task TypeSpecGenerator_ApiKeyAndBearer_UseMatchingAuth() var bearerTsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("Authorization", "Bearer opaque-token"))]); Assert.Contains("@useAuth(BearerAuth)", bearerTsp, StringComparison.Ordinal); } + + [Fact] + public async Task TypeSpecGenerator_EntraV1AppToken_UsesLoginEndpoints() + { + var token = Jwt(EntraIssuerV1, roles: ["User.Read.All"]); + var tsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + Assert.Contains("type: OAuth2FlowType.clientCredentials;", tsp, StringComparison.Ordinal); + Assert.Contains($"tokenUrl: \"{EntraBaseUrl}oauth2/token\";", tsp, StringComparison.Ordinal); + Assert.DoesNotContain("sts.windows.net", tsp, StringComparison.Ordinal); + } + + [Fact] + public async Task TypeSpecGenerator_NonEntraJwt_UsesBearerAuth() + { + var token = Jwt("https://auth.contoso.com/", scp: "read"); + var tsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}"))]); + + Assert.Contains("@useAuth(BearerAuth)", tsp, StringComparison.Ordinal); + Assert.DoesNotContain("APIOAuth2Auth", tsp, StringComparison.Ordinal); + } } diff --git a/DevProxy.Plugins/Generation/AuthDetector.cs b/DevProxy.Plugins/Generation/AuthDetector.cs index 2371e7cef..5063b89c7 100644 --- a/DevProxy.Plugins/Generation/AuthDetector.cs +++ b/DevProxy.Plugins/Generation/AuthDetector.cs @@ -25,7 +25,7 @@ internal abstract record DetectedAuth; internal sealed record DetectedApiKeyAuth(string Name, DetectedApiKeyLocation In) : DetectedAuth; -internal sealed record DetectedBearerAuth : DetectedAuth; +internal sealed record DetectedBearerAuth(bool IsJwt = false) : DetectedAuth; internal sealed record DetectedOAuth2Auth( string Name, @@ -36,7 +36,6 @@ internal sealed record DetectedOAuth2Auth( IReadOnlyList Scopes) : DetectedAuth { public const string EntraName = "EntraOAuth2Auth"; - public const string GenericName = "APIOAuth2Auth"; } /// @@ -109,6 +108,15 @@ private static DetectedAuth DetectAuthorizationAuth(string bearerToken, ILogger var issuer = jwtToken.Issuer; logger.LogDebug("Issuer: {Issuer}", issuer); + if (!IsEntraToken(issuer, logger)) + { + // the issuer doesn't identify the OAuth endpoints, so we can't + // describe a usable OAuth2 flow + logger.LogDebug("JWT not issued by Entra. Using bearer auth"); + logger.LogTrace("Left {Name}", nameof(DetectAuthorizationAuth)); + return new DetectedBearerAuth(IsJwt: true); + } + var scopes = jwtToken.Claims .Where(c => c.Type == "scp") .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) @@ -127,34 +135,27 @@ private static DetectedAuth DetectAuthorizationAuth(string bearerToken, ILogger { logger.LogDebug("Roles: {Roles}", string.Join(", ", roles)); } + // delegated tokens can also contain roles, so only treat tokens + // without scopes as app-only + var flowType = scopes.Count == 0 && roles.Count != 0 ? + DetectedOAuth2FlowType.ClientCredentials : DetectedOAuth2FlowType.AuthorizationCode; scopes.AddRange(roles); - DetectedOAuth2Auth auth; - if (IsEntraToken(issuer, logger)) - { - var version = jwtToken.Claims - .FirstOrDefault(c => c.Type == "ver")?.Value ?? "1.0"; - var baseUrl = issuer.Contains("v2.0", StringComparison.OrdinalIgnoreCase) ? - issuer.Replace("v2.0", "", StringComparison.OrdinalIgnoreCase) : issuer; - var tokenUrl = version == "1.0" ? $"{baseUrl}oauth2/token" : $"{baseUrl}oauth2/v2.0/token"; - auth = new( - DetectedOAuth2Auth.EntraName, - roles.Count != 0 ? DetectedOAuth2FlowType.ClientCredentials : DetectedOAuth2FlowType.AuthorizationCode, - version == "1.0" ? $"{baseUrl}oauth2/authorize" : $"{baseUrl}oauth2/v2.0/authorize", - tokenUrl, - tokenUrl, - scopes); - } - else - { - auth = new( - DetectedOAuth2Auth.GenericName, - DetectedOAuth2FlowType.AuthorizationCode, - issuer, - issuer, - string.Empty, - scopes); - } + var version = jwtToken.Claims + .FirstOrDefault(c => c.Type == "ver")?.Value ?? "1.0"; + var baseUrl = issuer.Contains("v2.0", StringComparison.OrdinalIgnoreCase) ? + issuer.Replace("v2.0", "", StringComparison.OrdinalIgnoreCase) : issuer; + // v1 tokens are issued by sts.windows.net, but the OAuth endpoints + // are on login.microsoftonline.com + baseUrl = baseUrl.Replace("https://sts.windows.net/", "https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase); + var tokenUrl = version == "1.0" ? $"{baseUrl}oauth2/token" : $"{baseUrl}oauth2/v2.0/token"; + var auth = new DetectedOAuth2Auth( + DetectedOAuth2Auth.EntraName, + flowType, + version == "1.0" ? $"{baseUrl}oauth2/authorize" : $"{baseUrl}oauth2/v2.0/authorize", + tokenUrl, + tokenUrl, + scopes); logger.LogTrace("Left {Name}", nameof(DetectAuthorizationAuth)); return auth; diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 4b8c29f96..1b2033818 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -75,6 +75,14 @@ public sealed class OpenApiSpecGeneratorPlugin( public override string Name => nameof(OpenApiSpecGeneratorPlugin); + private const string BearerSchemeName = "BearerAuth"; + + private sealed class SecurityState + { + public HashSet AnonymousOperations { get; } = new(ReferenceEqualityComparer.Instance); + public Dictionary RequirementKeys { get; } = new(ReferenceEqualityComparer.Instance); + } + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) { Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); @@ -90,6 +98,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation Logger.LogInformation("Creating OpenAPI spec from recorded requests..."); var openApiDocs = new List(); + var securityState = new SecurityState(); foreach (var request in e.RequestLogs) { @@ -136,7 +145,8 @@ request.Context.Session is null || parametrizedPath, cancellationToken ); - AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.Request.RequestUri, parametrizedPath, detectedAuth); + AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.Request.RequestUri, parametrizedPath); + AddOrMergeSecurity(openApiDocs, pathItem, request.Context.Session, parametrizedPath, detectedAuth, securityState); } catch (Exception ex) { @@ -477,7 +487,7 @@ private void SetResponseFromSession(OpenApiOperation operation, IHttpResponse re return null; } - private void AddOrMergePathItem(List openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath, DetectedAuth? detectedAuth) + private void AddOrMergePathItem(List openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers?.Any(s => s.Url == serverUrl) == true); @@ -526,39 +536,87 @@ private void AddOrMergePathItem(List openApiDocs, OpenApiPathIt AddOrMergeOperation((OpenApiPathItem)value, newOperation.Value.Key, newOperation.Value.Value); } } + } + private void AddOrMergeSecurity( + List openApiDocs, + OpenApiPathItem pathItem, + IProxySession session, + string parametrizedPath, + DetectedAuth? detectedAuth, + SecurityState securityState) + { + var serverUrl = session.Request.RequestUri.GetLeftPart(UriPartial.Authority); + var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers?.Any(s => s.Url == serverUrl) == true); var method = pathItem.Operations?.FirstOrDefault().Key; - if (detectedAuth is not null && - method is not null && - value.Operations?.TryGetValue(method, out var operation) == true) + if (openApiDoc is null || + method is null || + !openApiDoc.Paths.TryGetValue(parametrizedPath, out var docPathItem) || + docPathItem.Operations?.TryGetValue(method, out var operation) != true || + operation is null) { - AddOrMergeSecurity(openApiDoc, operation, detectedAuth); + return; + } + + if (detectedAuth is null) + { + var statusCode = (int)(session.Response?.StatusCode ?? 0); + if (statusCode is >= 200 and < 300) + { + _ = securityState.AnonymousOperations.Add(operation); + AddAnonymousSecurityRequirement(operation); + } + return; } - } - private void AddOrMergeSecurity(OpenApiDocument openApiDoc, OpenApiOperation operation, DetectedAuth detectedAuth) - { var (schemeName, scopes) = AddOrMergeSecurityScheme(openApiDoc, detectedAuth); + // requirements for different OAuth2 flows are alternatives, so we keep + // them separate and only merge scopes within the same flow + var requirementKey = detectedAuth is DetectedOAuth2Auth oauth2 ? + $"{schemeName}:{oauth2.FlowType}" : schemeName; operation.Security ??= []; var existingRequirement = operation.Security - .SelectMany(r => r) - .FirstOrDefault(r => r.Key.Reference.Id == schemeName); - if (existingRequirement.Key is null) + .FirstOrDefault(r => securityState.RequirementKeys.TryGetValue(r, out var key) && key == requirementKey); + if (existingRequirement is null) { - Logger.LogDebug(" Adding security requirement {SchemeName} to operation...", schemeName); - operation.Security.Add(new OpenApiSecurityRequirement + Logger.LogDebug(" Adding security requirement {RequirementKey} to operation...", requirementKey); + var requirement = new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(schemeName, openApiDoc)] = [.. scopes] - }); - return; + }; + securityState.RequirementKeys[requirement] = requirementKey; + operation.Security.Add(requirement); + } + else + { + Logger.LogDebug(" Merging security requirement {RequirementKey} into operation...", requirementKey); + var requirementScopes = existingRequirement.First().Value; + foreach (var scope in scopes.Where(s => !requirementScopes.Contains(s))) + { + requirementScopes.Add(scope); + } + } + + if (securityState.AnonymousOperations.Contains(operation)) + { + AddAnonymousSecurityRequirement(operation); } + } - Logger.LogDebug(" Merging security requirement {SchemeName} into operation...", schemeName); - foreach (var scope in scopes.Where(s => !existingRequirement.Value.Contains(s))) + // an empty requirement marks authentication as optional for operations + // that were also called successfully without authentication + private void AddAnonymousSecurityRequirement(OpenApiOperation operation) + { + if (operation.Security is null || + operation.Security.Count == 0 || + operation.Security.Any(r => r.Count == 0)) { - existingRequirement.Value.Add(scope); + return; } + + Logger.LogDebug(" Marking authentication as optional for operation..."); + operation.Security.Add(new OpenApiSecurityRequirement()); } private (string SchemeName, IReadOnlyList Scopes) AddOrMergeSecurityScheme(OpenApiDocument openApiDoc, DetectedAuth detectedAuth) @@ -575,7 +633,7 @@ [new OpenApiSecuritySchemeReference(schemeName, openApiDoc)] = [.. scopes] }, []), DetectedBearerAuth => ( - "BearerAuth", + BearerSchemeName, new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, @@ -610,6 +668,10 @@ [new OpenApiSecuritySchemeReference(schemeName, openApiDoc)] = [.. scopes] { MergeOAuth2Flow(scheme, oauth2Auth); } + else if (detectedAuth is DetectedBearerAuth { IsJwt: true }) + { + scheme.BearerFormat = "JWT"; + } return (schemeName, scopes); } From eb6b0c486e3b0efb42b13b76fb11da0fbe131e78 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:28:42 +0100 Subject: [PATCH 3/6] Address second round of review feedback for OpenAPI auth generation - Don't treat cookie-carrying requests as anonymous - Recognize api_key as an API key name Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 23 +++++++++++++++++++ .../Generation/OpenApiSpecGeneratorPlugin.cs | 4 +++- DevProxy.Plugins/Models/Http.cs | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs index 4d4642fda..33c413587 100644 --- a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -270,6 +270,29 @@ public async Task OpenApiSpecGenerator_SuccessfulAnonymousCall_MakesAuthOptional Assert.Empty(security[1]!.AsObject()); } + [Fact] + public async Task OpenApiSpecGenerator_SuccessfulCookieCall_KeepsAuthRequired() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users", ("Cookie", "session=abc")), + Exchange("https://api.contoso.com/users", ("x-api-key", "secret-value")), + ]); + + var security = Operation(doc)["security"]!.AsArray(); + Assert.NotNull(Assert.Single(security)!["ApiKeyAuth_header_x-api-key"]); + } + + [Fact] + public async Task OpenApiSpecGenerator_UnderscoreApiKeyQueryParameter_EmitsApiKeyScheme() + { + var doc = await GenerateOpenApiJsonAsync([Exchange("https://api.contoso.com/users?api_key=secret-value")]); + + var scheme = SecurityScheme(doc, "ApiKeyAuth_query_api_key"); + Assert.Equal("apiKey", scheme["type"]!.GetValue()); + Assert.Equal("query", scheme["in"]!.GetValue()); + Assert.Equal("api_key", scheme["name"]!.GetValue()); + } + [Fact] public async Task OpenApiSpecGenerator_FailedAnonymousCall_KeepsAuthRequired() { diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 1b2033818..aff71f7fa 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -561,7 +561,9 @@ method is null || if (detectedAuth is null) { var statusCode = (int)(session.Response?.StatusCode ?? 0); - if (statusCode is >= 200 and < 300) + // cookies aren't detected as auth, but might authenticate the request + var hasCookie = session.Request.Headers.Any(h => h.Name.Equals("cookie", StringComparison.OrdinalIgnoreCase)); + if (statusCode is >= 200 and < 300 && !hasCookie) { _ = securityState.AnonymousOperations.Add(operation); AddAnonymousSecurityRequirement(operation); diff --git a/DevProxy.Plugins/Models/Http.cs b/DevProxy.Plugins/Models/Http.cs index 89d0af224..db1286ea9 100644 --- a/DevProxy.Plugins/Models/Http.cs +++ b/DevProxy.Plugins/Models/Http.cs @@ -221,6 +221,7 @@ internal static class Http [ "access-token", "api-key", + "api_key", "auth-token", "authorization", "authorization-token", From a660e9658d49043292c7f6f2da86ffc16a5a2c26 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:55:49 +0100 Subject: [PATCH 4/6] Address third round of review feedback for OpenAPI auth generation - Require HTTPS and an exact Entra host when detecting Entra issuers - Use a separate client credentials scheme for Swagger 2.0 output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 38 +++++++++++++++++++ DevProxy.Plugins/Generation/AuthDetector.cs | 7 +++- .../Generation/OpenApiSpecGeneratorPlugin.cs | 7 +++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs index 33c413587..f87c68bbf 100644 --- a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -349,6 +349,44 @@ public async Task OpenApiSpecGenerator_V2_EmitsSecurityDefinitions() Assert.Equal(["User.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); } + [Fact] + public async Task OpenApiSpecGenerator_V2_DelegatedAndAppTokens_EmitSeparateSchemes() + { + var doc = await GenerateOpenApiJsonAsync( + [ + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", scp: "User.Read")}")), + Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {Jwt(EntraIssuerV2, version: "2.0", roles: ["User.Read.All"])}")), + ], + PluginConfig.FromJson("""{ "specVersion": "v2_0" }""")); + + var delegated = doc["securityDefinitions"]!["EntraOAuth2Auth"]!; + Assert.Equal("accessCode", delegated["flow"]!.GetValue()); + Assert.Equal(["User.Read"], delegated["scopes"]!.AsObject().Select(s => s.Key)); + var app = doc["securityDefinitions"]!["EntraOAuth2Auth_clientCredentials"]!; + Assert.Equal("application", app["flow"]!.GetValue()); + Assert.Equal(["User.Read.All"], app["scopes"]!.AsObject().Select(s => s.Key)); + Assert.Equal(["User.Read"], RequirementScopes(Operation(doc), "EntraOAuth2Auth")); + Assert.Equal(["User.Read.All"], RequirementScopes(Operation(doc), "EntraOAuth2Auth_clientCredentials")); + } + + [Theory] + [InlineData("https://attacker.example/https://login.microsoftonline.com/tenant/v2.0")] + [InlineData("http://login.microsoftonline.com/tenant/v2.0")] + [InlineData("https://login.microsoftonline.com.attacker.example/tenant/v2.0")] + public async Task Generators_SpoofedEntraIssuer_FallBackToBearer(string issuer) + { + var token = Jwt(issuer, version: "2.0", roles: ["User.Read.All"]); + var logs = new[] { Exchange("https://api.contoso.com/users", ("Authorization", $"Bearer {token}")) }; + + var doc = await GenerateOpenApiJsonAsync(logs); + Assert.Equal("JWT", SecurityScheme(doc, "BearerAuth")["bearerFormat"]!.GetValue()); + Assert.Null(doc["components"]!["securitySchemes"]!["EntraOAuth2Auth"]); + + var tsp = await GenerateTypeSpecAsync(logs); + Assert.Contains("@useAuth(BearerAuth)", tsp, StringComparison.Ordinal); + Assert.DoesNotContain("attacker", tsp, StringComparison.Ordinal); + } + [Fact] public async Task TypeSpecGenerator_EntraDelegatedToken_UsesOAuth2Auth() { diff --git a/DevProxy.Plugins/Generation/AuthDetector.cs b/DevProxy.Plugins/Generation/AuthDetector.cs index 5063b89c7..95b85b655 100644 --- a/DevProxy.Plugins/Generation/AuthDetector.cs +++ b/DevProxy.Plugins/Generation/AuthDetector.cs @@ -165,8 +165,11 @@ private static bool IsEntraToken(string issuer, ILogger logger) { logger.LogTrace("Entered {Name}", nameof(IsEntraToken)); - var isEntraToken = issuer.Contains("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase) || - issuer.Contains("https://sts.windows.net/", StringComparison.OrdinalIgnoreCase); + var isEntraToken = Uri.TryCreate(issuer, UriKind.Absolute, out var issuerUri) && + issuerUri.Scheme == Uri.UriSchemeHttps && + issuerUri.IsDefaultPort && + (issuerUri.Host.Equals("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase) || + issuerUri.Host.Equals("sts.windows.net", StringComparison.OrdinalIgnoreCase)); logger.LogDebug("Is token from Entra? {IsEntraToken}", isEntraToken); logger.LogTrace("Left {Name}", nameof(IsEntraToken)); diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index aff71f7fa..13be03cc8 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -643,7 +643,7 @@ private void AddAnonymousSecurityRequirement(OpenApiOperation operation) }, []), DetectedOAuth2Auth oauth2 => ( - oauth2.Name, + GetOAuth2SchemeName(oauth2), new OpenApiSecurityScheme { Type = SecuritySchemeType.OAuth2, @@ -678,6 +678,11 @@ private void AddAnonymousSecurityRequirement(OpenApiOperation operation) return (schemeName, scopes); } + // Swagger 2.0 allows only one flow per security scheme + private string GetOAuth2SchemeName(DetectedOAuth2Auth oauth2) => + Configuration.SpecVersion == SpecVersion.v2_0 && oauth2.FlowType == DetectedOAuth2FlowType.ClientCredentials ? + $"{oauth2.Name}_clientCredentials" : oauth2.Name; + private static void MergeOAuth2Flow(OpenApiSecurityScheme scheme, DetectedOAuth2Auth oauth2) { scheme.Flows ??= new OpenApiOAuthFlows(); From 169e59a5743fcb98e6beba3ad56adb4e6cb91597 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:16:13 +0100 Subject: [PATCH 5/6] Detect common auth query parameters in generated specs Use a dedicated list of auth query parameters (access_token, api_key, apikey, code, key, sig, subscription-key) so that query-authenticated calls aren't treated as anonymous. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 39 +++++++++++++++++++ DevProxy.Plugins/Generation/AuthDetector.cs | 2 +- .../Generation/TypeSpecGeneratorPlugin.cs | 4 +- DevProxy.Plugins/Models/Http.cs | 11 ++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs index f87c68bbf..341d23cb7 100644 --- a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -282,6 +282,36 @@ public async Task OpenApiSpecGenerator_SuccessfulCookieCall_KeepsAuthRequired() Assert.NotNull(Assert.Single(security)!["ApiKeyAuth_header_x-api-key"]); } + [Theory] + [InlineData("access_token")] + [InlineData("subscription-key")] + [InlineData("sig")] + public async Task OpenApiSpecGenerator_AuthQueryParameter_EmitsApiKeySchemeAndSkipsParameter(string name) + { + var doc = await GenerateOpenApiJsonAsync([Exchange($"https://api.contoso.com/users?{name}=secret-value&top=10")]); + + var scheme = SecurityScheme(doc, $"ApiKeyAuth_query_{name}"); + Assert.Equal("query", scheme["in"]!.GetValue()); + Assert.Equal(name, scheme["name"]!.GetValue()); + var parameters = Operation(doc)["parameters"]?.AsArray().Select(p => p!["name"]!.GetValue()) ?? []; + Assert.DoesNotContain(name, parameters); + } + + [Fact] + public async Task OpenApiSpecGenerator_QueryAccessTokenAndBearerCalls_KeepAuthRequired() + { + var doc = await GenerateOpenApiJsonAsync([ + Exchange("https://api.contoso.com/users?access_token=abc"), + Exchange("https://api.contoso.com/users", ("Authorization", "Bearer opaque-token")), + ]); + + var security = Operation(doc)["security"]!.AsArray(); + Assert.Equal(2, security.Count); + Assert.NotNull(security[0]!["ApiKeyAuth_query_access_token"]); + Assert.NotNull(security[1]!["BearerAuth"]); + Assert.DoesNotContain(security, r => r!.AsObject().Count == 0); + } + [Fact] public async Task OpenApiSpecGenerator_UnderscoreApiKeyQueryParameter_EmitsApiKeyScheme() { @@ -424,6 +454,15 @@ public async Task TypeSpecGenerator_EntraV1AppToken_UsesLoginEndpoints() Assert.DoesNotContain("sts.windows.net", tsp, StringComparison.Ordinal); } + [Fact] + public async Task TypeSpecGenerator_AccessTokenQueryParameter_UsesApiKeyAuth() + { + var tsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users?access_token=abc")]); + + Assert.Contains("ApiKeyAuth", tsp, StringComparison.Ordinal); + Assert.DoesNotContain("@query access_token", tsp, StringComparison.Ordinal); + } + [Fact] public async Task TypeSpecGenerator_NonEntraJwt_UsesBearerAuth() { diff --git a/DevProxy.Plugins/Generation/AuthDetector.cs b/DevProxy.Plugins/Generation/AuthDetector.cs index 95b85b655..13c0beed4 100644 --- a/DevProxy.Plugins/Generation/AuthDetector.cs +++ b/DevProxy.Plugins/Generation/AuthDetector.cs @@ -93,7 +93,7 @@ internal static class AuthDetector var query = HttpUtility.ParseQueryString(requestUri.Query); return query.AllKeys - .FirstOrDefault(k => k is not null && Models.Http.AuthHeaders.Contains(k.ToLowerInvariant())); + .FirstOrDefault(k => k is not null && Models.Http.AuthQueryParameters.Contains(k.ToLowerInvariant())); } private static DetectedAuth DetectAuthorizationAuth(string bearerToken, ILogger logger) diff --git a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs index 095f06d2c..ca2061245 100644 --- a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs @@ -575,7 +575,9 @@ private bool IsParametrizable(string segment) var query = HttpUtility.ParseQueryString(url.Query); foreach (string key in query.Keys) { - if (Models.Http.AuthHeaders.Contains(key.ToLowerInvariant())) + var lowerCaseKey = key.ToLowerInvariant(); + if (Models.Http.AuthHeaders.Contains(lowerCaseKey) || + Models.Http.AuthQueryParameters.Contains(lowerCaseKey)) { Logger.LogDebug("Skipping auth header: {Key}", key); continue; diff --git a/DevProxy.Plugins/Models/Http.cs b/DevProxy.Plugins/Models/Http.cs index db1286ea9..57ed1b9f3 100644 --- a/DevProxy.Plugins/Models/Http.cs +++ b/DevProxy.Plugins/Models/Http.cs @@ -241,6 +241,17 @@ internal static class Http "code" ]; + internal static readonly string[] AuthQueryParameters = + [ + "access_token", + "api_key", + "apikey", + "code", + "key", + "sig", + "subscription-key" + ]; + internal static readonly string[] SensitiveQueryParameters = [ "access_token", From 5bfc4c5b8befbbba11be1736c80f32edecee7ddb Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:25:03 +0100 Subject: [PATCH 6/6] Keep detecting auth header names in query strings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerationAuthIntegrationTests.cs | 14 +++++++++----- DevProxy.Plugins/Generation/AuthDetector.cs | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs index 341d23cb7..29b7149ec 100644 --- a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs @@ -286,6 +286,8 @@ public async Task OpenApiSpecGenerator_SuccessfulCookieCall_KeepsAuthRequired() [InlineData("access_token")] [InlineData("subscription-key")] [InlineData("sig")] + [InlineData("api-key")] + [InlineData("token")] public async Task OpenApiSpecGenerator_AuthQueryParameter_EmitsApiKeySchemeAndSkipsParameter(string name) { var doc = await GenerateOpenApiJsonAsync([Exchange($"https://api.contoso.com/users?{name}=secret-value&top=10")]); @@ -454,13 +456,15 @@ public async Task TypeSpecGenerator_EntraV1AppToken_UsesLoginEndpoints() Assert.DoesNotContain("sts.windows.net", tsp, StringComparison.Ordinal); } - [Fact] - public async Task TypeSpecGenerator_AccessTokenQueryParameter_UsesApiKeyAuth() + [Theory] + [InlineData("access_token")] + [InlineData("token")] + public async Task TypeSpecGenerator_AuthQueryParameter_UsesApiKeyAuth(string name) { - var tsp = await GenerateTypeSpecAsync([Exchange("https://api.contoso.com/users?access_token=abc")]); + var tsp = await GenerateTypeSpecAsync([Exchange($"https://api.contoso.com/users?{name}=abc")]); - Assert.Contains("ApiKeyAuth", tsp, StringComparison.Ordinal); - Assert.DoesNotContain("@query access_token", tsp, StringComparison.Ordinal); + Assert.Contains($"ApiKeyAuth", tsp, StringComparison.Ordinal); + Assert.DoesNotContain($"@query {name}", tsp, StringComparison.Ordinal); } [Fact] diff --git a/DevProxy.Plugins/Generation/AuthDetector.cs b/DevProxy.Plugins/Generation/AuthDetector.cs index 13c0beed4..7ed6b1389 100644 --- a/DevProxy.Plugins/Generation/AuthDetector.cs +++ b/DevProxy.Plugins/Generation/AuthDetector.cs @@ -93,9 +93,14 @@ internal static class AuthDetector var query = HttpUtility.ParseQueryString(requestUri.Query); return query.AllKeys - .FirstOrDefault(k => k is not null && Models.Http.AuthQueryParameters.Contains(k.ToLowerInvariant())); + .FirstOrDefault(k => k is not null && IsAuthQueryParameter(k.ToLowerInvariant())); } + // auth header names are also commonly used to pass credentials in the query string + private static bool IsAuthQueryParameter(string lowerCaseName) => + Models.Http.AuthQueryParameters.Contains(lowerCaseName) || + Models.Http.AuthHeaders.Contains(lowerCaseName); + private static DetectedAuth DetectAuthorizationAuth(string bearerToken, ILogger logger) { logger.LogTrace("Entered {Name}", nameof(DetectAuthorizationAuth));