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",