[object Object]

← back to Cli Printing Press

fix(cli): correct HTTP Basic auth env vars (#810)

9bb46da6972900e63e7a954609bc37a87570a63e · 2026-05-09 10:38:38 -0700 · Trevin Chow

Files touched

Diff

commit 9bb46da6972900e63e7a954609bc37a87570a63e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 9 10:38:38 2026 -0700

    fix(cli): correct HTTP Basic auth env vars (#810)
---
 internal/generator/generator.go             | 31 ++++++++++
 internal/generator/generator_test.go        | 57 +++++++++++++++++++
 internal/generator/templates/config.go.tmpl | 12 +++-
 internal/openapi/parser.go                  | 23 ++++++--
 internal/openapi/parser_test.go             | 88 +++++++++++++++++++++++++++++
 internal/pipeline/dogfood.go                |  4 +-
 internal/pipeline/dogfood_test.go           | 21 +++++++
 internal/pipeline/toolsmanifest.go          |  4 ++
 internal/pipeline/toolsmanifest_test.go     |  6 ++
 9 files changed, 238 insertions(+), 8 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3148fb25..fe26eaba 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -234,6 +234,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"authParameterName":      authParameterName,
 		"authCommandShort":       authCommandShort,
 		"authHarvestedEnvHint":   authHarvestedEnvHint,
+		"basicAuthEnvVars":       basicAuthEnvVars,
 		"hasAuthEnvVarKind":      hasAuthEnvVarKind,
 		"isRequestAuthEnvVar":    isRequestAuthEnvVar,
 		"effectiveTier":          effectiveTier,
@@ -900,6 +901,36 @@ func authHarvestedEnvHint(auth spec.AuthConfig) string {
 	}
 }
 
+func authFormatIsBasic(auth spec.AuthConfig) bool {
+	return strings.Contains(strings.ToLower(auth.Format), "basic ")
+}
+
+func basicAuthEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
+	if !authFormatIsBasic(auth) {
+		return nil
+	}
+	var envVars []spec.AuthEnvVar
+	if len(auth.EnvVarSpecs) > 0 {
+		for _, envVar := range auth.EnvVarSpecs {
+			if envVar.IsRequestCredential() {
+				envVars = append(envVars, envVar)
+			}
+		}
+	} else {
+		for _, name := range auth.EnvVars {
+			envVars = append(envVars, spec.AuthEnvVar{
+				Name:     name,
+				Kind:     spec.AuthEnvVarKindPerCall,
+				Required: true,
+			})
+		}
+	}
+	if len(envVars) < 2 {
+		return nil
+	}
+	return envVars[:2]
+}
+
 func hasAuthEnvVarKind(envVarSpecs []spec.AuthEnvVar, kind string) bool {
 	for _, envVar := range envVarSpecs {
 		if string(envVar.Kind) == kind {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 69e76148..ecd16e7e 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1049,6 +1049,63 @@ func TestAPIKeyFormatTokenPlaceholder(t *testing.T) {
 	runGoCommandRequired(t, outputDir, "test", "./internal/config")
 }
 
+func TestGenerateHTTPBasicAuthHeaderEncodesUsernamePassword(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "twilio",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			In:      "header",
+			Header:  "Authorization",
+			Format:  "Basic {username}:{password}",
+			EnvVars: []string{"TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"},
+			EnvVarSpecs: []spec.AuthEnvVar{
+				{Name: "TWILIO_ACCOUNT_SID", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: false},
+				{Name: "TWILIO_AUTH_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/twilio-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"accounts": {
+				Description: "Manage accounts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/Accounts",
+						Description: "List accounts",
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	const inlineTest = `package config
+
+import "testing"
+
+func TestBasicAuthHeader(t *testing.T) {
+	cfg := &Config{TwilioAccountSid: "AC123", TwilioAuthToken: "secret"}
+	if got := cfg.AuthHeader(); got != "Basic QUMxMjM6c2VjcmV0" {
+		t.Fatalf("AuthHeader() = %q", got)
+	}
+}
+`
+	testPath := filepath.Join(outputDir, "internal", "config", "basic_auth_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+	runGoCommandRequired(t, outputDir, "test", "./internal/config")
+}
+
 func countFiles(t *testing.T, root string) int {
 	t.Helper()
 
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 49dc53c1..b972ea73 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -4,6 +4,9 @@
 package config
 
 import (
+{{- if basicAuthEnvVars .Auth}}
+	"encoding/base64"
+{{- end}}
 {{- if ne .Config.Format "toml"}}
 	"encoding/json"
 {{- end}}
@@ -170,6 +173,7 @@ func Load(configPath string) (*Config, error) {
 func (c *Config) AuthHeader() string {
 {{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 {{- $isAuthEnvVarORCase := .Auth.IsAuthEnvVarORCase}}
+{{- $basicAuthEnvVars := basicAuthEnvVars .Auth}}
 {{- if and .BearerRefresh.Enabled (eq .Auth.Type "bearer_token") (ne .Auth.EffectiveOAuth2Grant "client_credentials")}}
 	if c.AuthHeaderVal != "" && c.AccessToken == "" {
 		return c.AuthHeaderVal
@@ -180,7 +184,13 @@ func (c *Config) AuthHeader() string {
 	}
 {{- end}}
 {{- if eq .Auth.Type "api_key"}}
-{{- if $isAuthEnvVarORCase}}
+{{- if $basicAuthEnvVars}}
+	if c.{{resolveEnvVarField (index $basicAuthEnvVars 0).Name}} == "" || c.{{resolveEnvVarField (index $basicAuthEnvVars 1).Name}} == "" {
+		return ""
+	}
+	credentials := c.{{resolveEnvVarField (index $basicAuthEnvVars 0).Name}} + ":" + c.{{resolveEnvVarField (index $basicAuthEnvVars 1).Name}}
+	return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
+{{- else if $isAuthEnvVarORCase}}
 {{- range .Auth.EnvVarSpecs}}
 	if c.{{resolveEnvVarField .Name}} != "" {
 		{{- if $.Auth.Format}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index b4bf6c44..f0e8b4f3 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -544,12 +544,16 @@ func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescript
 	envPrefix := naming.EnvPrefix(name)
 	switch auth.Type {
 	case "api_key":
-		// Use scheme name for more specific env var (e.g. BotToken -> DISCORD_BOT_TOKEN)
-		schemeEnvSuffix := toSnakeCase(schemeName)
-		if schemeEnvSuffix != "" && !isGenericAPIKeySchemeSuffix(schemeEnvSuffix) {
-			auth.EnvVars = []string{envPrefix + "_" + strings.ToUpper(schemeEnvSuffix)}
+		if authFormatIsBasic(auth.Format) {
+			auth.EnvVars = []string{envPrefix + "_USERNAME", envPrefix + "_PASSWORD"}
 		} else {
-			auth.EnvVars = []string{envPrefix + "_API_KEY"}
+			// Use scheme name for more specific env var (e.g. BotToken -> DISCORD_BOT_TOKEN)
+			schemeEnvSuffix := toSnakeCase(schemeName)
+			if schemeEnvSuffix != "" && !isGenericAPIKeySchemeSuffix(schemeEnvSuffix) {
+				auth.EnvVars = []string{envPrefix + "_" + strings.ToUpper(schemeEnvSuffix)}
+			} else {
+				auth.EnvVars = []string{envPrefix + "_API_KEY"}
+			}
 		}
 	case "bearer_token":
 		schemeEnvSuffix := toSnakeCase(schemeName)
@@ -647,7 +651,7 @@ func applyAuthEnvVarDefaults(auth *spec.AuthConfig, envPrefix string) {
 		return
 	}
 	auth.EnvVarSpecs = make([]spec.AuthEnvVar, 0, len(auth.EnvVars))
-	for _, name := range auth.EnvVars {
+	for i, name := range auth.EnvVars {
 		if name = strings.TrimSpace(name); name == "" {
 			continue
 		}
@@ -661,10 +665,17 @@ func applyAuthEnvVarDefaults(auth *spec.AuthConfig, envPrefix string) {
 		if auth.Type == "cookie" || strings.EqualFold(auth.In, "cookie") {
 			envVar.Kind = spec.AuthEnvVarKindHarvested
 		}
+		if authFormatIsBasic(auth.Format) && i == 0 {
+			envVar.Sensitive = false
+		}
 		auth.EnvVarSpecs = append(auth.EnvVarSpecs, envVar)
 	}
 }
 
+func authFormatIsBasic(format string) bool {
+	return strings.Contains(strings.ToLower(format), "basic ")
+}
+
 func applyAuthVarsRichOverride(auth *spec.AuthConfig, extensions map[string]any, path string) {
 	if auth == nil || len(extensions) == 0 {
 		return
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index f598dd11..fd6c5e8c 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -2198,6 +2198,94 @@ paths:
 	}, parsed.Auth.EnvVarSpecs[0])
 }
 
+func TestOpenAPIHTTPBasicAuthDefaultsToUsernamePasswordEnvVars(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Twilio
+  version: "1.0.0"
+servers:
+  - url: https://api.twilio.com
+components:
+  securitySchemes:
+    basicAuth:
+      type: http
+      scheme: basic
+security:
+  - basicAuth: []
+paths:
+  /Accounts:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, "api_key", parsed.Auth.Type)
+	assert.Equal(t, "Authorization", parsed.Auth.Header)
+	assert.Equal(t, "Basic {username}:{password}", parsed.Auth.Format)
+	assert.Equal(t, []string{"TWILIO_USERNAME", "TWILIO_PASSWORD"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 2)
+	assert.Equal(t, spec.AuthEnvVar{
+		Name:      "TWILIO_USERNAME",
+		Kind:      spec.AuthEnvVarKindPerCall,
+		Required:  true,
+		Sensitive: false,
+		Inferred:  true,
+	}, parsed.Auth.EnvVarSpecs[0])
+	assert.Equal(t, spec.AuthEnvVar{
+		Name:      "TWILIO_PASSWORD",
+		Kind:      spec.AuthEnvVarKindPerCall,
+		Required:  true,
+		Sensitive: true,
+		Inferred:  true,
+	}, parsed.Auth.EnvVarSpecs[1])
+}
+
+func TestOpenAPIHTTPBasicAuthHonorsAuthVarsOverride(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Twilio
+  version: "1.0.0"
+servers:
+  - url: https://api.twilio.com
+components:
+  securitySchemes:
+    basicAuth:
+      type: http
+      scheme: basic
+      x-auth-vars:
+        - name: TWILIO_ACCOUNT_SID
+          kind: per_call
+          required: true
+          sensitive: false
+        - name: TWILIO_AUTH_TOKEN
+          kind: per_call
+          required: true
+          sensitive: true
+security:
+  - basicAuth: []
+paths:
+  /Accounts:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 2)
+	assert.False(t, parsed.Auth.EnvVarSpecs[0].Sensitive)
+	assert.True(t, parsed.Auth.EnvVarSpecs[1].Sensitive)
+}
+
 func TestOpenAPIAuthVarsRichOverride(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index b4eccad1..6b3653be 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -836,12 +836,14 @@ func checkAuth(dir string, auth apispec.AuthConfig) AuthCheckResult {
 		result.GeneratedFmt = "Bot "
 	case strings.Contains(combinedSource, `"Bearer "`):
 		result.GeneratedFmt = "Bearer "
+	case strings.Contains(combinedSource, `"Basic "`):
+		result.GeneratedFmt = "Basic "
 	default:
 		result.GeneratedFmt = "unknown"
 	}
 
 	if expectedPrefix == "" {
-		result.Detail = "spec not provided or no bot/bearer scheme detected"
+		result.Detail = "spec not provided or no bot/bearer/basic scheme detected"
 		return result
 	}
 
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index b6b56ce4..41e7339a 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 	"testing"
 
+	apispec "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -589,6 +590,26 @@ func authHeader(token string) string {
 	assert.Equal(t, 0, report.WiringCheck.CommandTree.Defined)
 }
 
+func TestCheckAuthRecognizesBasicPrefix(t *testing.T) {
+	dir := t.TempDir()
+
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "client"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "config"), 0o755))
+
+	writeTestFile(t, filepath.Join(dir, "internal", "client", "client.go"), `package client
+func authHeader() string { return configAuthHeader() }
+`)
+	writeTestFile(t, filepath.Join(dir, "internal", "config", "config.go"), `package config
+func (c *Config) AuthHeader() string {
+	return "Basic " + encode(c.Username+":"+c.Password)
+}
+`)
+
+	result := checkAuth(dir, apispec.AuthConfig{Type: "api_key", Format: "Basic {username}:{password}"})
+	assert.True(t, result.Match)
+	assert.Equal(t, "Basic ", result.GeneratedFmt)
+}
+
 func TestDeriveDogfoodVerdict_WiringChecks(t *testing.T) {
 	// Test that unregistered commands cause FAIL
 	report := &DogfoodReport{
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index d187eeaf..f505c495 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -413,6 +413,10 @@ func normalizeAuthFormat(format string, envVars []string) string {
 			result = strings.ReplaceAll(result, "{"+derived+"}", "{"+envVar+"}")
 		}
 	}
+	if strings.Contains(strings.ToLower(format), "basic ") && len(envVars) >= 2 {
+		result = strings.ReplaceAll(result, "{username}", "{"+envVars[0]+"}")
+		result = strings.ReplaceAll(result, "{password}", "{"+envVars[1]+"}")
+	}
 	// Also replace common semantic aliases with the first env var.
 	first := envVars[0]
 	for _, alias := range []string{"token", "access_token", "api_key"} {
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
index 76b13a6b..b225f2df 100644
--- a/internal/pipeline/toolsmanifest_test.go
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -1110,6 +1110,12 @@ func TestNormalizeAuthFormat(t *testing.T) {
 			envVars: []string{"STYTCH_PROJECT_ID", "STYTCH_SECRET"},
 			want:    "Basic {STYTCH_PROJECT_ID}:{STYTCH_SECRET}",
 		},
+		{
+			name:    "basic username password placeholders",
+			format:  "Basic {username}:{password}",
+			envVars: []string{"TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"},
+			want:    "Basic {TWILIO_ACCOUNT_SID}:{TWILIO_AUTH_TOKEN}",
+		},
 		{
 			name:    "empty format stays empty",
 			format:  "",

← 6138e88e chore(ci): Gate main CI by changed file scope and add a skil  ·  back to Cli Printing Press  ·  fix(cli): default cookie HTML scrapes to browser transport ( 7a1d83ad →