From 36fe886e1fee62c6830b457b3ab873b423753e86 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:41:57 -0700 Subject: [PATCH] Detect Security Scheme 3.2 additions in pre-3.2 documents (SecuritySchemeFieldsBefore32 rule) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + lib/openapi_parser/spec_validator.rb | 2 + lib/openapi_parser/spec_validator/rule.rb | 12 +++ .../rules/security_scheme_fields_before_32.rb | 41 ++++++++ sig/openapi_parser/spec_validator.rbs | 6 ++ .../security_scheme_fields_31.yaml | 25 +++++ .../security_scheme_fields_32.yaml | 25 +++++ .../spec_validator/integration_3_2_spec.rb | 14 +++ .../security_scheme_fields_before_32_spec.rb | 96 +++++++++++++++++++ 9 files changed, 222 insertions(+) create mode 100644 lib/openapi_parser/spec_validator/rules/security_scheme_fields_before_32.rb create mode 100644 spec/data/openapi_3_2/security_scheme_fields_31.yaml create mode 100644 spec/data/openapi_3_2/security_scheme_fields_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/security_scheme_fields_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f269c..8b168da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * `ExampleValueFieldsBefore32`: detect Example Object `dataValue` / `serializedValue` usage in pre-3.2 documents (3.2 additions) * `XmlNodeTypeBefore32`: detect XML Object `nodeType` usage in pre-3.2 documents (3.2 addition) * `XmlAttributeDeprecation` / `XmlWrappedDeprecation`: detect XML Object `attribute: true` / `wrapped: true` in 3.2 documents (deprecated in 3.2 in favor of `nodeType`) + * `SecuritySchemeFieldsBefore32`: detect Security Scheme `deprecated` / `oauth2MetadataUrl` and the `deviceAuthorization` OAuth flow in pre-3.2 documents (3.2 additions) * expose the declared version as `OpenAPI#openapi_version` (a `Gem::Version`, or nil when the field is missing or malformed) so `SpecValidator` rules compare version ranges; a 3.2 document is checked by the 3.1-or-later rules * support 3.1-style numeric `exclusiveMinimum` / `exclusiveMaximum` in value validation (standalone bound, not a Boolean modifier on `minimum` / `maximum`) * support `type: "null"` (3.1 primitive) in value validation diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index b8f25ea..fffa1c6 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -23,6 +23,7 @@ require_relative 'spec_validator/rules/xml_node_type_before_32' require_relative 'spec_validator/rules/xml_attribute_deprecation' require_relative 'spec_validator/rules/xml_wrapped_deprecation' +require_relative 'spec_validator/rules/security_scheme_fields_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -93,6 +94,7 @@ def rules Rules::XmlNodeTypeBefore32, Rules::XmlAttributeDeprecation, Rules::XmlWrappedDeprecation, + Rules::SecuritySchemeFieldsBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rule.rb b/lib/openapi_parser/spec_validator/rule.rb index 204c9e0..c3aa945 100644 --- a/lib/openapi_parser/spec_validator/rule.rb +++ b/lib/openapi_parser/spec_validator/rule.rb @@ -56,6 +56,18 @@ def each_node(root, *klasses) end end + # security schemes are not modeled by the parse layer, so rules + # about them read the raw `components.securitySchemes` map + def each_security_scheme(root) + components = root.raw_schema.is_a?(Hash) ? root.raw_schema['components'] : nil + schemes = components.is_a?(Hash) ? components['securitySchemes'] : nil + return unless schemes.is_a?(Hash) + + schemes.each do |name, scheme| + yield(name, scheme) if scheme.is_a?(Hash) + end + end + # escapes a map key for use in a violation path, matching object_reference def escape_reference(key) key.to_s.gsub('/', '~1') diff --git a/lib/openapi_parser/spec_validator/rules/security_scheme_fields_before_32.rb b/lib/openapi_parser/spec_validator/rules/security_scheme_fields_before_32.rb new file mode 100644 index 0000000..e795fa2 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/security_scheme_fields_before_32.rb @@ -0,0 +1,41 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `deprecated` and `oauth2MetadataUrl` to the Security Scheme + # Object and the `deviceAuthorization` OAuth flow. Security schemes are + # not modeled by the parse layer, so this rule inspects the raw + # `components.securitySchemes` map; schemes given as a `$ref` are not + # followed. + class SecuritySchemeFieldsBefore32 < Rule + NEW_FIELDS = %w[deprecated oauth2MetadataUrl].freeze + + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_security_scheme(root) do |name, scheme| + base = "#/components/securitySchemes/#{escape_reference(name)}" + + NEW_FIELDS.each do |field| + next unless scheme.key?(field) + + violations << violation( + path: "#{base}/#{field}", + message: "`#{field}` on a Security Scheme Object is a 3.2 addition; earlier documents have no such field", + ) + end + + flows = scheme['flows'] + next unless flows.is_a?(Hash) && flows.key?('deviceAuthorization') + + violations << violation( + path: "#{base}/flows/deviceAuthorization", + message: 'the `deviceAuthorization` OAuth flow is a 3.2 addition; earlier documents have no such flow', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 2a5f81f..52316b6 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -36,6 +36,7 @@ module OpenAPIParser private def violation: (path: String, message: String) -> SpecViolation private def each_schema: (untyped root) ?{ (untyped) -> void } -> untyped private def each_node: (untyped root, *Class klasses) { (untyped) -> void } -> void + private def each_security_scheme: (untyped root) { (String, Hash[untyped, untyped]) -> void } -> void private def escape_reference: (untyped key) -> String private def walk: (untyped node, Hash[Integer, bool] visited) { (untyped) -> void } -> void end @@ -134,6 +135,11 @@ module OpenAPIParser class XmlWrappedDeprecation < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class SecuritySchemeFieldsBefore32 < Rule + NEW_FIELDS: Array[String] + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/security_scheme_fields_31.yaml b/spec/data/openapi_3_2/security_scheme_fields_31.yaml new file mode 100644 index 0000000..fbc119c --- /dev/null +++ b/spec/data/openapi_3_2/security_scheme_fields_31.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + securitySchemes: + OAuth2: + type: oauth2 + # `deprecated`, `oauth2MetadataUrl`, and the `deviceAuthorization` + # flow are 3.2 additions to the Security Scheme Object; a 3.1 + # document has no such fields, so their use here is a spec violation. + deprecated: true + oauth2MetadataUrl: https://auth.example.com/.well-known/oauth-authorization-server + flows: + deviceAuthorization: + deviceAuthorizationUrl: https://auth.example.com/device + tokenUrl: https://auth.example.com/token + scopes: {} diff --git a/spec/data/openapi_3_2/security_scheme_fields_32.yaml b/spec/data/openapi_3_2/security_scheme_fields_32.yaml new file mode 100644 index 0000000..e1d3e05 --- /dev/null +++ b/spec/data/openapi_3_2/security_scheme_fields_32.yaml @@ -0,0 +1,25 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + securitySchemes: + OAuth2: + type: oauth2 + # `deprecated`, `oauth2MetadataUrl`, and the `deviceAuthorization` + # flow are legitimate Security Scheme fields under 3.2, so no + # violation is expected here. + deprecated: true + oauth2MetadataUrl: https://auth.example.com/.well-known/oauth-authorization-server + flows: + deviceAuthorization: + deviceAuthorizationUrl: https://auth.example.com/device + tokenUrl: https://auth.example.com/token + scopes: {} diff --git a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb index edd33fb..5ae7fc6 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -122,4 +122,18 @@ def expect_clean(file) expect_clean('xml_deprecated_fields_31.yaml') end end + + describe 'Security Scheme deviceAuthorization/oauth2MetadataUrl/deprecated (3.2 additions)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('security_scheme_fields_31.yaml', [:security_scheme_fields_before32] * 3) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('security_scheme_fields_31.yaml', [:security_scheme_fields_before32] * 3) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('security_scheme_fields_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/security_scheme_fields_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/security_scheme_fields_before_32_spec.rb new file mode 100644 index 0000000..8b9c1c2 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/security_scheme_fields_before_32_spec.rb @@ -0,0 +1,96 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::SecuritySchemeFieldsBefore32' do + def base_doc(openapi_version_string, schemes) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'securitySchemes' => schemes }, + } + end + + def doc_with_new_fields(openapi_version_string) + scheme = { + 'type' => 'oauth2', + 'deprecated' => true, + 'oauth2MetadataUrl' => 'https://auth.example.com/.well-known/oauth-authorization-server', + 'flows' => { + 'deviceAuthorization' => { + 'deviceAuthorizationUrl' => 'https://auth.example.com/device', + 'tokenUrl' => 'https://auth.example.com/token', + 'scopes' => {}, + }, + }, + } + OpenAPIParser.parse(base_doc(openapi_version_string, { 'OAuth2' => scheme }), strict_reference_validation: false) + end + + def doc_without_new_fields(openapi_version_string) + scheme = { + 'type' => 'oauth2', + 'flows' => { 'clientCredentials' => { 'tokenUrl' => 'https://auth.example.com/token', 'scopes' => {} } }, + } + OpenAPIParser.parse(base_doc(openapi_version_string, { 'OAuth2' => scheme }), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::SecuritySchemeFieldsBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using the new fields' do + it 'reports no violation' do + root = doc_with_new_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without the new fields' do + it 'reports no violation' do + root = doc_without_new_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using the new fields' do + it 'reports one violation per offending field' do + root = doc_with_new_fields('3.1.0') + violations = run_rule_for(root) + expect(violations.map(&:path)).to match_array [ + '#/components/securitySchemes/OAuth2/deprecated', + '#/components/securitySchemes/OAuth2/oauth2MetadataUrl', + '#/components/securitySchemes/OAuth2/flows/deviceAuthorization', + ] + expect(violations.first.rule_name).to eq :security_scheme_fields_before32 + end + end + + context 'with a 3.1 document without the new fields' do + it 'reports no violation' do + root = doc_without_new_fields('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using the new fields' do + it 'reports violations' do + root = doc_with_new_fields('3.0.0') + expect(run_rule_for(root).size).to eq 3 + end + end + + context 'with a 3.1 document whose scheme name contains a slash' do + it 'escapes the name in the violation path' do + raw = base_doc('3.1.0', { 'auth/v2' => { 'type' => 'http', 'scheme' => 'bearer', 'deprecated' => true } }) + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root).map(&:path)).to eq ['#/components/securitySchemes/auth~1v2/deprecated'] + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_new_fields('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end