← back to Cli Printing Press
fix(cli): clear every emitted credential field in ClearTokens (#1433)
2bf7d1e759ee602d9ba5fc038dcd2da83c57f07e · 2026-05-15 02:58:49 -0700 · Trevin Chow
* fix(cli): clear every emitted credential field in ClearTokens
The config-template's `ClearTokens()` only zeroed the OAuth trio
(`AccessToken`, `RefreshToken`, `TokenExpiry`), so `auth logout`
succeeded but the working credential persisted on disk for any CLI
whose AuthHeader() falls back to env-var-derived fields. Every
api_key-auth CLI inherited the bug.
Expand ClearTokens to also zero `AuthHeaderVal` (cached header that
short-circuits AuthHeader()), every non-builtin EnvVarSpecs / EnvVars
field, and every non-builtin AdditionalHeaders sibling-scheme field.
Mirrors the existing clear-on-write pattern in SaveBearerToken.
Refresh the three affected goldens (golden-api, oauth2-cc, rich-auth)
and add a focused regression test that pins the new emission for an
api_key CLI plus the builtin-collision case (no duplicate
`c.AccessToken = ""` lines when env vars resolve to OAuth-trio fields).
Closes #1328
* fix(cli): clear ClientID and ClientSecret in ClearTokens
SaveTokens (called from both the oauth2 auth-code and the oauth2-cc
flows) persists ClientID/ClientSecret to disk as part of the credential
record. Leaving them after `auth logout` let `auth login` re-mint a new
access token unattended — not a true credential wipe for either oauth2
auth shape.
Zero ClientID and ClientSecret alongside the OAuth trio in ClearTokens,
and pin the fix with a focused oauth2-cc test. Restore the `// fallback
to legacy EnvVars when EnvVarSpecs is empty` comment in the new
ClearTokens block for symmetry with the same fallback in SaveBearerToken
and the Config struct declaration.
Refresh the three goldens that have HasAuthCommand emission.
Resolves Greptile feedback on #1433.
Files touched
A internal/generator/cleartokens_test.goM internal/generator/templates/config.go.tmplM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
Diff
commit 2bf7d1e759ee602d9ba5fc038dcd2da83c57f07e
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 15 02:58:49 2026 -0700
fix(cli): clear every emitted credential field in ClearTokens (#1433)
* fix(cli): clear every emitted credential field in ClearTokens
The config-template's `ClearTokens()` only zeroed the OAuth trio
(`AccessToken`, `RefreshToken`, `TokenExpiry`), so `auth logout`
succeeded but the working credential persisted on disk for any CLI
whose AuthHeader() falls back to env-var-derived fields. Every
api_key-auth CLI inherited the bug.
Expand ClearTokens to also zero `AuthHeaderVal` (cached header that
short-circuits AuthHeader()), every non-builtin EnvVarSpecs / EnvVars
field, and every non-builtin AdditionalHeaders sibling-scheme field.
Mirrors the existing clear-on-write pattern in SaveBearerToken.
Refresh the three affected goldens (golden-api, oauth2-cc, rich-auth)
and add a focused regression test that pins the new emission for an
api_key CLI plus the builtin-collision case (no duplicate
`c.AccessToken = ""` lines when env vars resolve to OAuth-trio fields).
Closes #1328
* fix(cli): clear ClientID and ClientSecret in ClearTokens
SaveTokens (called from both the oauth2 auth-code and the oauth2-cc
flows) persists ClientID/ClientSecret to disk as part of the credential
record. Leaving them after `auth logout` let `auth login` re-mint a new
access token unattended — not a true credential wipe for either oauth2
auth shape.
Zero ClientID and ClientSecret alongside the OAuth trio in ClearTokens,
and pin the fix with a focused oauth2-cc test. Restore the `// fallback
to legacy EnvVars when EnvVarSpecs is empty` comment in the new
ClearTokens block for symmetry with the same fallback in SaveBearerToken
and the Config struct declaration.
Refresh the three goldens that have HasAuthCommand emission.
Resolves Greptile feedback on #1433.
---
internal/generator/cleartokens_test.go | 103 +++++++++++++++++++++
internal/generator/templates/config.go.tmpl | 28 ++++++
.../internal/config/config.go | 11 +++
.../internal/config/config.go | 16 ++++
.../internal/config/config.go | 10 ++
5 files changed, 168 insertions(+)
diff --git a/internal/generator/cleartokens_test.go b/internal/generator/cleartokens_test.go
new file mode 100644
index 00000000..686de118
--- /dev/null
+++ b/internal/generator/cleartokens_test.go
@@ -0,0 +1,103 @@
+package generator
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestClearTokensClearsEmittedCredentialFields pins that the generated
+// (*Config).ClearTokens body zeroes every credential-bearing field the
+// template emits for an api_key CLI, not only the OAuth trio. AuthHeader()
+// falls back to the env-var-derived field for api_key auth, so leaving
+// it set after logout would silently re-authenticate the next command.
+func TestClearTokensClearsEmittedCredentialFields(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("clear-tokens")
+ apiSpec.Auth.EnvVars = []string{"PRINTING_PRESS_APIKEY"}
+
+ outputDir := filepath.Join(t.TempDir(), "clear-tokens-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configSrc := readGeneratedFile(t, outputDir, "internal", "config", "config.go")
+ body := extractClearTokensBody(t, configSrc)
+
+ assert.Contains(t, body, "c.AccessToken = \"\"", "AccessToken should be cleared")
+ assert.Contains(t, body, "c.RefreshToken = \"\"", "RefreshToken should be cleared")
+ assert.Contains(t, body, "c.TokenExpiry = time.Time{}", "TokenExpiry should be cleared")
+ assert.Contains(t, body, "c.AuthHeaderVal = \"\"", "AuthHeaderVal should be cleared (cached header could re-authenticate)")
+ assert.Contains(t, body, "c.PrintingPressApikey = \"\"", "env-var-derived API key field should be cleared")
+}
+
+// TestClearTokensClearsOAuth2ClientCredentials pins that ClientID and
+// ClientSecret are zeroed on logout. SaveTokens (called from the
+// oauth2 auth-code and client_credentials flows) persists them to disk,
+// so leaving them after `auth logout` would let `auth login` re-mint a
+// new access token unattended — not a true logout.
+func TestClearTokensClearsOAuth2ClientCredentials(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("oauth2-cc-logout")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "oauth2",
+ TokenURL: "https://api.example.com/oauth/token",
+ OAuth2Grant: "client_credentials",
+ EnvVars: []string{"OAUTH2_CC_LOGOUT_CLIENT_ID", "OAUTH2_CC_LOGOUT_CLIENT_SECRET"},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "oauth2-cc-logout-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configSrc := readGeneratedFile(t, outputDir, "internal", "config", "config.go")
+ body := extractClearTokensBody(t, configSrc)
+
+ assert.Contains(t, body, "c.ClientID = \"\"", "ClientID must be cleared so auth login can't re-mint headlessly")
+ assert.Contains(t, body, "c.ClientSecret = \"\"", "ClientSecret must be cleared so auth login can't re-mint headlessly")
+}
+
+// TestClearTokensSkipsBuiltinCollisions pins that env vars whose
+// placeholder collides with a builtin Config tag (e.g. *_ACCESS_TOKEN
+// resolves to AccessToken) don't produce a duplicate clear: the OAuth-trio
+// line already covers them and a second `c.AccessToken = ""` would be
+// dead-code duplication.
+func TestClearTokensSkipsBuiltinCollisions(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("builtin-collision")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"PRINTING_PRESS_ACCESS_TOKEN"},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "builtin-collision-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configSrc := readGeneratedFile(t, outputDir, "internal", "config", "config.go")
+ body := extractClearTokensBody(t, configSrc)
+
+ count := strings.Count(body, "c.AccessToken = \"\"")
+ assert.Equal(t, 1, count, "AccessToken should be cleared exactly once; env-var loop must skip the builtin collision")
+}
+
+func extractClearTokensBody(t *testing.T, src string) string {
+ t.Helper()
+ const decl = "func (c *Config) ClearTokens() error {"
+ start := strings.Index(src, decl)
+ require.GreaterOrEqual(t, start, 0, "ClearTokens declaration not found in generated config.go")
+ body := src[start+len(decl):]
+ end := strings.Index(body, "\nfunc ")
+ if end < 0 {
+ end = len(body)
+ }
+ return body[:end]
+}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 07a2f9c7..5d0b6a48 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -523,11 +523,39 @@ func (c *Config) SaveBearerToken(accessToken string, refreshedAt time.Time) erro
{{- end}}
func (c *Config) ClearTokens() error {
+ // AuthHeader() falls back to the env-var-derived fields when AuthHeaderVal
+ // and AccessToken are empty, so dropping the working credential requires
+ // zeroing every emitted credential field, not just the OAuth trio.
+ // ClientID/ClientSecret persist to disk via SaveTokens for the oauth2
+ // and oauth2-cc flows, so logout must wipe them too; otherwise
+ // `auth login` can re-mint a new access token unattended.
+ c.AuthHeaderVal = ""
c.AccessToken = ""
c.RefreshToken = ""
c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
{{- if .BearerRefresh.Enabled}}
c.BearerTokenRefreshedAt = time.Time{}
+{{- end}}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if not (envVarIsBuiltinField .Name)}}
+ c.{{envVarField .Name}} = ""
+{{- end}}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+ // fallback to legacy EnvVars when EnvVarSpecs is empty
+{{- range .Auth.EnvVars}}
+{{- if not (envVarIsBuiltinField .)}}
+ c.{{envVarField .}} = ""
+{{- end}}
+{{- end}}
+{{- end}}
+{{- range .Auth.AdditionalHeaders}}
+{{- if not (envVarIsBuiltinField .EnvVar.Name)}}
+ c.{{envVarField .EnvVar.Name}} = ""
+{{- end}}
{{- end}}
return c.save()
}
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 552a9037..af857d6a 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
@@ -132,9 +132,20 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
}
func (c *Config) ClearTokens() error {
+ // AuthHeader() falls back to the env-var-derived fields when AuthHeaderVal
+ // and AccessToken are empty, so dropping the working credential requires
+ // zeroing every emitted credential field, not just the OAuth trio.
+ // ClientID/ClientSecret persist to disk via SaveTokens for the oauth2
+ // and oauth2-cc flows, so logout must wipe them too; otherwise
+ // `auth login` can re-mint a new access token unattended.
+ c.AuthHeaderVal = ""
c.AccessToken = ""
c.RefreshToken = ""
c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
+ c.PrintingPressOauth2ClientId = ""
+ c.PrintingPressOauth2ClientSecret = ""
return c.save()
}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
index fdffe031..5ac01bf9 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
@@ -179,9 +179,25 @@ func (c *Config) SaveCredential(token string) error {
}
func (c *Config) ClearTokens() error {
+ // AuthHeader() falls back to the env-var-derived fields when AuthHeaderVal
+ // and AccessToken are empty, so dropping the working credential requires
+ // zeroing every emitted credential field, not just the OAuth trio.
+ // ClientID/ClientSecret persist to disk via SaveTokens for the oauth2
+ // and oauth2-cc flows, so logout must wipe them too; otherwise
+ // `auth login` can re-mint a new access token unattended.
+ c.AuthHeaderVal = ""
c.AccessToken = ""
c.RefreshToken = ""
c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
+ c.RichAuthApiKey = ""
+ c.RichAuthClientId = ""
+ c.RichAuthClientSecret = ""
+ c.RichAuthSessionCookie = ""
+ c.RichAuthOptionalToken = ""
+ c.RichAuthBotToken = ""
+ c.RichAuthUserToken = ""
return c.save()
}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
index 612ec5a6..1447dccf 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
@@ -131,9 +131,19 @@ func (c *Config) SaveCredential(token string) error {
}
func (c *Config) ClearTokens() error {
+ // AuthHeader() falls back to the env-var-derived fields when AuthHeaderVal
+ // and AccessToken are empty, so dropping the working credential requires
+ // zeroing every emitted credential field, not just the OAuth trio.
+ // ClientID/ClientSecret persist to disk via SaveTokens for the oauth2
+ // and oauth2-cc flows, so logout must wipe them too; otherwise
+ // `auth login` can re-mint a new access token unattended.
+ c.AuthHeaderVal = ""
c.AccessToken = ""
c.RefreshToken = ""
c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
+ c.PrintingPressGoldenApiKey = ""
return c.save()
}
← 73fe5673 fix(cli): extend verify-skill recipe scan to README.md (#143
·
back to Cli Printing Press
·
fix(skills): use api-slug in publish Step 6 staged path (#14 ce3aeb4a →