Skip to content

Commit 38ae42d

Browse files
committed
fix: compose external security scheme refs during bundling (#932)
External $refs to security schemes — both bare-file refs and component fragments — were not composed into components.securitySchemes when bundling. Use source-slot context to infer the securityScheme component type, with a shape guard (isSecuritySchemeNode) so bare-file wrapper maps and full OpenAPI documents are not misclassified.
1 parent f078cc8 commit 38ae42d

4 files changed

Lines changed: 167 additions & 3 deletions

File tree

bundler/bundler_issue932_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley
2+
// SPDX-License-Identifier: MIT
3+
4+
package bundler
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/pb33f/libopenapi/datamodel"
12+
"github.com/pb33f/testify/assert"
13+
"github.com/pb33f/testify/require"
14+
)
15+
16+
func TestBundleBytesComposed_ExternalSecurityScheme(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
ref string
20+
expectedComponent string
21+
extraFiles map[string]string
22+
}{
23+
{
24+
name: "bare file",
25+
ref: "./bearer-auth.yaml",
26+
expectedComponent: "bearer-auth",
27+
extraFiles: map[string]string{
28+
"bearer-auth.yaml": `type: http
29+
scheme: bearer
30+
bearerFormat: JWT
31+
description: JWT bearer token.
32+
`,
33+
},
34+
},
35+
{
36+
name: "component fragment",
37+
ref: "./shared.yaml#/components/securitySchemes/bearerAuth",
38+
expectedComponent: "bearerAuth__shared",
39+
extraFiles: map[string]string{
40+
"shared.yaml": `components:
41+
securitySchemes:
42+
bearerAuth:
43+
type: http
44+
scheme: bearer
45+
bearerFormat: JWT
46+
description: JWT bearer token.
47+
`,
48+
},
49+
},
50+
}
51+
52+
for _, tt := range tests {
53+
t.Run(tt.name, func(t *testing.T) {
54+
tmpDir := t.TempDir()
55+
for name, contents := range tt.extraFiles {
56+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, name), []byte(contents), 0o644))
57+
}
58+
59+
root := `openapi: 3.2.0
60+
info:
61+
title: Repro
62+
version: 1.0.0
63+
paths: {}
64+
components:
65+
securitySchemes:
66+
bearerAuth:
67+
$ref: "` + tt.ref + `"
68+
`
69+
config := datamodel.NewDocumentConfiguration()
70+
config.BasePath = tmpDir
71+
72+
bundled, err := BundleBytesComposed([]byte(root), config, nil)
73+
require.NoError(t, err)
74+
75+
output := string(bundled)
76+
assert.Contains(t, output, `$ref: "#/components/securitySchemes/`+tt.expectedComponent+`"`)
77+
assert.Contains(t, output, tt.expectedComponent+":\n type: http")
78+
assert.Contains(t, output, "scheme: bearer")
79+
assert.Contains(t, output, "bearerFormat: JWT")
80+
assert.NotContains(t, output, "#/components/schemas/")
81+
assert.NotContains(t, output, tt.ref)
82+
})
83+
}
84+
}

bundler/composer_functions.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ func composeReferenceAs(
292292
return false, nil
293293
}
294294
return true, checkReferenceAndCapture(name, delimiter, v3low.MediaTypesLabel, pr, idx, components.MediaTypes, buildMediaType, cf.origins)
295+
case v3low.SecuritySchemesLabel:
296+
if components.SecuritySchemes == nil {
297+
return false, nil
298+
}
299+
return true, checkReferenceAndCapture(name, delimiter, v3low.SecuritySchemesLabel, pr, idx, components.SecuritySchemes, buildSecurityScheme, cf.origins)
295300
default:
296301
return false, nil
297302
}
@@ -365,6 +370,11 @@ func fileImportLocationForType(
365370
return false, nil
366371
}
367372
return true, handleFileImport(pr, v3low.MediaTypesLabel, delimiter, components.MediaTypes)
373+
case v3low.SecuritySchemesLabel:
374+
if components.SecuritySchemes == nil {
375+
return false, nil
376+
}
377+
return true, handleFileImport(pr, v3low.SecuritySchemesLabel, delimiter, components.SecuritySchemes)
368378
default:
369379
return false, nil
370380
}
@@ -610,6 +620,13 @@ func buildResponse(node *yaml.Node, idx *index.SpecIndex) (*v3.Response, error)
610620
return v3.NewResponse(&resp), err
611621
}
612622

