From 335cee5dece87d0be2060e0e5d494189734f9ba2 Mon Sep 17 00:00:00 2001 From: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:13:17 -0800 Subject: [PATCH 1/2] feat(securityscheme): add oauth2 metadata url support --- .../Interfaces/IOpenApiSecurityScheme.cs | 6 ++ .../Models/OpenApiConstants.cs | 5 ++ .../Models/OpenApiSecurityScheme.cs | 9 +++ .../OpenApiSecuritySchemeReference.cs | 3 + .../V32/OpenApiSecuritySchemeDeserializer.cs | 12 ++- .../V32Tests/OpenApiSecuritySchemeTests.cs | 30 ++++++++ .../oauth2SecuritySchemeWithMetadataUrl.yaml | 8 ++ .../Models/OpenApiSecuritySchemeTests.cs | 73 +++++++++++++++++++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/oauth2SecuritySchemeWithMetadataUrl.yaml diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSecurityScheme.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSecurityScheme.cs index 41247ce08..16da4a881 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSecurityScheme.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSecurityScheme.cs @@ -46,6 +46,12 @@ public interface IOpenApiSecurityScheme : IOpenApiDescribedElement, IOpenApiRead /// public Uri? OpenIdConnectUrl { get; } + /// + /// URL to the OAuth2 Authorization Server Metadata document (RFC 8414). + /// Note: This field is supported in OpenAPI 3.2.0+ only. + /// + public Uri? OAuth2MetadataUrl { get; } + /// /// Specifies that a security scheme is deprecated and SHOULD be transitioned out of usage. /// Note: This field is supported in OpenAPI 3.2.0+. For earlier versions, it will be serialized as x-oai-deprecated extension. diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index b87c9079d..543baae29 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -695,6 +695,11 @@ public static class OpenApiConstants /// public const string Flows = "flows"; + /// + /// Field: Oauth2MetadataUrl + /// + public const string OAuth2MetadataUrl = "oauth2MetadataUrl"; + /// /// Field: OpenIdConnectUrl /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs index 57603e0aa..6278096b2 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs @@ -35,6 +35,9 @@ public class OpenApiSecurityScheme : IOpenApiExtensible, IOpenApiSecurityScheme /// public Uri? OpenIdConnectUrl { get; set; } + /// + public Uri? OAuth2MetadataUrl { get; set; } + /// public bool Deprecated { get; set; } @@ -60,6 +63,7 @@ internal OpenApiSecurityScheme(IOpenApiSecurityScheme securityScheme) BearerFormat = securityScheme.BearerFormat ?? BearerFormat; Flows = securityScheme.Flows != null ? new(securityScheme.Flows) : null; OpenIdConnectUrl = securityScheme.OpenIdConnectUrl != null ? new Uri(securityScheme.OpenIdConnectUrl.OriginalString, UriKind.RelativeOrAbsolute) : null; + OAuth2MetadataUrl = securityScheme.OAuth2MetadataUrl != null ? new Uri(securityScheme.OAuth2MetadataUrl.OriginalString, UriKind.RelativeOrAbsolute) : null; Deprecated = securityScheme.Deprecated; Extensions = securityScheme.Extensions != null ? new Dictionary(securityScheme.Extensions) : null; } @@ -118,7 +122,12 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version break; case SecuritySchemeType.OAuth2: // This property apply to oauth2 type only. + // oauth2MetadataUrl // flows + if (version >= OpenApiSpecVersion.OpenApi3_2) + { + writer.WriteProperty(OpenApiConstants.OAuth2MetadataUrl, OAuth2MetadataUrl?.ToString()); + } writer.WriteOptionalObject(OpenApiConstants.Flows, Flows, callback); break; case SecuritySchemeType.OpenIdConnect: diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs index 4267315e3..25ed6e25f 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs @@ -54,6 +54,9 @@ public string? Description /// public Uri? OpenIdConnectUrl { get => Target?.OpenIdConnectUrl; } + /// + public Uri? OAuth2MetadataUrl { get => Target?.OAuth2MetadataUrl; } + /// public IDictionary? Extensions { get => Target?.Extensions; } diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSecuritySchemeDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSecuritySchemeDeserializer.cs index 34e3f589e..f0acc7ea6 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSecuritySchemeDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSecuritySchemeDeserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -68,6 +68,16 @@ internal static partial class OpenApiV32Deserializer } } }, + { + "oauth2MetadataUrl", (o, n, _) => + { + var metadataUrl = n.GetScalarValue(); + if (metadataUrl != null) + { + o.OAuth2MetadataUrl = new(metadataUrl, UriKind.RelativeOrAbsolute); + } + } + }, { "flows", (o, n, t) => { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs index ea0937237..50c836f66 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs @@ -86,6 +86,36 @@ public async Task ParseOAuth2SecuritySchemeShouldSucceed() }, securityScheme); } + [Fact] + public async Task ParseOAuth2SecuritySchemeWithMetadataUrlShouldSucceed() + { + // Act + var securityScheme = await OpenApiModelFactory.LoadAsync( + Path.Combine(SampleFolderPath, "oauth2SecuritySchemeWithMetadataUrl.yaml"), + OpenApiSpecVersion.OpenApi3_2, + new(), + SettingsFixture.ReaderSettings); + + // Assert + Assert.Equivalent( + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + OAuth2MetadataUrl = new Uri("https://idp.example.com/.well-known/oauth-authorization-server"), + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = new Uri("https://idp.example.com/oauth/token"), + Scopes = new System.Collections.Generic.Dictionary + { + ["scope:one"] = "Scope one" + } + } + } + }, securityScheme); + } + [Fact] public async Task ParseOpenIdConnectSecuritySchemeShouldSucceed() { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/oauth2SecuritySchemeWithMetadataUrl.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/oauth2SecuritySchemeWithMetadataUrl.yaml new file mode 100644 index 000000000..612f65fde --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/oauth2SecuritySchemeWithMetadataUrl.yaml @@ -0,0 +1,8 @@ +# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.2.0.md#securitySchemeObject +type: oauth2 +oauth2MetadataUrl: https://idp.example.com/.well-known/oauth-authorization-server +flows: + clientCredentials: + tokenUrl: https://idp.example.com/oauth/token + scopes: + scope:one: Scope one diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs index cd8499b9f..6a234c99f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs @@ -93,6 +93,24 @@ public class OpenApiSecuritySchemeTests } }; + private static OpenApiSecurityScheme OAuth2MetadataSecurityScheme => new() + { + Description = "description1", + Type = SecuritySchemeType.OAuth2, + OAuth2MetadataUrl = new("https://idp.example.com/.well-known/oauth-authorization-server"), + Flows = new() + { + ClientCredentials = new() + { + TokenUrl = new("https://idp.example.com/oauth/token"), + Scopes = new Dictionary + { + ["scope:one"] = "Scope one" + } + } + } + }; + private static OpenApiSecurityScheme OpenIdConnectSecurityScheme => new() { Description = "description1", @@ -257,6 +275,61 @@ public async Task SerializeOAuthSingleFlowSecuritySchemeAsV3JsonWorks() Assert.Equal(expected, actual); } + [Fact] + public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV32JsonWorks() + { + // Arrange + var expected = + """ + { + "type": "oauth2", + "description": "description1", + "oauth2MetadataUrl": "https://idp.example.com/.well-known/oauth-authorization-server", + "flows": { + "clientCredentials": { + "tokenUrl": "https://idp.example.com/oauth/token", + "scopes": { + "scope:one": "Scope one" + } + } + } + } + """; + + // Act + var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV31JsonOmitsMetadataUrl() + { + // Arrange + var expected = + """ + { + "type": "oauth2", + "description": "description1", + "flows": { + "clientCredentials": { + "tokenUrl": "https://idp.example.com/oauth/token", + "scopes": { + "scope:one": "Scope one" + } + } + } + } + """; + + // Act + var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + [Fact] public async Task SerializeOAuthMultipleFlowSecuritySchemeAsV3JsonWorks() { From 14b73bf3288eb56a12eb53e401422561b01f87d4 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Thu, 22 Jan 2026 11:25:14 -0800 Subject: [PATCH 2/2] Add x-oauth2-metadata-url serialization for OAuth2 schemes When serializing OAuth2 security schemes, the OAuth2MetadataUrl is now written as 'x-oauth2-metadata-url' for OpenAPI 3.1 and later. Updated tests and public API documentation to reflect this change. --- src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs | 4 ++++ .../Models/OpenApiSecuritySchemeTests.cs | 1 + test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs index 6278096b2..b1670de6d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs @@ -128,6 +128,10 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version { writer.WriteProperty(OpenApiConstants.OAuth2MetadataUrl, OAuth2MetadataUrl?.ToString()); } + else + { + writer.WriteProperty("x-oauth2-metadata-url", OAuth2MetadataUrl?.ToString()); + } writer.WriteOptionalObject(OpenApiConstants.Flows, Flows, callback); break; case SecuritySchemeType.OpenIdConnect: diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs index 6a234c99f..b826c89b3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs @@ -312,6 +312,7 @@ public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV31JsonOmitsMetad { "type": "oauth2", "description": "description1", + "x-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server", "flows": { "clientCredentials": { "tokenUrl": "https://idp.example.com/oauth/token", diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index 5413879c0..b1c9810e5 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -288,6 +288,7 @@ namespace Microsoft.OpenApi Microsoft.OpenApi.OpenApiOAuthFlows? Flows { get; } Microsoft.OpenApi.ParameterLocation? In { get; } string? Name { get; } + System.Uri? OAuth2MetadataUrl { get; } System.Uri? OpenIdConnectUrl { get; } string? Scheme { get; } Microsoft.OpenApi.SecuritySchemeType? Type { get; } @@ -538,6 +539,7 @@ namespace Microsoft.OpenApi public const string Null = "null"; public const string Nullable = "nullable"; public const string NullableExtension = "x-nullable"; + public const string OAuth2MetadataUrl = "oauth2MetadataUrl"; public const string OneOf = "oneOf"; public const string OpenApi = "openapi"; public const string OpenIdConnectUrl = "openIdConnectUrl"; @@ -1381,6 +1383,7 @@ namespace Microsoft.OpenApi public Microsoft.OpenApi.OpenApiOAuthFlows? Flows { get; set; } public Microsoft.OpenApi.ParameterLocation? In { get; set; } public string? Name { get; set; } + public System.Uri? OAuth2MetadataUrl { get; set; } public System.Uri? OpenIdConnectUrl { get; set; } public string? Scheme { get; set; } public Microsoft.OpenApi.SecuritySchemeType? Type { get; set; } @@ -1400,6 +1403,7 @@ namespace Microsoft.OpenApi public Microsoft.OpenApi.OpenApiOAuthFlows? Flows { get; } public Microsoft.OpenApi.ParameterLocation? In { get; } public string? Name { get; } + public System.Uri? OAuth2MetadataUrl { get; } public System.Uri? OpenIdConnectUrl { get; } public string? Scheme { get; } public Microsoft.OpenApi.SecuritySchemeType? Type { get; }