[object Object]

← back to Cli Printing Press

fix(cli): preserve canonical auth env hints (#633)

6b5945fdec5af4255b655ebda6a20faf4305b990 · 2026-05-05 13:32:44 -0700 · Trevin Chow

Files touched

Diff

commit 6b5945fdec5af4255b655ebda6a20faf4305b990
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 5 13:32:44 2026 -0700

    fix(cli): preserve canonical auth env hints (#633)
---
 docs/SPEC-EXTENSIONS.md         |  16 +++++++
 internal/openapi/parser.go      |  85 ++++++++++++++++++++++++++++------
 internal/openapi/parser_test.go | 100 ++++++++++++++++++++++++++++++++++++++++
 skills/printing-press/SKILL.md  |   5 ++
 4 files changed, 193 insertions(+), 13 deletions(-)

diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 4a54c31e..b971dd28 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -20,6 +20,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-auth-env-vars` | `components.securitySchemes.<name>` | `APISpec.Auth.EnvVars` | No |
+| `x-speakeasy-example` | `components.securitySchemes.<name>` | `APISpec.Auth.EnvVars` | No |
 | `x-auth-optional` | `components.securitySchemes.<name>` | `APISpec.Auth.Optional` | No |
 | `x-auth-key-url` | `components.securitySchemes.<name>` | `APISpec.Auth.KeyURL` | No |
 | `x-auth-title` | `components.securitySchemes.<name>` | `APISpec.Auth.Title` | No |
@@ -239,6 +240,21 @@ Rules:
 - When at least one non-empty item is present, the list replaces the parser's
   generated env var names.
 
+### `x-speakeasy-example`
+
+Uses a Speakeasy security-scheme example as the credential environment variable
+name when it is shaped like a shell env var.
+
+Parsed field: `APISpec.Auth.EnvVars`
+
+Rules:
+- Optional.
+- Must be a string shaped like an uppercase environment variable name, for
+  example `DUB_API_KEY`.
+- Ignored when `x-auth-env-vars` is present.
+- Ignored when the selected auth config has multiple env vars.
+- Ignored when the value looks like a token value instead of an env var name.
+
 ### `x-auth-optional`
 
 Marks the credential as optional for install/config surfaces.
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0cbfca24..b416e158 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -27,17 +27,18 @@ var (
 )
 
 const (
-	extensionAuthEnvVars     = "x-auth-env-vars"
-	extensionAuthOptional    = "x-auth-optional"
-	extensionAuthKeyURL      = "x-auth-key-url"
-	extensionAuthTitle       = "x-auth-title"
-	extensionAuthDescription = "x-auth-description"
-	extensionTierRouting     = "x-tier-routing"
-	extensionTier            = "x-tier"
-	extensionAPIName         = "x-api-name"
-	extensionDisplayName     = "x-display-name"
-	extensionWebsite         = "x-website"
-	extensionProxyRoutes     = "x-proxy-routes"
+	extensionAuthEnvVars      = "x-auth-env-vars"
+	extensionAuthOptional     = "x-auth-optional"
+	extensionAuthKeyURL       = "x-auth-key-url"
+	extensionAuthTitle        = "x-auth-title"
+	extensionAuthDescription  = "x-auth-description"
+	extensionSpeakeasyExample = "x-speakeasy-example"
+	extensionTierRouting      = "x-tier-routing"
+	extensionTier             = "x-tier"
+	extensionAPIName          = "x-api-name"
+	extensionDisplayName      = "x-display-name"
+	extensionWebsite          = "x-website"
+	extensionProxyRoutes      = "x-proxy-routes"
 )
 
 // SetMaxResources overrides the default resource limit. When not called,
@@ -490,7 +491,7 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 func isGenericAPIKeySchemeSuffix(suffix string) bool {
 	normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(suffix)), "_", "")
 	switch normalized {
-	case "", "apikey", "apikeyauth":
+	case "", "auth", "authentication", "apikey", "apikeyauth", "default":
 		return true
 	}
 	for _, prefix := range []string{"apikeyv", "apikeyauthv"} {
@@ -517,7 +518,11 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 		return
 	}
 	if envVars := stringListExtension(extensions, extensionAuthEnvVars); len(envVars) > 0 {
-		auth.EnvVars = envVars
+		applyAuthEnvVars(auth, envVars)
+	} else if len(auth.EnvVars) == 1 {
+		if envVar := envVarExtension(extensions, extensionSpeakeasyExample); envVar != "" {
+			applyAuthEnvVars(auth, []string{envVar})
+		}
 	}
 	if optional, ok := boolExtension(extensions, extensionAuthOptional); ok {
 		auth.Optional = optional
@@ -533,6 +538,60 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 	}
 }
 
+func applyAuthEnvVars(auth *spec.AuthConfig, envVars []string) {
+	oldEnvVars := append([]string(nil), auth.EnvVars...)
+	auth.EnvVars = envVars
+	remapAuthFormatForEnvOverride(auth, oldEnvVars, envVars)
+}
+
+func remapAuthFormatForEnvOverride(auth *spec.AuthConfig, oldEnvVars, newEnvVars []string) {
+	if auth.Format == "" || len(oldEnvVars) != 1 || len(newEnvVars) != 1 {
+		return
+	}
+	oldPlaceholder := naming.EnvVarPlaceholder(oldEnvVars[0])
+	newPlaceholder := naming.EnvVarPlaceholder(newEnvVars[0])
+	if oldPlaceholder == "" || newPlaceholder == "" {
+		return
+	}
+	auth.Format = strings.ReplaceAll(auth.Format, "{"+oldPlaceholder+"}", "{"+newPlaceholder+"}")
+	auth.Format = strings.ReplaceAll(auth.Format, "{"+oldEnvVars[0]+"}", "{"+newPlaceholder+"}")
+}
+
+func envVarExtension(extensions map[string]any, name string) string {
+	value, ok := extensions[name]
+	if !ok {
+		return ""
+	}
+	s, ok := value.(string)
+	if !ok {
+		return ""
+	}
+	s = strings.TrimSpace(s)
+	if !isUpperEnvVarName(s) {
+		return ""
+	}
+	return s
+}
+
+func isUpperEnvVarName(s string) bool {
+	if s == "" {
+		return false
+	}
+	for i, r := range s {
+		if r == '_' || ('A' <= r && r <= 'Z') {
+			continue
+		}
+		if '0' <= r && r <= '9' {
+			if i == 0 {
+				return false
+			}
+			continue
+		}
+		return false
+	}
+	return true
+}
+
 func stringExtension(extensions map[string]any, name string) string {
 	value, ok := extensions[name]
 	if !ok {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a9b128ef..6b5d4c31 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1096,6 +1096,7 @@ func TestGenericAPIKeySchemeNamesUseAPIKeyEnvVar(t *testing.T) {
 		"ApiKeyAuth",
 		"APIKey",
 		"ApiKeyAuth_v2",
+		"auth",
 	}
 
 	for _, schemeName := range tests {
@@ -1130,6 +1131,105 @@ paths:
 	}
 }
 
+func TestSpeakeasyAuthExampleOverridesDerivedEnvVar(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Dub API
+  version: "1.0.0"
+servers:
+  - url: https://api.dub.co
+components:
+  securitySchemes:
+    token:
+      type: http
+      scheme: bearer
+      x-speakeasy-example: DUB_API_KEY
+paths:
+  /links:
+    get:
+      security:
+        - token: []
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, "bearer_token", parsed.Auth.Type)
+	assert.Equal(t, []string{"DUB_API_KEY"}, parsed.Auth.EnvVars)
+}
+
+func TestSpeakeasyAuthExampleRemapsInferredFormatPlaceholder(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Discord
+  version: "1.0.0"
+servers:
+  - url: https://discord.com/api
+components:
+  securitySchemes:
+    BotToken:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-speakeasy-example: DISCORD_TOKEN
+paths:
+  /users/@me:
+    get:
+      security:
+        - BotToken: []
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"DISCORD_TOKEN"}, parsed.Auth.EnvVars)
+	assert.Equal(t, "Bot {token}", parsed.Auth.Format)
+}
+
+func TestSpeakeasyAuthExampleDoesNotOverrideExplicitEnvVars(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: OAuth Client
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ClientCredentials:
+      type: oauth2
+      x-auth-env-vars:
+        - OAUTH_CLIENT_ID
+        - OAUTH_CLIENT_SECRET
+      x-speakeasy-example: OAUTH_TOKEN
+      flows:
+        clientCredentials:
+          tokenUrl: https://api.example.com/oauth/token
+          scopes: {}
+paths:
+  /widgets:
+    get:
+      security:
+        - ClientCredentials: []
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET"}, parsed.Auth.EnvVars)
+}
+
 func TestOpenAPIAuthOverrideExtensions(t *testing.T) {
 	t.Parallel()
 
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index d484faff..9ea83c9e 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1485,6 +1485,11 @@ auth:
     - <API_NAME>_TOKEN  # bearer_token → _TOKEN, api_key → _API_KEY
 ```
 
+When research or source metadata names a real env var, use only that canonical
+name in `env_vars`; do not add guessed slug-based aliases. For OpenAPI specs,
+prefer `x-auth-env-vars` on the selected security scheme when the wrapper slug
+differs from the underlying API brand.
+
 For OpenAPI specs, add an `info.description` mention if one doesn't exist — the
 parser's `inferDescriptionAuth` will detect it automatically.
 

← 013f92ad fix(cli): correct no-auth 403 hints (#629)  ·  back to Cli Printing Press  ·  fix(cli): infer bearer auth from inline params (#634) b4a95cb7 →