← back to Cli Printing Press
fix(cli): prioritize bearer + apply root-security filter in scheme selection (#1238)
09d72e68455d0ff1b720d6de369349d45c30112a · 2026-05-12 13:01:23 -0700 · Trevin Chow
* fix(cli): prioritize bearer + apply root-security filter in scheme selection
Multi-scheme OpenAPI specs were picking the wrong primary scheme. Cloudflare-
shape specs selected legacy `api_email` (apiKey-header) over the modern
`api_token` (http+bearer), and Datadog-shape specs selected a components-only
OAuth2 marketplace scheme even when the document's root `security:` only named
the apiKey pair.
Replace the 3-pass walk in `selectSecurityScheme` with a single-pass typed
priority pick and gate the candidate pool on root security when present:
http+bearer > oauth2+cc > oauth2+authcode (both URLs) > oauth2+implicit
(auth URL) > apiKey-header > apiKey-query > http+basic > apiKey-cookie >
other. When `doc.Security` references at least one scheme, only those schemes
are candidates; otherwise fall back to all components alphabetically.
Single-scheme specs and the well-formed-OAuth2 preference (gmail) are
preserved by construction; goldens are unchanged.
Refs #979
* fix(cli): handle OAuth2 Password flow + mark scheme-test subtests parallel
PR #1238 review feedback. The new priority selector skipped the OAuth2
Password (ROPC) flow, scoring it as malformed (800) — below every apiKey
shape. The prior 3-pass selector returned any well-formed oauth2 before any
apiKey, so multi-scheme specs that pair ROPC with an apiKey alternative
would have silently regressed to the apiKey scheme.
Score Password between AuthorizationCode (200) and Implicit (300) at 250.
ROPC is deprecated but kept above all apiKey shapes for parity with the
prior behavior; CC and AuthCode still outrank it. Add a regression test.
Also add t.Parallel() to each subtest of the single-scheme table test for
consistency with every other parallel test in this file.
Refs #979
Files touched
M internal/openapi/parser.goM internal/openapi/parser_test.go
Diff
commit 09d72e68455d0ff1b720d6de369349d45c30112a
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 12 13:01:23 2026 -0700
fix(cli): prioritize bearer + apply root-security filter in scheme selection (#1238)
* fix(cli): prioritize bearer + apply root-security filter in scheme selection
Multi-scheme OpenAPI specs were picking the wrong primary scheme. Cloudflare-
shape specs selected legacy `api_email` (apiKey-header) over the modern
`api_token` (http+bearer), and Datadog-shape specs selected a components-only
OAuth2 marketplace scheme even when the document's root `security:` only named
the apiKey pair.
Replace the 3-pass walk in `selectSecurityScheme` with a single-pass typed
priority pick and gate the candidate pool on root security when present:
http+bearer > oauth2+cc > oauth2+authcode (both URLs) > oauth2+implicit
(auth URL) > apiKey-header > apiKey-query > http+basic > apiKey-cookie >
other. When `doc.Security` references at least one scheme, only those schemes
are candidates; otherwise fall back to all components alphabetically.
Single-scheme specs and the well-formed-OAuth2 preference (gmail) are
preserved by construction; goldens are unchanged.
Refs #979
* fix(cli): handle OAuth2 Password flow + mark scheme-test subtests parallel
PR #1238 review feedback. The new priority selector skipped the OAuth2
Password (ROPC) flow, scoring it as malformed (800) — below every apiKey
shape. The prior 3-pass selector returned any well-formed oauth2 before any
apiKey, so multi-scheme specs that pair ROPC with an apiKey alternative
would have silently regressed to the apiKey scheme.
Score Password between AuthorizationCode (200) and Implicit (300) at 250.
ROPC is deprecated but kept above all apiKey shapes for parity with the
prior behavior; CC and AuthCode still outrank it. Add a regression test.
Also add t.Parallel() to each subtest of the single-scheme table test for
consistency with every other parallel test in this file.
Refs #979
---
internal/openapi/parser.go | 148 ++++++++++++++++++---------
internal/openapi/parser_test.go | 222 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 323 insertions(+), 47 deletions(-)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index d3384cc9..0986df19 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"maps"
+ "math"
"net/url"
"os"
"path/filepath"
@@ -1515,76 +1516,129 @@ func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
return "", nil
}
- orderedNames := orderedSecuritySchemeNames(doc)
- for _, name := range orderedNames {
- scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
- if scheme == nil || !strings.EqualFold(scheme.Type, "oauth2") || scheme.Flows == nil {
- continue
- }
- if ac := scheme.Flows.AuthorizationCode; ac != nil && strings.TrimSpace(ac.AuthorizationURL) != "" && strings.TrimSpace(ac.TokenURL) != "" {
- return name, scheme
- }
- }
+ candidates := candidateSecuritySchemeNames(doc)
- for _, name := range orderedNames {
+ bestScore := math.MaxInt
+ var bestName string
+ var bestScheme *openapi3.SecurityScheme
+
+ for _, name := range candidates {
scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
if scheme == nil {
continue
}
- switch strings.ToLower(scheme.Type) {
- case "apikey", "oauth2":
- return name, scheme
- case "http":
- switch strings.ToLower(scheme.Scheme) {
- case "bearer", "basic":
- return name, scheme
- }
- }
- }
-
- for _, name := range orderedNames {
- scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
- if scheme != nil {
- return name, scheme
+ score := schemePriorityScore(scheme)
+ if score < bestScore {
+ bestScore = score
+ bestName = name
+ bestScheme = scheme
}
}
- return "", nil
+ return bestName, bestScheme
}
-func orderedSecuritySchemeNames(doc *openapi3.T) []string {
+// Root security is authoritative when present: a components-only scheme
+// (e.g. an OAuth2 marketplace flow the API doesn't actually accept at the
+// document default) must not outrank an apiKey the spec explicitly named.
+// The empty-names guard handles `security: []` and `security: [{}]`
+// no-auth declarations, where falling back to components recovers the
+// previous behavior for inferred-auth specs.
+func candidateSecuritySchemeNames(doc *openapi3.T) []string {
seen := map[string]struct{}{}
var names []string
- for _, requirement := range doc.Security {
- var requirementNames []string
- for name := range requirement {
- requirementNames = append(requirementNames, name)
- }
- sort.Strings(requirementNames)
- for _, name := range requirementNames {
- if _, ok := seen[name]; ok {
- continue
+ if doc.Security != nil {
+ for _, requirement := range doc.Security {
+ var requirementNames []string
+ for name := range requirement {
+ requirementNames = append(requirementNames, name)
+ }
+ sort.Strings(requirementNames)
+ for _, name := range requirementNames {
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
}
- seen[name] = struct{}{}
- names = append(names, name)
+ }
+ if len(names) > 0 {
+ return names
}
}
- var all []string
+ all := make([]string, 0, len(doc.Components.SecuritySchemes))
for name := range doc.Components.SecuritySchemes {
all = append(all, name)
}
sort.Strings(all)
- for _, name := range all {
- if _, ok := seen[name]; ok {
- continue
+ return all
+}
+
+// Ordering rationale: Bearer is the simplest for CLI use; OAuth2 flows rank
+// by viability for non-interactive runs (cc > authorization_code > password >
+// implicit); apiKey-in-header beats apiKey-in-query because query strings leak
+// to logs; HTTP Basic ranks below standalone apiKey because compound legacy
+// schemes (email + key) surface as basic-ish patterns and shouldn't outrank a
+// modern apiKey alternative when both are offered. Password (ROPC) is
+// deprecated but kept above all apiKey shapes so multi-scheme specs that
+// pair ROPC with an apiKey alternative don't regress vs. the prior selector,
+// which returned any well-formed oauth2 before any apiKey.
+const (
+ schemePriorityBearer = 0
+ schemePriorityOAuth2CC = 100
+ schemePriorityOAuth2AuthCode = 200
+ schemePriorityOAuth2Password = 250
+ schemePriorityOAuth2Implicit = 300
+ schemePriorityAPIKeyHeader = 400
+ schemePriorityAPIKeyQuery = 450
+ schemePriorityHTTPBasic = 500
+ schemePriorityAPIKeyCookie = 600
+ schemePriorityAPIKeyOther = 700
+ schemePriorityOAuth2Malformed = 800
+ schemePriorityHTTPOther = 900
+ schemePriorityUnknown = 1000
+)
+
+func schemePriorityScore(scheme *openapi3.SecurityScheme) int {
+ switch strings.ToLower(scheme.Type) {
+ case "http":
+ switch strings.ToLower(scheme.Scheme) {
+ case "bearer":
+ return schemePriorityBearer
+ case "basic":
+ return schemePriorityHTTPBasic
}
- seen[name] = struct{}{}
- names = append(names, name)
+ return schemePriorityHTTPOther
+ case "oauth2":
+ if scheme.Flows != nil {
+ if cc := scheme.Flows.ClientCredentials; cc != nil && strings.TrimSpace(cc.TokenURL) != "" {
+ return schemePriorityOAuth2CC
+ }
+ if ac := scheme.Flows.AuthorizationCode; ac != nil && strings.TrimSpace(ac.AuthorizationURL) != "" && strings.TrimSpace(ac.TokenURL) != "" {
+ return schemePriorityOAuth2AuthCode
+ }
+ if pw := scheme.Flows.Password; pw != nil && strings.TrimSpace(pw.TokenURL) != "" {
+ return schemePriorityOAuth2Password
+ }
+ if ic := scheme.Flows.Implicit; ic != nil && strings.TrimSpace(ic.AuthorizationURL) != "" {
+ return schemePriorityOAuth2Implicit
+ }
+ }
+ return schemePriorityOAuth2Malformed
+ case "apikey":
+ switch strings.ToLower(scheme.In) {
+ case "header":
+ return schemePriorityAPIKeyHeader
+ case "query":
+ return schemePriorityAPIKeyQuery
+ case "cookie":
+ return schemePriorityAPIKeyCookie
+ }
+ return schemePriorityAPIKeyOther
}
-
- return names
+ return schemePriorityUnknown
}
func securitySchemeValue(ref *openapi3.SecuritySchemeRef) *openapi3.SecurityScheme {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 1e6974aa..30324c9d 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1020,6 +1020,228 @@ paths:
assert.Equal(t, []string{"SENTRY_AUTH_TOKEN"}, parsed.Auth.EnvVars)
}
+func TestSelectSecuritySchemeMultiSchemePrefersBearer(t *testing.T) {
+ t.Parallel()
+
+ // Cloudflare-shape: a spec advertises a modern http+bearer scheme
+ // alongside a legacy email + apiKey pair. The parser must pick the
+ // bearer scheme so the generated CLI reads CF_API_TOKEN and sets
+ // Authorization: Bearer, not X-Auth-Email.
+ spec := []byte(`openapi: "3.0.3"
+info:
+ title: Cloudflare-shape
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+security:
+ - api_email: []
+ api_key: []
+ - api_token: []
+components:
+ securitySchemes:
+ api_email:
+ type: apiKey
+ in: header
+ name: X-Auth-Email
+ api_key:
+ type: apiKey
+ in: header
+ name: X-Auth-Key
+ api_token:
+ type: http
+ scheme: bearer
+paths:
+ /v1/zones:
+ get:
+ operationId: listZones
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(spec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type, "bearer outranks apiKey-header")
+ assert.Equal(t, "Authorization", parsed.Auth.Header)
+ assert.Equal(t, "api_token", parsed.Auth.Scheme, "selected scheme name must surface for downstream env-var derivation")
+}
+
+func TestSelectSecuritySchemeRespectsRootSecurityFilter(t *testing.T) {
+ t.Parallel()
+
+ // Datadog-shape: components declares an OAuth2 marketplace scheme
+ // alongside the primary apiKey scheme, but root-level security only
+ // references the apiKey. The parser must restrict candidates to the
+ // root-declared schemes so the marketplace OAuth2 does not win pass 1.
+ spec := []byte(`openapi: "3.0.3"
+info:
+ title: Datadog-shape
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+security:
+ - apiKeyAuth: []
+components:
+ securitySchemes:
+ AuthZ:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://api.example.com/oauth2/authorize
+ tokenUrl: https://api.example.com/oauth2/token
+ scopes:
+ read: read access
+ apiKeyAuth:
+ type: apiKey
+ in: header
+ name: DD-API-KEY
+paths:
+ /v1/series:
+ get:
+ operationId: queryMetrics
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(spec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "api_key", parsed.Auth.Type, "components-only OAuth2 must not outrank root-declared apiKey")
+ assert.Equal(t, "DD-API-KEY", parsed.Auth.Header)
+ assert.Equal(t, "apiKeyAuth", parsed.Auth.Scheme)
+}
+
+func TestSelectSecuritySchemeFallsBackToComponentsWhenRootSecurityEmpty(t *testing.T) {
+ t.Parallel()
+
+ // A spec with `security: []` (or only `{}` no-auth requirements at
+ // root) does not constrain candidates — fall back to all schemes in
+ // components, sorted alphabetically, and apply the type priority.
+ spec := []byte(`openapi: "3.0.3"
+info:
+ title: NoRootSecurity
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ LegacyAPIKey:
+ type: apiKey
+ in: header
+ name: X-Legacy-Key
+ ModernBearer:
+ type: http
+ scheme: bearer
+paths:
+ /v1/items:
+ get:
+ operationId: listItems
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(spec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type, "type priority still applies when root security is absent")
+ assert.Equal(t, "ModernBearer", parsed.Auth.Scheme)
+}
+
+func TestSelectSecuritySchemeSingleSchemeUnchanged(t *testing.T) {
+ t.Parallel()
+
+ // Negative case from the issue acceptance criteria: single-scheme specs
+ // must continue to emit identical output.
+ specTemplate := `openapi: "3.0.3"
+info: {title: X, version: "1.0"}
+servers: [{url: https://api.example.com}]
+security: [{Only: []}]
+components:
+ securitySchemes:
+ Only:
+%s
+paths:
+ /v1/x: {get: {operationId: getX, responses: {"200": {description: ok}}}}
+`
+
+ cases := []struct {
+ name string
+ scheme string
+ wantType string
+ wantHdr string
+ }{
+ {
+ name: "apiKey only",
+ scheme: " type: apiKey\n in: header\n name: X-API-Key",
+ wantType: "api_key",
+ wantHdr: "X-API-Key",
+ },
+ {
+ name: "bearer only",
+ scheme: " type: http\n scheme: bearer",
+ wantType: "bearer_token",
+ wantHdr: "Authorization",
+ },
+ {
+ name: "basic only",
+ scheme: " type: http\n scheme: basic",
+ wantType: "api_key",
+ wantHdr: "Authorization",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ parsed, err := Parse(fmt.Appendf(nil, specTemplate, tc.scheme))
+ require.NoError(t, err)
+ assert.Equal(t, tc.wantType, parsed.Auth.Type)
+ assert.Equal(t, tc.wantHdr, parsed.Auth.Header)
+ })
+ }
+}
+
+func TestSelectSecuritySchemeOAuth2PasswordBeatsAPIKey(t *testing.T) {
+ t.Parallel()
+
+ // OAuth2 Password (ROPC) is deprecated but real specs still use it for
+ // server-to-server flows alongside an apiKey alternative. The old 3-pass
+ // selector returned any well-formed oauth2 before any apiKey; the new
+ // priority-score selector must preserve that ordering or such specs
+ // regress silently to the apiKey scheme. See PR #1238 review feedback.
+ spec := []byte(`openapi: "3.0.3"
+info:
+ title: ROPC-shape
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+security:
+ - ropcAuth: []
+ - apiKeyAuth: []
+components:
+ securitySchemes:
+ ropcAuth:
+ type: oauth2
+ flows:
+ password:
+ tokenUrl: https://api.example.com/oauth/token
+ scopes:
+ read: read access
+ apiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+paths:
+ /v1/items:
+ get:
+ operationId: listItems
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(spec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type, "well-formed ROPC oauth2 must outrank co-declared apiKey")
+ assert.Equal(t, "ropcAuth", parsed.Auth.Scheme)
+}
+
func TestSkipUnderscoreFields(t *testing.T) {
spec := []byte(`
openapi: "3.0.0"
← 5c359ba6 fix(cli): gate refreshAccessToken emission on Auth.TokenURL
·
back to Cli Printing Press
·
fix(ci): fold skill docs into lint gate (#1226) 7010c102 →