← back to Cli Printing Press
fix(cli): honor auth.prefix on bearer_token specs (#1054)
d2770e1610f5ab38dcee000ff7a941732174be39 · 2026-05-11 05:33:06 -0700 · Trevin Chow
* fix(cli): honor auth.prefix on bearer_token specs
The bearer_token branches of `internal/config/config.go` (and the per-tier
bearer branch in `internal/client/client.go`) emitted hardcoded `"Bearer "`
even when the spec declared `auth.prefix: "Token"` (Monarch Money), lowercase
`token` (GitHub legacy PATs), or `PRIVATE-TOKEN` (GitLab PATs). Operators had
to hand-edit generated config and re-edit on every regen.
- Add `Prefix string` to `spec.AuthConfig` plus `HeaderPrefix()` helper that
returns `Prefix` when set, `"Bearer"` otherwise. `Auth.Format`, when set,
still wins via the existing `applyAuthFormat` machinery.
- Swap the literal `"Bearer "` for `{{.Auth.HeaderPrefix}}` at five sites in
`config.go.tmpl` (oauth2/cc, bearer_refresh AccessToken, env-var OR,
canonical env-var, AccessToken fallback) and one site in `client.go.tmpl`
(tier-routing bearer).
- Update the AuthProtocol scorer: thread `scheme.Prefix` through
`openAPISecurityScheme` and `internalSpecToOpenAPISpecInfo`, then accept
the configured prefix as a valid literal in `scoreAuthScheme`. Without
this, a non-Bearer prefix would silently drop 3 AuthProtocol points.
- Validate the field at spec-load time: reject characters outside the
RFC 7235 token charset and cap length at 32. Defense-in-depth against an
operator-authored prefix accidentally breaking out of the Go string literal
the generator emits.
Closes #909
* test(cli): pin auth.prefix at oauth2/cc, bearer_refresh, OR-case, and validator wiring
The initial change covered the canonical env-var and AccessToken
fallback branches but left three rendering paths untested. A revert of
any of those template sites back to the literal "Bearer " would have
shipped undetected.
- TestAuthHeader_BearerTokenPrefixMissedSites exercises
config.go.tmpl's oauth2/client_credentials AccessToken branch,
BearerRefresh.Enabled AccessToken branch, and the
$isAuthEnvVarORCase env-var branch with a non-Bearer prefix.
- TestAPISpecValidate_RejectsBadAuthPrefix pins validateAuthPrefix
into the APISpec.Validate() pipeline so a future reorder cannot
silently drop the charset gate.
- TestScoreAuthScheme_BearerPrefixOverride now compares scores
relatively (with-prefix > without-prefix) instead of asserting
absolute >= 7 / < 7 thresholds that would false-pass under weight
rebalancing.
Files touched
M internal/generator/auth_env_precedence_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/config.go.tmplM internal/pipeline/scorecard.goM internal/pipeline/scorecard_tier2_test.goM internal/pipeline/spec_detect.goM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit d2770e1610f5ab38dcee000ff7a941732174be39
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 11 05:33:06 2026 -0700
fix(cli): honor auth.prefix on bearer_token specs (#1054)
* fix(cli): honor auth.prefix on bearer_token specs
The bearer_token branches of `internal/config/config.go` (and the per-tier
bearer branch in `internal/client/client.go`) emitted hardcoded `"Bearer "`
even when the spec declared `auth.prefix: "Token"` (Monarch Money), lowercase
`token` (GitHub legacy PATs), or `PRIVATE-TOKEN` (GitLab PATs). Operators had
to hand-edit generated config and re-edit on every regen.
- Add `Prefix string` to `spec.AuthConfig` plus `HeaderPrefix()` helper that
returns `Prefix` when set, `"Bearer"` otherwise. `Auth.Format`, when set,
still wins via the existing `applyAuthFormat` machinery.
- Swap the literal `"Bearer "` for `{{.Auth.HeaderPrefix}}` at five sites in
`config.go.tmpl` (oauth2/cc, bearer_refresh AccessToken, env-var OR,
canonical env-var, AccessToken fallback) and one site in `client.go.tmpl`
(tier-routing bearer).
- Update the AuthProtocol scorer: thread `scheme.Prefix` through
`openAPISecurityScheme` and `internalSpecToOpenAPISpecInfo`, then accept
the configured prefix as a valid literal in `scoreAuthScheme`. Without
this, a non-Bearer prefix would silently drop 3 AuthProtocol points.
- Validate the field at spec-load time: reject characters outside the
RFC 7235 token charset and cap length at 32. Defense-in-depth against an
operator-authored prefix accidentally breaking out of the Go string literal
the generator emits.
Closes #909
* test(cli): pin auth.prefix at oauth2/cc, bearer_refresh, OR-case, and validator wiring
The initial change covered the canonical env-var and AccessToken
fallback branches but left three rendering paths untested. A revert of
any of those template sites back to the literal "Bearer " would have
shipped undetected.
- TestAuthHeader_BearerTokenPrefixMissedSites exercises
config.go.tmpl's oauth2/client_credentials AccessToken branch,
BearerRefresh.Enabled AccessToken branch, and the
$isAuthEnvVarORCase env-var branch with a non-Bearer prefix.
- TestAPISpecValidate_RejectsBadAuthPrefix pins validateAuthPrefix
into the APISpec.Validate() pipeline so a future reorder cannot
silently drop the charset gate.
- TestScoreAuthScheme_BearerPrefixOverride now compares scores
relatively (with-prefix > without-prefix) instead of asserting
absolute >= 7 / < 7 thresholds that would false-pass under weight
rebalancing.
---
internal/generator/auth_env_precedence_test.go | 216 +++++++++++++++++++++++++
internal/generator/templates/client.go.tmpl | 2 +-
internal/generator/templates/config.go.tmpl | 10 +-
internal/pipeline/scorecard.go | 10 +-
internal/pipeline/scorecard_tier2_test.go | 25 +++
internal/pipeline/spec_detect.go | 1 +
internal/spec/spec.go | 40 +++++
internal/spec/spec_test.go | 86 ++++++++++
8 files changed, 383 insertions(+), 7 deletions(-)
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index d7f25d6f..5165ec93 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -186,6 +186,222 @@ func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
}
}
+// TestAuthHeader_BearerTokenPrefixOverride pins that a bearer_token spec
+// declaring auth.prefix changes the rendered Authorization scheme word
+// across both the env-var and AccessToken branches. APIs that require a
+// non-Bearer scheme (e.g., "Token", "PRIVATE-TOKEN", lowercase "token")
+// otherwise force operators to hand-edit generated config. When auth.prefix
+// is unset, "Bearer" remains the default.
+func TestAuthHeader_BearerTokenPrefixOverride(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ prefix string
+ expected string
+ }{
+ {"default", "", "Bearer"},
+ {"token", "Token", "Token"},
+ {"lowercase", "token", "token"},
+ {"private_token", "PRIVATE-TOKEN", "PRIVATE-TOKEN"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("prefix-" + tc.name)
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: tc.prefix,
+ EnvVars: []string{"PREFIX_TEST_TOKEN"},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "prefix-"+tc.name+"-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ body := authHeaderBody(t, string(cfgSrc))
+
+ envField := resolveEnvVarField("PREFIX_TEST_TOKEN")
+ require.Contains(t, body, `return "`+tc.expected+` " + c.`+envField,
+ "env-var branch must render configured prefix")
+ require.Contains(t, body, `return "`+tc.expected+` " + c.AccessToken`,
+ "AccessToken branch must render configured prefix")
+
+ if tc.prefix != "" && tc.expected != "Bearer" {
+ assert.NotContains(t, body, `return "Bearer " + c.`+envField,
+ "default Bearer literal must not leak when prefix is overridden")
+ assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+ "default Bearer literal must not leak when prefix is overridden")
+ }
+ })
+ }
+}
+
+// TestAuthHeader_BearerTokenPrefixFormatPrecedence pins that Auth.Format
+// wins over Auth.Prefix at the same call sites, so the documented "Ignored
+// when Format is set" contract survives template restructuring.
+func TestAuthHeader_BearerTokenPrefixFormatPrecedence(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("prefix-format")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: "Token",
+ Format: "Bearer {token}",
+ EnvVars: []string{"PREFIX_FORMAT_TOKEN"},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "prefix-format-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ body := authHeaderBody(t, string(cfgSrc))
+
+ envField := resolveEnvVarField("PREFIX_FORMAT_TOKEN")
+ require.Contains(t, body, `applyAuthFormat("Bearer {token}"`,
+ "Format must render via applyAuthFormat, not via the prefix literal")
+ assert.NotContains(t, body, `return "Token " + c.`+envField,
+ "Prefix must not leak into the env-var branch when Format is set")
+ assert.NotContains(t, body, `return "Token " + c.AccessToken`,
+ "Prefix must not leak into the AccessToken branch when Format is set")
+}
+
+// TestAuthHeader_BearerTokenPrefixMissedSites exercises the three
+// non-default code paths in config.go.tmpl that the main override table
+// does not reach: oauth2/client_credentials, BearerRefresh.Enabled, and
+// the $isAuthEnvVarORCase branch. Without these cases a revert of any of
+// those template sites back to a "Bearer " literal would ship undetected.
+func TestAuthHeader_BearerTokenPrefixMissedSites(t *testing.T) {
+ t.Parallel()
+
+ t.Run("oauth2_client_credentials", func(t *testing.T) {
+ t.Parallel()
+ apiSpec := minimalSpec("prefix-oauth2-cc")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: "Token",
+ EnvVarSpecs: []spec.AuthEnvVar{{Name: "CC_PREFIX_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput}, {Name: "CC_PREFIX_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Sensitive: true}},
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://example.com/token",
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "prefix-oauth2-cc-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ body := authHeaderBody(t, string(cfgSrc))
+
+ require.Contains(t, body, `return "Token " + c.AccessToken`,
+ "oauth2/client_credentials AccessToken branch must honor configured prefix")
+ assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+ "default Bearer literal must not leak in the oauth2/cc branch when prefix is overridden")
+ })
+
+ t.Run("bearer_refresh_enabled", func(t *testing.T) {
+ t.Parallel()
+ apiSpec := minimalSpec("prefix-bearer-refresh")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: "Token",
+ EnvVars: []string{"REFRESH_PREFIX_TOKEN"},
+ }
+ apiSpec.BearerRefresh = spec.BearerRefreshConfig{
+ BundleURL: "https://cdn.example.com/main.js",
+ Pattern: `"(AAAAAAAA[^"]+)"`,
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "prefix-bearer-refresh-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ body := authHeaderBody(t, string(cfgSrc))
+
+ require.Contains(t, body, `return "Token " + c.AccessToken`,
+ "BearerRefresh AccessToken branch must honor configured prefix")
+ assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+ "default Bearer literal must not leak in the bearer_refresh branch when prefix is overridden")
+ })
+
+ t.Run("env_var_or_case", func(t *testing.T) {
+ t.Parallel()
+ apiSpec := minimalSpec("prefix-or-case")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: "Token",
+ EnvVarSpecs: []spec.AuthEnvVar{
+ {Name: "OR_PREFIX_A", Kind: spec.AuthEnvVarKindPerCall, Required: false},
+ {Name: "OR_PREFIX_B", Kind: spec.AuthEnvVarKindPerCall, Required: false},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "prefix-or-case-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ body := authHeaderBody(t, string(cfgSrc))
+
+ fieldA := resolveEnvVarField("OR_PREFIX_A")
+ fieldB := resolveEnvVarField("OR_PREFIX_B")
+ require.Contains(t, body, `return "Token " + c.`+fieldA,
+ "OR-case env-var branch must honor configured prefix (first env var)")
+ require.Contains(t, body, `return "Token " + c.`+fieldB,
+ "OR-case env-var branch must honor configured prefix (second env var)")
+ assert.NotContains(t, body, `return "Bearer " + c.`+fieldA,
+ "default Bearer literal must not leak in the OR-case branch when prefix is overridden")
+ })
+}
+
+// TestTierRouting_BearerPrefix pins that the per-tier bearer scheme in
+// client.go.tmpl honors auth.prefix on the tier's auth config, matching
+// the default-tier AuthHeader() behavior.
+func TestTierRouting_BearerPrefix(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("tier-prefix")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ EnvVars: []string{"TIER_PREFIX_TOKEN"},
+ }
+ apiSpec.TierRouting = spec.TierRoutingConfig{
+ DefaultTier: "primary",
+ Tiers: map[string]spec.TierConfig{
+ "primary": {
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: "Token",
+ EnvVars: []string{"TIER_PRIMARY_TOKEN"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "tier-prefix-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ content := string(clientSrc)
+
+ require.Contains(t, content, `value := "Token " + tierValue0`,
+ "per-tier bearer auth must honor configured prefix")
+ assert.NotContains(t, content, `value := "Bearer " + tierValue0`,
+ "default Bearer literal must not leak when tier prefix is overridden")
+}
+
// authHeaderBody slices out just the AuthHeader function body so precedence
// assertions can't be tricked by a matching pattern in unrelated code
// further down the file.
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c7e16826..d134d50f 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -186,7 +186,7 @@ func (c *Client) authForRequest() (requestAuth, error) {
{{- end}}
})
{{- else if eq $tier.Auth.Type "bearer_token"}}
- value := "Bearer " + tierValue0
+ value := "{{$tier.Auth.HeaderPrefix}} " + tierValue0
{{- else}}
value := tierValue0
{{- end}}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 57318cd2..9dd90532 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -303,7 +303,7 @@ func (c *Config) AuthHeader() string {
{{- if .Auth.Format}}
return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
{{- else}}
- return "Bearer " + c.AccessToken
+ return "{{.Auth.HeaderPrefix}} " + c.AccessToken
{{- end}}
}
return ""
@@ -316,7 +316,7 @@ func (c *Config) AuthHeader() string {
{{- if .Auth.Format}}
return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
{{- else}}
- return "Bearer " + c.AccessToken
+ return "{{.Auth.HeaderPrefix}} " + c.AccessToken
{{- end}}
}
{{- else}}
@@ -335,7 +335,7 @@ func (c *Config) AuthHeader() string {
{{- end}}
})
{{- else}}
- return "Bearer " + c.{{resolveEnvVarField .Name}}
+ return "{{$.Auth.HeaderPrefix}} " + c.{{resolveEnvVarField .Name}}
{{- end}}
}
{{- end}}
@@ -352,7 +352,7 @@ func (c *Config) AuthHeader() string {
{{- end}}
})
{{- else}}
- return "Bearer " + c.{{resolveEnvVarField .Name}}
+ return "{{$.Auth.HeaderPrefix}} " + c.{{resolveEnvVarField .Name}}
{{- end}}
}
{{- end}}
@@ -363,7 +363,7 @@ func (c *Config) AuthHeader() string {
{{- if .Auth.Format}}
return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
{{- else}}
- return "Bearer " + c.AccessToken
+ return "{{.Auth.HeaderPrefix}} " + c.AccessToken
{{- end}}
}
{{- end}}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 4b42099b..85cddb59 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1499,6 +1499,10 @@ type openAPISecurityScheme struct {
Scheme string
In string
HeaderName string
+ // Prefix mirrors apispec.AuthConfig.Prefix for internal-YAML specs that
+ // override the literal "Bearer" scheme word (e.g., "Token", "PRIVATE-TOKEN").
+ // Empty for OpenAPI-derived schemes; bearer-branch scoring falls back to "Bearer".
+ Prefix string
}
const (
@@ -1913,7 +1917,11 @@ func scoreAuthScheme(clientContent, configContent, authContent string, scheme op
}
case strings.Contains(nameLower, "bearer") || (scheme.Type == "http" && scheme.Scheme == "bearer"):
scoreable = true
- if authPrefixLiteralPresent("Bearer", clientContent, configContent, authContent) {
+ bearerLiteral := scheme.Prefix
+ if strings.TrimSpace(bearerLiteral) == "" {
+ bearerLiteral = "Bearer"
+ }
+ if authPrefixLiteralPresent(bearerLiteral, clientContent, configContent, authContent) {
authHeaderMatched = true
}
case strings.Contains(nameLower, "basic") || (scheme.Type == "http" && scheme.Scheme == "basic"):
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 2cdef980..58e5e7ab 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -2339,3 +2339,28 @@ func writeScorecardFixture(t *testing.T, root, relPath, content string) {
t.Fatalf("write %s: %v", relPath, err)
}
}
+
+// TestScoreAuthScheme_BearerPrefixOverride pins that the AuthProtocol scorer
+// accepts a non-Bearer scheme literal when openAPISecurityScheme.Prefix is
+// populated by an internal-YAML spec. The relative comparison (with-Prefix
+// scores higher than without) survives weight rebalancing where an absolute
+// threshold would not.
+func TestScoreAuthScheme_BearerPrefixOverride(t *testing.T) {
+ configWithToken := `func (c *Config) AuthHeader() string { return "Token " + c.AccessToken }`
+ clientStub := `req.Header.Set("Authorization", c.AuthHeader())`
+
+ withPrefix := openAPISecurityScheme{
+ Key: "bearer_token",
+ Type: "http",
+ Scheme: "bearer",
+ Prefix: "Token",
+ }
+ withoutPrefix := withPrefix
+ withoutPrefix.Prefix = ""
+
+ scoreWith, _ := scoreAuthScheme(clientStub, configWithToken, "", withPrefix)
+ scoreWithout, _ := scoreAuthScheme(clientStub, configWithToken, "", withoutPrefix)
+
+ assert.Greater(t, scoreWith, scoreWithout,
+ "AuthProtocol score with configured prefix must exceed the empty-prefix default when generated code uses the prefix literal")
+}
diff --git a/internal/pipeline/spec_detect.go b/internal/pipeline/spec_detect.go
index c8533f83..8ec5549f 100644
--- a/internal/pipeline/spec_detect.go
+++ b/internal/pipeline/spec_detect.go
@@ -134,6 +134,7 @@ func internalSpecToOpenAPISpecInfo(s *apispec.APISpec) *openAPISpecInfo {
case "bearer_token":
scheme.Type = "http"
scheme.Scheme = "bearer"
+ scheme.Prefix = s.Auth.Prefix
case "api_key":
scheme.Type = "apikey"
scheme.In = s.Auth.In
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 218a3412..e48d402c 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -444,6 +444,7 @@ func (c BearerRefreshConfig) Enabled() bool {
type AuthConfig struct {
Type string `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
Header string `yaml:"header" json:"header"`
+ Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` // Authorization scheme word (e.g., "Token", "PRIVATE-TOKEN"); empty defaults to "Bearer". Ignored when Format is set.
Format string `yaml:"format" json:"format"`
EnvVars []string `yaml:"env_vars" json:"env_vars"`
EnvVarSpecs []AuthEnvVar `yaml:"env_var_specs,omitempty" json:"env_var_specs,omitempty"`
@@ -556,6 +557,16 @@ func (v AuthEnvVar) MarkdownDescription() string {
return strings.ReplaceAll(description, "\r", " ")
}
+// HeaderPrefix returns Prefix when set, "Bearer" otherwise. Callers only
+// consult it when Auth.Format is empty; Format's placeholder template
+// already carries its own prefix and takes precedence.
+func (c AuthConfig) HeaderPrefix() string {
+ if p := strings.TrimSpace(c.Prefix); p != "" {
+ return p
+ }
+ return "Bearer"
+}
+
// CanonicalEnvVar returns the deterministic canonical entry for human-prose surfaces.
func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
if c == nil {
@@ -706,6 +717,32 @@ func (c AuthConfig) EffectiveOAuth2Grant() string {
// validateOAuth2Grant ensures OAuth2Grant is empty or one of the supported
// values. Empty is accepted (treated as the default). Cross-checking against
+// validateAuthPrefix rejects characters that would break out of the Go
+// double-quoted string literal the generator emits at the prefix interpolation
+// sites (`return "<prefix> " + c.Token`). RFC 7235 only permits token
+// characters in the scheme word anyway, so the cap is both safety and spec
+// adherence. Length is bounded so a typo doesn't balloon every printed CLI's
+// AuthHeader return value.
+func validateAuthPrefix(c AuthConfig) error {
+ prefix := c.Prefix
+ if prefix == "" {
+ return nil
+ }
+ if len(prefix) > 32 {
+ return fmt.Errorf("auth.prefix length %d exceeds 32-character cap", len(prefix))
+ }
+ for i, r := range prefix {
+ if r > 0x7E || r < 0x21 {
+ return fmt.Errorf("auth.prefix contains non-printable or non-ASCII byte at index %d (0x%02x); only RFC 7235 token characters are allowed", i, r)
+ }
+ switch r {
+ case '"', '\\', '(', ')', ',', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '{', '}':
+ return fmt.Errorf("auth.prefix contains separator character %q at index %d; only RFC 7235 token characters are allowed", r, i)
+ }
+ }
+ return nil
+}
+
// AuthConfig.Type is intentionally skipped: the field is ignored for
// non-oauth2 types, matching how SessionTTLHours and similar fields behave.
func validateOAuth2Grant(c AuthConfig) error {
@@ -1578,6 +1615,9 @@ func (s *APISpec) Validate() error {
if err := validateOAuth2Grant(s.Auth); err != nil {
return err
}
+ if err := validateAuthPrefix(s.Auth); err != nil {
+ return err
+ }
if err := validateSessionHandshake(s.Auth); err != nil {
return err
}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index f9c081a7..970d0f1f 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -3,6 +3,7 @@ package spec
import (
"bytes"
"os"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -855,6 +856,91 @@ func TestThrottlingValidate(t *testing.T) {
}
}
+func TestAuthPrefixValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ wantErr string
+ }{
+ {name: "empty is valid (defaults to Bearer)", prefix: ""},
+ {name: "Bearer is valid", prefix: "Bearer"},
+ {name: "Token is valid", prefix: "Token"},
+ {name: "lowercase token is valid", prefix: "token"},
+ {name: "PRIVATE-TOKEN is valid (hyphen is a token char)", prefix: "PRIVATE-TOKEN"},
+ {name: "embedded quote is rejected", prefix: `Token"`, wantErr: "separator character"},
+ {name: "backslash is rejected", prefix: `Token\`, wantErr: "separator character"},
+ {name: "carriage return is rejected", prefix: "Token\r", wantErr: "non-printable"},
+ {name: "newline is rejected", prefix: "Token\n", wantErr: "non-printable"},
+ {name: "space is rejected", prefix: "Token Foo", wantErr: "non-printable"},
+ {name: "non-ASCII is rejected", prefix: "Tøken", wantErr: "non-ASCII"},
+ {name: "over-long prefix is rejected", prefix: strings.Repeat("A", 33), wantErr: "32-character cap"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateAuthPrefix(AuthConfig{Prefix: tt.prefix})
+ if tt.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestAPISpecValidate_RejectsBadAuthPrefix(t *testing.T) {
+ build := func(prefix string) APISpec {
+ return APISpec{
+ Name: "prefix-validate",
+ BaseURL: "https://api.example.com",
+ Auth: AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Prefix: prefix,
+ EnvVars: []string{"PREFIX_VALIDATE_TOKEN"},
+ },
+ Resources: map[string]Resource{
+ "items": {
+ Endpoints: map[string]Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+ }
+
+ t.Run("valid prefix passes Validate()", func(t *testing.T) {
+ s := build("Token")
+ require.NoError(t, s.Validate())
+ })
+
+ t.Run("embedded quote is rejected at the APISpec level", func(t *testing.T) {
+ s := build(`Token"`)
+ err := s.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "auth.prefix")
+ })
+}
+
+func TestAuthConfigHeaderPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ want string
+ }{
+ {name: "empty defaults to Bearer", prefix: "", want: "Bearer"},
+ {name: "whitespace-only defaults to Bearer", prefix: " ", want: "Bearer"},
+ {name: "Token is preserved", prefix: "Token", want: "Token"},
+ {name: "surrounding whitespace is trimmed", prefix: " Token ", want: "Token"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := AuthConfig{Prefix: tt.prefix}.HeaderPrefix()
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
func TestOAuth2GrantValidate(t *testing.T) {
tests := []struct {
name string
← e2f3a53c fix(cli): honor --resources filter in dependent sync fan-out
·
back to Cli Printing Press
·
fix(cli): walk parent dirs for research.json in live-check ( 9a07f287 →