[object Object]

← back to Cli Printing Press

fix(cli): infer bearer auth from inline params (#634)

b4a95cb7d9c803354741dc63ffcc051e4b82d2aa · 2026-05-05 13:43:41 -0700 · Trevin Chow

* fix(cli): infer bearer auth from inline params

* docs(cli): document inline bearer auth inference

Files touched

Diff

commit b4a95cb7d9c803354741dc63ffcc051e4b82d2aa
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 5 13:43:41 2026 -0700

    fix(cli): infer bearer auth from inline params (#634)
    
    * fix(cli): infer bearer auth from inline params
    
    * docs(cli): document inline bearer auth inference
---
 ...horization-param-bearer-inference-2026-05-05.md | 113 +++++++++++++++++++++
 internal/openapi/parser.go                         |  61 +++++++----
 internal/openapi/parser_test.go                    | 101 +++++++++++++++++-
 testdata/openapi/auth-header-param.yaml            |  12 ++-
 4 files changed, 261 insertions(+), 26 deletions(-)

diff --git a/docs/solutions/logic-errors/inline-authorization-param-bearer-inference-2026-05-05.md b/docs/solutions/logic-errors/inline-authorization-param-bearer-inference-2026-05-05.md
new file mode 100644
index 00000000..afe6ab72
--- /dev/null
+++ b/docs/solutions/logic-errors/inline-authorization-param-bearer-inference-2026-05-05.md
@@ -0,0 +1,113 @@
+---
+title: "Inline Authorization params: conservative bearer inference"
+date: 2026-05-05
+category: logic-errors
+module: internal/openapi
+problem_type: logic_error
+component: authentication
+symptoms:
+  - "OpenAPI specs without top-level securitySchemes or security failed to infer bearer_token auth from required operation-level Authorization header params"
+  - "Cal.com-style specs exposed Authorization as inline operation parameters instead of reusable OpenAPI security declarations"
+  - "Generated CLIs could omit bearer auth wiring unless manual Phase 2 enrichment patched auth metadata"
+root_cause: logic_error
+resolution_type: code_fix
+severity: medium
+related_components:
+  - testing_framework
+tags:
+  - openapi-parser
+  - bearer-auth
+  - authorization-header
+  - inline-parameters
+  - security-inference
+---
+
+# Inline Authorization params: conservative bearer inference
+
+## Problem
+
+Some OpenAPI specs declare authentication as a required operation-level `Authorization` header parameter instead of using `components.securitySchemes` or top-level `security`. The parser treated those specs as unauthenticated, so generated CLIs could miss bearer auth setup unless the skill flow manually enriched the spec before generation.
+
+## Symptoms
+
+- Cal.com-style specs had no top-level OpenAPI security declaration, but most operations required an `Authorization` header.
+- Parsed specs could end up with `auth.type: none` even though the API required bearer credentials.
+- Manual auth enrichment became the fallback for a spec shape the parser could infer safely.
+
+## What Didn't Work
+
+- **Description-only auth inference.** Specs may have sparse descriptions, or descriptions may mention bearer tokens in examples, warnings, migration notes, or unrelated prose.
+- **Treating any `Authorization` header as bearer.** Adjacent auth-shape sessions showed that auth-looking fields can be non-bearer, query-key, composed, or session-handshake auth. A permissive "Authorization means bearer" rule would repeat that class of bug (session history).
+- **Running inline inference when explicit security exists.** `securitySchemes` and top-level `security` are the canonical OpenAPI declarations. A fallback must not compete with them.
+
+## Solution
+
+Add a fourth-tier fallback in `mapAuth`: after explicit security schemes, query-param auth, and description auth all fail, infer bearer auth from inline operation parameters only when the evidence is broad and scheme-specific.
+
+The fallback has 4 guards:
+
+1. The spec has no `components.securitySchemes`.
+2. The spec has no top-level `security` declaration.
+3. At least 80% of operations have a required `Authorization` header parameter.
+4. The header parameter or its schema description contains a non-negated Bearer mention.
+
+```go
+func inferOperationLevelBearer(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig {
+    if doc == nil || doc.Paths == nil {
+        return fallback
+    }
+    if hasTopLevelSecurityDeclaration(doc) {
+        return fallback
+    }
+
+    authParamCount := 0
+    hasBearerSignal := false
+    totalOps := 0
+
+    // Count operations with a required Authorization header and require a
+    // non-negated Bearer signal before synthesizing bearer_token auth.
+}
+```
+
+Use the parser's existing helpers instead of creating nearby duplicate semantics:
+
+```go
+func requiredAuthorizationParam(pathItem *openapi3.PathItem, op *openapi3.Operation) (*openapi3.Parameter, bool) {
+    for _, p := range mergeParameters(pathItem, op) {
+        if p.In == openapi3.ParameterInHeader && strings.EqualFold(p.Name, "Authorization") && p.Required {
+            return p, true
+        }
+    }
+    return nil, false
+}
+```
+
+`mergeParameters` preserves the parser's existing path-level and operation-level parameter precedence. `findUnnegated` avoids false positives from descriptions such as `Do not use Bearer prefix.`
+
+## Why This Works
+
+The fix recognizes a real generated-spec pattern without turning auth inference into guesswork. Broad required-header coverage is structural evidence that the API expects auth on most operations. A non-negated Bearer mention on the parameter or schema narrows that auth shape to bearer-token auth.
+
+The explicit-security guard keeps the fallback behind authoritative spec declarations. Reusing `mergeParameters` keeps parameter resolution consistent with the rest of `internal/openapi/parser.go`, so path-level and operation-level params do not drift between inference paths.
+
+## Prevention
+
+- Order auth inference from most authoritative to least authoritative: explicit security schemes, query-param auth, description auth, then inline operation-level bearer inference.
+- Require both structural evidence and textual scheme evidence before inferring bearer auth from header params.
+- Use negation-aware matching for auth-scheme mentions.
+- Reuse parser helpers such as `mergeParameters` when adding inference over OpenAPI parameters.
+- Preserve regression coverage for:
+  - positive Cal.com-style inline `Authorization` params
+  - exact 80% coverage threshold
+  - below-threshold coverage
+  - missing Bearer signal
+  - negated Bearer signal
+  - optional `Authorization` header ignored
+  - explicit `securitySchemes` or `security` winning over fallback inference
+
+## Related Issues
+
+- [mvanhorn/cli-printing-press#600](https://github.com/mvanhorn/cli-printing-press/issues/600) -- direct issue for operation-level Authorization bearer inference
+- [mvanhorn/cli-printing-press#634](https://github.com/mvanhorn/cli-printing-press/pull/634) -- implementation PR
+- [mvanhorn/cli-printing-press#597](https://github.com/mvanhorn/cli-printing-press/issues/597) -- parent Cal.com retro
+- [mvanhorn/cli-printing-press#517](https://github.com/mvanhorn/cli-printing-press/issues/517) -- related Phase 2 auth enrichment umbrella
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index b416e158..ae2a3ff5 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -368,7 +368,7 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 			result = inferDescriptionAuth(doc, name, result)
 		}
 		if result.Type == "none" {
-			result = inferAuthHeaderParam(doc, name, result)
+			result = inferOperationLevelBearer(doc, name, result)
 		}
 		return result
 	}
@@ -969,17 +969,20 @@ func inferDescriptionAuth(doc *openapi3.T, name string, fallback spec.AuthConfig
 	return fallback
 }
 
-// inferAuthHeaderParam scans all operations for required Authorization header
-// parameters. This is the fourth-tier auth fallback — it fires only when
-// securitySchemes, query-param inference, and description inference all fail.
-// APIs like Cal.com declare auth via individual header parameters instead of
-// securitySchemes or description text.
-func inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig {
+// inferOperationLevelBearer scans all operations for required Authorization
+// header parameters that identify themselves as Bearer tokens. This is the
+// fourth-tier auth fallback — it fires only when securitySchemes, query-param
+// inference, and description inference all fail.
+func inferOperationLevelBearer(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig {
 	if doc == nil || doc.Paths == nil {
 		return fallback
 	}
+	if hasTopLevelSecurityDeclaration(doc) {
+		return fallback
+	}
 
 	authParamCount := 0
+	hasBearerSignal := false
 	totalOps := 0
 
 	for _, pathKey := range doc.Paths.InMatchingOrder() {
@@ -992,24 +995,16 @@ func inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig
 				continue
 			}
 			totalOps++
-			for _, params := range []openapi3.Parameters{pathItem.Parameters, op.Parameters} {
-				for _, pRef := range params {
-					if pRef == nil || pRef.Value == nil {
-						continue
-					}
-					p := pRef.Value
-					if p.In == openapi3.ParameterInHeader &&
-						strings.EqualFold(p.Name, "Authorization") &&
-						p.Required {
-						authParamCount++
-						break // count once per operation
-					}
+			if authParam, ok := requiredAuthorizationParam(pathItem, op); ok {
+				authParamCount++
+				if authorizationParamMentionsBearer(authParam) {
+					hasBearerSignal = true
 				}
 			}
 		}
 	}
 
-	if totalOps == 0 || float64(authParamCount)/float64(totalOps) <= 0.3 {
+	if totalOps == 0 || !hasBearerSignal || float64(authParamCount)/float64(totalOps) < 0.8 {
 		return fallback
 	}
 
@@ -1023,6 +1018,32 @@ func inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig
 	}
 }
 
+func hasTopLevelSecurityDeclaration(doc *openapi3.T) bool {
+	return (doc.Components != nil && len(doc.Components.SecuritySchemes) > 0) || doc.Security != nil
+}
+
+func requiredAuthorizationParam(pathItem *openapi3.PathItem, op *openapi3.Operation) (*openapi3.Parameter, bool) {
+	for _, p := range mergeParameters(pathItem, op) {
+		if p.In == openapi3.ParameterInHeader && strings.EqualFold(p.Name, "Authorization") && p.Required {
+			return p, true
+		}
+	}
+	return nil, false
+}
+
+func authorizationParamMentionsBearer(p *openapi3.Parameter) bool {
+	if p == nil {
+		return false
+	}
+	if findUnnegated(strings.ToLower(p.Description), "bearer") {
+		return true
+	}
+	if p.Schema != nil && p.Schema.Value != nil {
+		return findUnnegated(strings.ToLower(p.Schema.Value.Description), "bearer")
+	}
+	return false
+}
+
 // commonCustomHeaders are header names that APIs use instead of Authorization.
 // Checked case-insensitively against the description text.
 var commonCustomHeaders = []string{
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 6b5d4c31..6fa45ce3 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1269,10 +1269,10 @@ paths:
 	assert.Equal(t, "Optional FlightAware AeroAPI credential for enriched flight data.", parsed.Auth.Description)
 }
 
-func TestInferAuthHeaderParam(t *testing.T) {
+func TestInferOperationLevelBearer(t *testing.T) {
 	t.Parallel()
 
-	t.Run("detects auth from required Authorization header params", func(t *testing.T) {
+	t.Run("detects bearer auth from required Authorization header params", func(t *testing.T) {
 		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "auth-header-param.yaml"))
 		require.NoError(t, err)
 
@@ -1287,7 +1287,7 @@ func TestInferAuthHeaderParam(t *testing.T) {
 	})
 
 	t.Run("does not trigger when Authorization params below threshold", func(t *testing.T) {
-		// 1 out of 5 operations = 20% < 30% threshold
+		// 1 out of 5 operations = 20% < 80% threshold
 		doc := &openapi3.T{
 			Info:  &openapi3.Info{Title: "test", Description: "no auth keywords"},
 			Paths: &openapi3.Paths{},
@@ -1299,7 +1299,7 @@ func TestInferAuthHeaderParam(t *testing.T) {
 			if i == 0 { // only first has Authorization param
 				pathItem.Get.Parameters = openapi3.Parameters{
 					&openapi3.ParameterRef{Value: &openapi3.Parameter{
-						Name: "Authorization", In: "header", Required: true,
+						Name: "Authorization", In: "header", Required: true, Description: "Bearer token credential",
 					}},
 				}
 			}
@@ -1309,6 +1309,74 @@ func TestInferAuthHeaderParam(t *testing.T) {
 		assert.Equal(t, "none", result.Type)
 	})
 
+	t.Run("detects auth at exact eighty percent threshold", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info:  &openapi3.Info{Title: "test", Description: "no auth keywords"},
+			Paths: &openapi3.Paths{},
+		}
+		for i, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+			pathItem := &openapi3.PathItem{
+				Get: &openapi3.Operation{Responses: openapi3.NewResponses()},
+			}
+			if i < 4 {
+				pathItem.Get.Parameters = openapi3.Parameters{
+					&openapi3.ParameterRef{Value: &openapi3.Parameter{
+						Name: "Authorization", In: "header", Required: true, Description: "Bearer token credential",
+					}},
+				}
+			}
+			doc.Paths.Set(path, pathItem)
+		}
+		result := mapAuth(doc, "test-api")
+		assert.Equal(t, "bearer_token", result.Type)
+		assert.Equal(t, []string{"TEST_API_TOKEN"}, result.EnvVars)
+		assert.True(t, result.Inferred)
+	})
+
+	t.Run("does not infer bearer without bearer signal", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info:  &openapi3.Info{Title: "test", Description: "no auth keywords"},
+			Paths: &openapi3.Paths{},
+		}
+		for _, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+			pathItem := &openapi3.PathItem{
+				Get: &openapi3.Operation{
+					Responses: openapi3.NewResponses(),
+					Parameters: openapi3.Parameters{
+						&openapi3.ParameterRef{Value: &openapi3.Parameter{
+							Name: "Authorization", In: "header", Required: true,
+						}},
+					},
+				},
+			}
+			doc.Paths.Set(path, pathItem)
+		}
+		result := mapAuth(doc, "test-api")
+		assert.Equal(t, "none", result.Type)
+	})
+
+	t.Run("does not infer bearer from negated bearer signal", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info:  &openapi3.Info{Title: "test", Description: "no auth keywords"},
+			Paths: &openapi3.Paths{},
+		}
+		for _, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+			pathItem := &openapi3.PathItem{
+				Get: &openapi3.Operation{
+					Responses: openapi3.NewResponses(),
+					Parameters: openapi3.Parameters{
+						&openapi3.ParameterRef{Value: &openapi3.Parameter{
+							Name: "Authorization", In: "header", Required: true, Description: "Do not use Bearer prefix.",
+						}},
+					},
+				},
+			}
+			doc.Paths.Set(path, pathItem)
+		}
+		result := mapAuth(doc, "test-api")
+		assert.Equal(t, "none", result.Type)
+	})
+
 	t.Run("optional Authorization param not counted", func(t *testing.T) {
 		doc := &openapi3.T{
 			Info:  &openapi3.Info{Title: "test", Description: "no auth keywords"},
@@ -1320,7 +1388,30 @@ func TestInferAuthHeaderParam(t *testing.T) {
 					Responses: openapi3.NewResponses(),
 					Parameters: openapi3.Parameters{
 						&openapi3.ParameterRef{Value: &openapi3.Parameter{
-							Name: "Authorization", In: "header", Required: false,
+							Name: "Authorization", In: "header", Required: false, Description: "Bearer token credential",
+						}},
+					},
+				},
+			}
+			doc.Paths.Set(path, pathItem)
+		}
+		result := mapAuth(doc, "test-api")
+		assert.Equal(t, "none", result.Type)
+	})
+
+	t.Run("top-level security declaration disables inline inference", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info:     &openapi3.Info{Title: "test", Description: "no auth keywords"},
+			Paths:    &openapi3.Paths{},
+			Security: openapi3.SecurityRequirements{},
+		}
+		for _, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+			pathItem := &openapi3.PathItem{
+				Get: &openapi3.Operation{
+					Responses: openapi3.NewResponses(),
+					Parameters: openapi3.Parameters{
+						&openapi3.ParameterRef{Value: &openapi3.Parameter{
+							Name: "Authorization", In: "header", Required: true, Description: "Bearer token credential",
 						}},
 					},
 				},
diff --git a/testdata/openapi/auth-header-param.yaml b/testdata/openapi/auth-header-param.yaml
index a9512b54..51137bf4 100644
--- a/testdata/openapi/auth-header-param.yaml
+++ b/testdata/openapi/auth-header-param.yaml
@@ -14,6 +14,7 @@ paths:
         - name: Authorization
           in: header
           required: true
+          description: "Bearer token credential"
           schema:
             type: string
       responses:
@@ -32,6 +33,7 @@ paths:
         - name: Authorization
           in: header
           required: true
+          description: "Bearer token credential"
           schema:
             type: string
       responses:
@@ -45,6 +47,7 @@ paths:
         - name: Authorization
           in: header
           required: true
+          description: "Bearer token credential"
           schema:
             type: string
       responses:
@@ -53,7 +56,14 @@ paths:
   /v2/health:
     get:
       operationId: healthCheck
-      summary: Health check (no auth)
+      summary: Health check
+      parameters:
+        - name: Authorization
+          in: header
+          required: true
+          description: "Bearer token credential"
+          schema:
+            type: string
       responses:
         "200":
           description: OK

← 6b5945fd fix(cli): preserve canonical auth env hints (#633)  ·  back to Cli Printing Press  ·  fix(cli): require explicit retry no-ops (#635) cb3ef873 →