[object Object]

← 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

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 →