← back to Cli Printing Press
fix(cli): emit AccessToken-only AuthHeader for all OAuth2 grants (#1010)
3b050899c91b03dcae0466482dbd4d5a4faec9a1 · 2026-05-10 22:51:42 -0700 · Trevin Chow
Generated CLIs with auth.type: oauth2 were emitting the configured
CLIENT_ID env var as the Authorization bearer before falling through
to the persisted AccessToken. Users following the standard OAuth setup
(export <API>_CLIENT_ID) saw every live API call rejected with
token_rejected because the client_id was sent as a bearer token.
The template's AccessToken-only branch was gated on EffectiveOAuth2Grant
== "client_credentials" only; authorization_code (the default OAuth2
grant) fell through to the env-var-wins block and emitted CLIENT_ID as
a bearer fallback.
Broaden the gate to fire for any auth.type: oauth2 spec while preserving
the existing bearer_token + client_credentials carve-out. AuthHeaderVal
manual override still wins for both grants; the env-var-wins block at
lines 320-355 is now unreachable for OAuth2.
The existing test that pinned the buggy behavior is rewritten as a
table-driven test covering both OAuth2 grants and asserting both that
CLIENT_ID/CLIENT_SECRET are never emitted as bearers and that AuthHeader
returns the AccessToken via applyAuthFormat.
Files touched
M internal/generator/auth_env_precedence_test.goM internal/generator/templates/config.go.tmplM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
Diff
commit 3b050899c91b03dcae0466482dbd4d5a4faec9a1
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 22:51:42 2026 -0700
fix(cli): emit AccessToken-only AuthHeader for all OAuth2 grants (#1010)
Generated CLIs with auth.type: oauth2 were emitting the configured
CLIENT_ID env var as the Authorization bearer before falling through
to the persisted AccessToken. Users following the standard OAuth setup
(export <API>_CLIENT_ID) saw every live API call rejected with
token_rejected because the client_id was sent as a bearer token.
The template's AccessToken-only branch was gated on EffectiveOAuth2Grant
== "client_credentials" only; authorization_code (the default OAuth2
grant) fell through to the env-var-wins block and emitted CLIENT_ID as
a bearer fallback.
Broaden the gate to fire for any auth.type: oauth2 spec while preserving
the existing bearer_token + client_credentials carve-out. AuthHeaderVal
manual override still wins for both grants; the env-var-wins block at
lines 320-355 is now unreachable for OAuth2.
The existing test that pinned the buggy behavior is rewritten as a
table-driven test covering both OAuth2 grants and asserting both that
CLIENT_ID/CLIENT_SECRET are never emitted as bearers and that AuthHeader
returns the AccessToken via applyAuthFormat.
---
internal/generator/auth_env_precedence_test.go | 69 ++++++++++++++--------
internal/generator/templates/config.go.tmpl | 9 ++-
.../internal/config/config.go | 7 ++-
3 files changed, 57 insertions(+), 28 deletions(-)
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index d5ba2a4b..d7f25d6f 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -57,37 +57,60 @@ func TestAuthHeader_ClientCredentialsDoesNotUseSetupEnvVars(t *testing.T) {
assert.Less(t, verifyIdx, mintIdx, "mock verification must not dial the real token endpoint")
}
-func TestAuthHeader_OAuth2AuthorizationCodeUsesToken(t *testing.T) {
+// TestAuthHeader_OAuth2DoesNotUseSetupEnvVars pins that for every OAuth2
+// grant (authorization_code via the default, client_credentials via explicit
+// OAuth2Grant) the configured env vars (e.g. CLIENT_ID / CLIENT_SECRET) are
+// never emitted as bearer headers. The minted AccessToken is the only usable
+// bearer; sending CLIENT_ID as `Authorization: Bearer` surfaces as
+// token_rejected at the API.
+func TestAuthHeader_OAuth2DoesNotUseSetupEnvVars(t *testing.T) {
t.Parallel()
- apiSpec := minimalSpec("oauth-precedence")
- apiSpec.Auth = spec.AuthConfig{
- Type: "oauth2",
- Header: "Authorization",
- Format: "Bearer {token}",
- EnvVars: []string{"OAUTH_AUTH_TEST_TOKEN"},
- AuthorizationURL: "https://example.com/auth",
- TokenURL: "https://example.com/token",
+ cases := []struct {
+ name string
+ grant string
+ }{
+ {"authorization_code", ""},
+ {"client_credentials", spec.OAuth2GrantClientCredentials},
}
- outputDir := filepath.Join(t.TempDir(), "oauth-precedence-pp-cli")
- require.NoError(t, New(apiSpec, outputDir).Generate())
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
- cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
- require.NoError(t, err)
- content := string(cfgSrc)
+ apiSpec := minimalSpec("oauth-precedence-" + tc.name)
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "oauth2",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVarSpecs: []spec.AuthEnvVar{
+ {Name: "OAUTH_AUTH_TEST_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: false},
+ {Name: "OAUTH_AUTH_TEST_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: true},
+ },
+ AuthorizationURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ OAuth2Grant: tc.grant,
+ }
- envCheck := "if c." + resolveEnvVarField("OAUTH_AUTH_TEST_TOKEN") + ` != ""`
- tokenCheck := `if c.AccessToken != ""`
+ outputDir := filepath.Join(t.TempDir(), "oauth-precedence-"+tc.name+"-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
- require.Contains(t, content, envCheck)
- require.Contains(t, content, tokenCheck)
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ content := string(cfgSrc)
- body := authHeaderBody(t, content)
- envIdx := strings.Index(body, envCheck)
- tokenIdx := strings.Index(body, tokenCheck)
- assert.Less(t, envIdx, tokenIdx,
- "env-var bearer fallback should win over file token for OAuth2 authorization_code")
+ clientIDCheck := "if c." + resolveEnvVarField("OAUTH_AUTH_TEST_CLIENT_ID") + ` != ""`
+ clientSecretCheck := "if c." + resolveEnvVarField("OAUTH_AUTH_TEST_CLIENT_SECRET") + ` != ""`
+ tokenCheck := `if c.AccessToken != ""`
+
+ body := authHeaderBody(t, content)
+ require.Contains(t, body, tokenCheck, "AuthHeader must check AccessToken")
+ require.Contains(t, body, `applyAuthFormat("Bearer {token}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})`,
+ "AuthHeader must return the AccessToken via applyAuthFormat")
+ require.NotContains(t, body, clientIDCheck, "client ID must not be used as a bearer token")
+ require.NotContains(t, body, clientSecretCheck, "client secret must not be used as a bearer token")
+ })
+ }
}
func TestAuthLoginEnvVarsUseShellSafePrefix(t *testing.T) {
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 7a7b0719..57318cd2 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -292,9 +292,12 @@ func (c *Config) AuthHeader() string {
{{- end}}
{{- end}}
{{- else if or (eq .Auth.Type "bearer_token") (eq .Auth.Type "oauth2")}}
-{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
- // Under OAuth2 client_credentials the env var is the Client ID, not a
- // usable bearer; the minted AccessToken must win.
+{{- if or (eq .Auth.Type "oauth2") (and (eq .Auth.Type "bearer_token") (eq .Auth.EffectiveOAuth2Grant "client_credentials"))}}
+ // Under OAuth2 (and bearer_token specs running the client_credentials
+ // grant) the configured env vars hold client credentials (client_id /
+ // client_secret), not a usable bearer; the minted AccessToken must
+ // win. Sending the client_id as Authorization: Bearer surfaces as
+ // token_rejected at the API.
if c.AccessToken != "" {
c.AuthSource = "oauth2"
{{- if .Auth.Format}}
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
index e4c43288..17959b2c 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
@@ -97,8 +97,11 @@ func (c *Config) AuthHeader() string {
if c.AuthHeaderVal != "" {
return c.AuthHeaderVal
}
- // Under OAuth2 client_credentials the env var is the Client ID, not a
- // usable bearer; the minted AccessToken must win.
+ // Under OAuth2 (and bearer_token specs running the client_credentials
+ // grant) the configured env vars hold client credentials (client_id /
+ // client_secret), not a usable bearer; the minted AccessToken must
+ // win. Sending the client_id as Authorization: Bearer surfaces as
+ // token_rejected at the API.
if c.AccessToken != "" {
c.AuthSource = "oauth2"
return "Bearer " + c.AccessToken
← fef70ba1 fix(cli): bind typed upsert values in column-declaration ord
·
back to Cli Printing Press
·
fix(skills): gate polish Publish Offer on --standalone, not 54f007f5 →