← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/config.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/dogfood.goM internal/pipeline/dogfood_test.goM internal/pipeline/toolsmanifest.goM internal/pipeline/toolsmanifest_test.go
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 →