diff --git a/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs
new file mode 100644
index 00000000..29b7149e
--- /dev/null
+++ b/DevProxy.Integration.Tests/GenerationAuthIntegrationTests.cs
@@ -0,0 +1,479 @@
+// 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) =>
+ 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(
+ statusCode,
+ 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($"{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_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 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_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"]);
+ }
+
+ [Theory]
+ [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")]);
+
+ 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()
+ {
+ 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()
+ {
+ 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]
+ 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 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()
+ {
+ 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);
+ }
+
+ [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);
+ }
+
+ [Theory]
+ [InlineData("access_token")]
+ [InlineData("token")]
+ public async Task TypeSpecGenerator_AuthQueryParameter_UsesApiKeyAuth(string name)
+ {
+ var tsp = await GenerateTypeSpecAsync([Exchange($"https://api.contoso.com/users?{name}=abc")]);
+
+ Assert.Contains($"ApiKeyAuth", tsp, StringComparison.Ordinal);
+ Assert.DoesNotContain($"@query {name}", 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
new file mode 100644
index 00000000..7ed6b138
--- /dev/null
+++ b/DevProxy.Plugins/Generation/AuthDetector.cs
@@ -0,0 +1,205 @@
+// 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(bool IsJwt = false) : DetectedAuth;
+
+internal sealed record DetectedOAuth2Auth(
+ string Name,
+ DetectedOAuth2FlowType FlowType,
+ string AuthorizationUrl,
+ string TokenUrl,
+ string RefreshUrl,
+ IReadOnlyList Scopes) : DetectedAuth
+{
+ public const string EntraName = "EntraOAuth2Auth";
+}
+
+///
+/// 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 && 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));
+
+ 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);
+ 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))
+ .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));
+ }
+ // 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);
+
+ 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;
+ }
+
+ private static bool IsEntraToken(string issuer, ILogger logger)
+ {
+ logger.LogTrace("Entered {Name}", nameof(IsEntraToken));
+
+ 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));
+
+ 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 9963c038..13be03cc 100644
--- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs
+++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs
@@ -1,841 +1,1035 @@
-// 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);
+
+ 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));
+
+ 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();
+ var securityState = new SecurityState();
+
+ 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);
+ AddOrMergeSecurity(openApiDocs, pathItem, request.Context.Session, parametrizedPath, detectedAuth, securityState);
+ }
+ 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)
+ {
+ 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);
+ }
+ }
+ }
+
+ 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 (openApiDoc is null ||
+ method is null ||
+ !openApiDoc.Paths.TryGetValue(parametrizedPath, out var docPathItem) ||
+ docPathItem.Operations?.TryGetValue(method, out var operation) != true ||
+ operation is null)
+ {
+ return;
+ }
+
+ if (detectedAuth is null)
+ {
+ var statusCode = (int)(session.Response?.StatusCode ?? 0);
+ // 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);
+ }
+ return;
+ }
+
+ 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
+ .FirstOrDefault(r => securityState.RequirementKeys.TryGetValue(r, out var key) && key == requirementKey);
+ if (existingRequirement is null)
+ {
+ Logger.LogDebug(" Adding security requirement {RequirementKey} to operation...", requirementKey);
+ var requirement = new OpenApiSecurityRequirement
+ {
+ [new OpenApiSecuritySchemeReference(schemeName, openApiDoc)] = [.. scopes]
+ };
+ 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);
+ }
+ }
+
+ // 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))
+ {
+ return;
+ }
+
+ Logger.LogDebug(" Marking authentication as optional for operation...");
+ operation.Security.Add(new OpenApiSecurityRequirement());
+ }
+
+ 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 => (
+ BearerSchemeName,
+ new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer"
+ },
+ []),
+ DetectedOAuth2Auth oauth2 => (
+ GetOAuth2SchemeName(oauth2),
+ 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);
+ }
+ else if (detectedAuth is DetectedBearerAuth { IsJwt: true })
+ {
+ scheme.BearerFormat = "JWT";
+ }
+
+ 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();
+ 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 5731ac56..ca206124 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)
@@ -696,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 89d0af22..57ed1b9f 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",
@@ -240,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",