[object Object]

← 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

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 →