623+
func buildSecurityScheme(node *yaml.Node, idx *index.SpecIndex) (*v3.SecurityScheme, error) {
624+
securityScheme := v3low.SecurityScheme{}
625+
_ = low.BuildModel(node, &securityScheme)
626+
err := securityScheme.Build(context.Background(), &yaml.Node{}, node, idx)
627+
return v3.NewSecurityScheme(&securityScheme), err
628+
}
629+
613630
func buildParameter(node *yaml.Node, idx *index.SpecIndex) (*v3.Parameter, error) {
614631
param := v3low.Parameter{}
615632
_ = low.BuildModel(node, &param)

bundler/source_context.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ func inferComponentTypeFromSourcePath(sourcePath []string) (string, bool) {
5555
return v3.MediaTypesLabel, true
5656
case v3.ContentLabel:
5757
return v3.MediaTypesLabel, true
58+
case v3.SecuritySchemesLabel:
59+
return v3.SecuritySchemesLabel, true
5860
}
5961

6062
if segment == v3.RequestBodyLabel {
@@ -86,6 +88,9 @@ func canComposeContextualReference(componentType string, node *yaml.Node, bareFi
8688
if !bareFile {
8789
return true
8890
}
91+
if componentType == v3.SecuritySchemesLabel {
92+
return isSecuritySchemeNode(node)
93+
}
8994

9095
if detectedType, ok := DetectOpenAPIComponentType(node); ok {
9196
if detectedType == componentType {
@@ -125,6 +130,25 @@ func canComposeContextualReference(componentType string, node *yaml.Node, bareFi
125130
}
126131
}
127132

133+
func isSecuritySchemeNode(node *yaml.Node) bool {
134+
keys := getNodeKeys(node)
135+
typeValue := getNodeValueForKey(node, v3.TypeLabel)
136+
switch typeValue {
137+
case "apiKey":
138+
return containsKey(keys, v3.NameLabel) && containsKey(keys, v3.InLabel)
139+
case "http":
140+
return containsKey(keys, v3.SchemeLabel)
141+
case "oauth2":
142+
return containsKey(keys, v3.FlowsLabel) || containsKey(keys, v3.OAuth2MetadataUrlLabel)
143+
case "openIdConnect":
144+
return containsKey(keys, v3.OpenIdConnectUrlLabel)
145+
case "mutualTLS":
146+
return true
147+
default:
148+
return false
149+
}
150+
}
151+
128152
func unwrapDocumentNode(node *yaml.Node) *yaml.Node {
129153
if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
130154
return node.Content[0]

bundler/source_context_test.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ func TestInferComponentTypeFromSourcePath(t *testing.T) {
132132
wantType: v3.MediaTypesLabel,
133133
wantOK: true,
134134
},
135+
{
136+
name: "security scheme component",
137+
sourcePath: []string{"components", "securitySchemes", "bearerAuth"},
138+
wantType: v3.SecuritySchemesLabel,
139+
wantOK: true,
140+
},
135141
{
136142
name: "unknown path",
137143
sourcePath: []string{"x-private", "thing"},
@@ -249,9 +255,16 @@ func TestCanComposeContextualReference(t *testing.T) {
249255
want: false,
250256
},
251257
{
252-
name: "unknown component type is not composed",
253-
componentType: "securitySchemes",
254-
source: "description: Sparse security",
258+
name: "bare file security scheme accepts HTTP scheme",
259+
componentType: v3.SecuritySchemesLabel,
260+
source: "type: http\nscheme: bearer\n",
261+
bareFile: true,
262+
want: true,
263+
},
264+
{
265+
name: "bare file security scheme rejects schema type",
266+
componentType: v3.SecuritySchemesLabel,
267+
source: "type: string\n",
255268
bareFile: true,
256269
want: false,
257270
},
@@ -271,3 +284,29 @@ func TestCanComposeContextualReference(t *testing.T) {
271284
func TestCanComposeContextualReference_NilNode(t *testing.T) {
272285
assert.False(t, canComposeContextualReference(v3.ResponsesLabel, nil, true))
273286
}
287+
288+
func TestIsSecuritySchemeNode(t *testing.T) {
289+
tests := []struct {
290+
name string
291+
source string
292+
want bool
293+
}{
294+
{name: "api key", source: "type: apiKey\nname: X-API-Key\nin: header\n", want: true},
295+
{name: "http", source: "type: http\nscheme: bearer\n", want: true},
296+
{name: "oauth flows", source: "type: oauth2\nflows: {}\n", want: true},
297+
{name: "oauth metadata", source: "type: oauth2\noauth2MetadataUrl: https://example.com/oauth\n", want: true},
298+
{name: "openid connect", source: "type: openIdConnect\nopenIdConnectUrl: https://example.com/openid\n", want: true},
299+
{name: "mutual TLS", source: "type: mutualTLS\n", want: true},
300+
{name: "incomplete api key", source: "type: apiKey\nname: X-API-Key\n", want: false},
301+
{name: "incomplete HTTP", source: "type: http\n", want: false},
302+
{name: "schema", source: "type: string\n", want: false},
303+
}
304+
305+
for _, tt := range tests {
306+
t.Run(tt.name, func(t *testing.T) {
307+
var document yaml.Node
308+
require.NoError(t, yaml.Unmarshal([]byte(tt.source), &document))
309+
assert.Equal(t, tt.want, isSecuritySchemeNode(unwrapDocumentNode(&document)))
310+
})
311+
}
312+
}

0 commit comments

Comments
 (0)