← back to Cli Printing Press
fix(cli): infer bearer auth from prose-only specs (#753)
92d303ac75d26944c91ffa8a21eb09d05cb909f3 · 2026-05-08 16:15:39 -0700 · Trevin Chow
Files touched
M docs/SPEC-EXTENSIONS.mdM internal/generator/generator_test.goM internal/openapi/description_normalize.goM internal/openapi/parser.goM internal/openapi/parser_test.goA testdata/openapi/prose-bearer-auth.yaml
Diff
commit 92d303ac75d26944c91ffa8a21eb09d05cb909f3
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 8 16:15:39 2026 -0700
fix(cli): infer bearer auth from prose-only specs (#753)
---
docs/SPEC-EXTENSIONS.md | 10 ++
internal/generator/generator_test.go | 28 +++++
internal/openapi/description_normalize.go | 48 ++++++-
internal/openapi/parser.go | 16 ++-
internal/openapi/parser_test.go | 200 ++++++++++++++++++++++++++++++
testdata/openapi/prose-bearer-auth.yaml | 15 +++
6 files changed, 308 insertions(+), 9 deletions(-)
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 7265f76f..ce4b4a47 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -224,6 +224,16 @@ Security scheme extensions are read from
auth or override install/config metadata when the API spec's service identity
differs from the product identity exposed by the printed CLI.
+When `components.securitySchemes` is absent, the parser may infer simple
+bearer auth from clear API-wide prose such as `Authorization: Bearer`,
+`personal access token`, `fine-grained PAT`, `app installation token`, or
+`OAuth app token`. An explicitly empty block disables that prose fallback:
+
+```yaml
+components:
+ securitySchemes: {}
+```
+
### `x-auth-type`
Marks an API key scheme as composed auth.
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b5c4d6a0..7f6277cb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -622,6 +622,34 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
_, err = os.Stat(filepath.Join(outputDir, "internal", "cli", "auth.go"))
require.NoError(t, err)
})
+
+ t.Run("OpenAPI prose-inferred bearer spec generates simple auth command", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "prose-bearer-auth.yaml"))
+ require.NoError(t, err)
+
+ apiSpec, err := openapi.Parse(data)
+ require.NoError(t, err)
+ require.Equal(t, "bearer_token", apiSpec.Auth.Type)
+
+ // Existing golden fixtures pin simple-auth template output; this test
+ // covers the new OpenAPI inference path into that generated surface.
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ _, err = os.Stat(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+ require.NoError(t, err)
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(configGo), "GITHUB_TOKEN")
+
+ binaryPath := filepath.Join(outputDir, naming.CLI(apiSpec.Name))
+ runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/"+naming.CLI(apiSpec.Name))
+ helpOut, err := exec.Command(binaryPath, "auth", "--help").CombinedOutput()
+ require.NoError(t, err, string(helpOut))
+ assert.Contains(t, string(helpOut), "status")
+ assert.Contains(t, string(helpOut), "set-token")
+ assert.Contains(t, string(helpOut), "logout")
+ })
}
func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
diff --git a/internal/openapi/description_normalize.go b/internal/openapi/description_normalize.go
index 42054752..0fd82102 100644
--- a/internal/openapi/description_normalize.go
+++ b/internal/openapi/description_normalize.go
@@ -30,17 +30,53 @@ import (
// error and callers fall back to the original bytes — which preserves
// kin-openapi's existing error reporting for genuinely malformed input.
func normalizeSpecData(data []byte) ([]byte, error) {
- var root any
- if err := yaml.Unmarshal(data, &root); err != nil {
- return nil, fmt.Errorf("normalize spec: yaml unmarshal: %w", err)
+ normalized, _, err := normalizeSpecDataWithMetadata(data)
+ return normalized, err
+}
+
+type specDataMetadata struct {
+ explicitEmptySecuritySchemes bool
+}
+
+func normalizeSpecDataWithMetadata(data []byte) ([]byte, specDataMetadata, error) {
+ root, err := decodeSpecTree(data)
+ if err != nil {
+ return nil, specDataMetadata{}, err
+ }
+ metadata := specDataMetadata{
+ explicitEmptySecuritySchemes: treeHasExplicitEmptySecuritySchemes(root),
}
- root = convertToStringKeyed(root)
normalizeSpecTree(root, "", false)
out, err := json.Marshal(root)
if err != nil {
- return nil, fmt.Errorf("normalize spec: json marshal: %w", err)
+ return nil, specDataMetadata{}, fmt.Errorf("normalize spec: json marshal: %w", err)
+ }
+ return out, metadata, nil
+}
+
+func decodeSpecTree(data []byte) (any, error) {
+ var root any
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("normalize spec: yaml unmarshal: %w", err)
+ }
+ return convertToStringKeyed(root), nil
+}
+
+func treeHasExplicitEmptySecuritySchemes(root any) bool {
+ rootMap, ok := root.(map[string]any)
+ if !ok {
+ return false
+ }
+ components, ok := rootMap["components"].(map[string]any)
+ if !ok {
+ return false
+ }
+ raw, ok := components["securitySchemes"]
+ if !ok {
+ return false
}
- return out, nil
+ schemes, ok := raw.(map[string]any)
+ return ok && len(schemes) == 0
}
// convertToStringKeyed walks a value decoded by gopkg.in/yaml.v3 and rewrites
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 8d1a791f..b4bf6c44 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -201,8 +201,10 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
}
func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APISpec, error) {
- if normalized, err := normalizeSpecData(data); err == nil {
+ var metadata specDataMetadata
+ if normalized, meta, err := normalizeSpecDataWithMetadata(data); err == nil {
data = normalized
+ metadata = meta
}
doc, err := loadOpenAPIDoc(data, lenient, location)
if err != nil {
@@ -319,7 +321,7 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
baseURL = "https://api.example.com"
}
- auth := mapAuth(doc, name)
+ auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes)
if auth.Type != "none" && allOperationsAllowAnonymous(doc) {
auth = spec.AuthConfig{Type: "none"}
}
@@ -431,11 +433,15 @@ func lookupOpenAPIInfoExtension(doc *openapi3.T, key string) (any, bool) {
}
func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
+ return mapAuthWithDescriptionInference(doc, name, true)
+}
+
+func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescriptionInference bool) spec.AuthConfig {
auth := spec.AuthConfig{Type: "none"}
schemeName, scheme := selectSecurityScheme(doc)
if scheme == nil {
result := inferQueryParamAuth(doc, name, auth)
- if result.Type == "none" {
+ if result.Type == "none" && allowDescriptionInference {
result = inferDescriptionAuth(doc, name, result)
}
if result.Type == "none" {
@@ -1161,6 +1167,10 @@ var bearerKeywords = []string{
"bearer",
"access token",
"auth token",
+ "app installation token",
+ "fine-grained pat",
+ "oauth app token",
+ "personal access token",
}
// apiKeyKeywords indicate API-key auth when found in info.description.
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 472b541c..f598dd11 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1657,6 +1657,206 @@ func TestInferDescriptionAuth(t *testing.T) {
assert.Contains(t, parsed.Auth.EnvVars[0], "_TOKEN")
})
+ t.Run("GitHub-style token prose infers bearer auth", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "prose-bearer-auth.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.Equal(t, "Authorization", parsed.Auth.Header)
+ assert.Equal(t, []string{"GITHUB_TOKEN"}, parsed.Auth.EnvVars)
+ assert.True(t, parsed.Auth.Inferred)
+ })
+
+ t.Run("specific bearer prose signals infer bearer auth independently", func(t *testing.T) {
+ tests := []struct {
+ name string
+ description string
+ }{
+ {name: "personal access token", description: "Authenticate with a personal access token."},
+ {name: "fine-grained PAT", description: "Authenticate with a fine-grained PAT."},
+ {name: "app installation token", description: "Authenticate with an app installation token."},
+ {name: "OAuth app token", description: "Authenticate with an OAuth app token."},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := inferDescriptionAuth(&openapi3.T{
+ Info: &openapi3.Info{Description: tt.description},
+ }, "github", spec.AuthConfig{Type: "none"})
+
+ assert.Equal(t, "bearer_token", result.Type)
+ assert.Equal(t, []string{"GITHUB_TOKEN"}, result.EnvVars)
+ assert.True(t, result.Inferred)
+ })
+ }
+ })
+
+ t.Run("explicit empty securitySchemes opts out of prose inference", func(t *testing.T) {
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: GitHub
+ version: "1.0.0"
+ description: "Authenticate requests with Authorization: Bearer TOKEN."
+components:
+ securitySchemes: {}
+ schemas:
+ Repository:
+ type: object
+paths:
+ /repos:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "none", parsed.Auth.Type)
+ assert.False(t, parsed.Auth.Inferred)
+ })
+
+ t.Run("explicit empty securitySchemes keeps query-param inference", func(t *testing.T) {
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Example
+ version: "1.0.0"
+components:
+ securitySchemes: {}
+paths:
+ /a:
+ get:
+ parameters:
+ - name: api_key
+ in: query
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /b:
+ get:
+ parameters:
+ - name: api_key
+ in: query
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /c:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "api_key", parsed.Auth.Type)
+ assert.Equal(t, "query", parsed.Auth.In)
+ assert.Equal(t, []string{"EXAMPLE_API_KEY"}, parsed.Auth.EnvVars)
+ })
+
+ t.Run("explicit empty securitySchemes keeps operation-level bearer inference", func(t *testing.T) {
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Example
+ version: "1.0.0"
+components:
+ securitySchemes: {}
+paths:
+ /a:
+ get:
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ description: Bearer token credential.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /b:
+ get:
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ description: Bearer token credential.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /c:
+ get:
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ description: Bearer token credential.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /d:
+ get:
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ description: Bearer token credential.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /e:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.Equal(t, "Authorization", parsed.Auth.Header)
+ assert.Equal(t, []string{"EXAMPLE_TOKEN"}, parsed.Auth.EnvVars)
+ assert.True(t, parsed.Auth.Inferred)
+ })
+
+ t.Run("components without securitySchemes still allows prose inference", func(t *testing.T) {
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: GitHub
+ version: "1.0.0"
+ description: "Authenticate requests with Authorization: Bearer TOKEN."
+components:
+ schemas:
+ Repository:
+ type: object
+paths:
+ /repos:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.Equal(t, []string{"GITHUB_TOKEN"}, parsed.Auth.EnvVars)
+ assert.True(t, parsed.Auth.Inferred)
+ })
+
t.Run("petstore has explicit auth, not inferred", func(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
require.NoError(t, err)
diff --git a/testdata/openapi/prose-bearer-auth.yaml b/testdata/openapi/prose-bearer-auth.yaml
new file mode 100644
index 00000000..acc38cd3
--- /dev/null
+++ b/testdata/openapi/prose-bearer-auth.yaml
@@ -0,0 +1,15 @@
+openapi: "3.0.3"
+info:
+ title: GitHub
+ version: "1.0.0"
+ description: Authenticate requests with a fine-grained PAT or OAuth app token.
+components:
+ schemas:
+ Repository:
+ type: object
+paths:
+ /repos:
+ get:
+ responses:
+ "200":
+ description: OK
← 7990021b Add issue ownership guidance to AGENTS.md (#754)
·
back to Cli Printing Press
·
ci(cli): add Mergify merge queue (#757) ecb2cba0 →