← back to Cli Printing Press
feat(cli): OAuth2 client_credentials grant + bearer_token AuthHeader precedence fix (#528)
593ce970a252630f7da42f78dcb885564bf518da · 2026-05-03 02:24:11 -0700 · Trevin Chow
* feat(cli): AuthConfig.OAuth2Grant field + validation
Add a discriminator field on AuthConfig for OAuth2 sub-flows. Defaults to
"authorization_code" (the existing 3-legged user-OAuth flow with callback
server) when empty. "client_credentials" enables the 2-legged
server-to-server flow used by Auth0 Management, Microsoft Graph daemon
apps, FedEx, etc. — POST to TokenURL with form-encoded credentials, no
user redirect, no refresh_token.
This commit only adds the field, the EffectiveOAuth2Grant() helper, the
public OAuth2Grant* constants, and validation. Generator templates that
branch on the grant ship in subsequent commits (U3, U4 of the OAuth2
client_credentials WU plan).
Field is meaningless for non-oauth2 Auth.Type values; validation
intentionally does not cross-check type/grant combos to keep the
validator simple. Templates that consume the field should ignore it for
non-oauth2 types, mirroring how SessionTTLHours and similar fields
behave.
Refs #525 (FedEx retro WU-1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): OpenAPI parser detects oauth2 client_credentials flow
When a securityScheme of type oauth2 declares a clientCredentials flow,
the parser now sets Auth.OAuth2Grant to "client_credentials" alongside
the existing TokenURL + Scopes extraction. AuthorizationURL stays empty
because the cc flow has no user redirect.
When a single scheme declares both authorizationCode and clientCredentials
flows, clientCredentials wins. Server-to-server is the more common shape
for printed CLIs (which run in CI/scripts, not interactive browsers); the
spec author can override post-import by setting OAuth2Grant explicitly.
Documented in a code comment so future readers don't reorder accidentally.
When a clientCredentials block exists but its tokenUrl is empty
(malformed spec), the parser falls through to the next flow rather than
crashing. Tested as TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch.
Auth.Type stays at "bearer_token" (the existing parser convention for all
oauth2 schemes) — OAuth2Grant is the discriminator that downstream
templates branch on.
Refs #525 (FedEx retro WU-1, U2 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): generator emits OAuth2 client_credentials login command
Adds a new auth template (auth_client_credentials.go.tmpl) and wires its
selection in the generator's per-spec template-choice ladder. Specs with
auth.OAuth2Grant=client_credentials and a non-empty TokenURL get a
generated `auth login` command that:
- POSTs grant_type=client_credentials&client_id=&client_secret= to the
spec's TokenURL with form-encoded body
- Defaults --client-id / --client-secret to the spec's first two
auth.env_vars (FedEx pattern: FEDEX_API_KEY / FEDEX_SECRET_KEY)
- Caches the access_token + expires_in via cfg.SaveTokens
- Short-circuits under PRINTING_PRESS_VERIFY=1 (cliutil.IsVerifyEnv)
so shipcheck side-effect detection treats it as safe
- Carries Annotations{"mcp:hidden": "true"} so the MCP surface skips
it (it's a setup command, not an agent tool)
Template selection priority is documented inline:
1. OAuth2 client_credentials (server-to-server, no user redirect)
2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
3. Browser-cookie / composed / persisted-query
4. Simple token-management (catch-all)
The new template emits its own mintClientCredentialsToken helper —
client.go.tmpl will reuse the same algorithm in U4 for proactive
refresh, but the helper lives with the login command since that's its
primary call site.
Tests:
- TestGenerateOAuth2ClientCredentialsAuthTemplate pins the new
template's contents (login command, token URL, env-var defaults,
verify-env short-circuit, MCP-hidden annotation).
- TestGenerateOAuth2AuthorizationCodeRegression pins the existing
3-legged path: spec with AuthorizationURL=non-empty + no
OAuth2Grant continues to select auth.go.tmpl, NOT the new template.
Refs #525 (FedEx retro WU-1, U3 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): config + client templates honor oauth2 client_credentials
Three related changes in the auth-flow plumbing for OAuth2
client_credentials specs:
1. config.go.tmpl bearer_token precedence reorder. AccessToken (cached
after auth login or set-token) now wins over the env-var-as-bearer
fallback. Was the FedEx "Invalid CXS JWT" bug class: env var holds
the Client ID for OAuth2 specs, and sending it as Bearer 401s every
request after a successful login. The env-var fallback remains for
the simple case where the env var IS a usable bearer JWT (personal
access tokens, GitHub PATs).
2. client.go.tmpl proactive client_credentials refresh. When
Auth.OAuth2Grant=client_credentials, the client emits new helpers
needsClientCredentialsMint, resolveClientCredentials, and
mintClientCredentials. authHeader() consults them on each request
and re-mints via the same grant when the cached token is missing or
within a 60-second safety window of expiry.
3. cookie / composed AuthHeader behavior unchanged. Only bearer_token
flipped; cookie and composed still check env-var first.
Tests:
- TestAuthHeader_BearerTokenAccessTokenWinsOverEnv (new precedence)
- TestAuthHeader_EnvVarWinsOverFileTokenForCookieComposed (unchanged)
- TestGenerateOAuth2ClientCredentialsClientRefresh
- TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged
Refs #525 (FedEx retro WU-1, U4 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): simplify OAuth2 client_credentials surface from /simplify pass
- Drop local truncateForError; use cliutil.SanitizeErrorBody so error
bodies are also redacted of credential-shaped strings.
- Drop unused tokenResponse.TokenType / Scope fields.
- Trim over-documented OAuth2Grant doc comments and the bearer_token
AuthHeader precedence comment.
- Drop incident-name reference per AGENTS.md (no incident references in
code comments).
No behavior change.
* fix(cli): scope bearer_token AccessToken-first precedence to client_credentials
Review surfaced that the bearer_token AuthHeader precedence flip applied to
all bearer_token CLIs unconditionally, regressing the env-first convention
established for plain bearer_token / PAT-style flows. Gate the new precedence
on EffectiveOAuth2Grant=client_credentials so:
- client_credentials: cached AccessToken wins (env var is the Client ID,
not a bearer)
- plain bearer_token, cookie, composed: env-first preserved (kubectl/gh/aws
convention; freshly-rotated env var must not be silently shadowed by a
stale on-disk AccessToken)
Also from the review pass:
- generator.go: read OAuth2Grant via EffectiveOAuth2Grant() to honor the
default-lives-in-one-place rule the spec doc states
- auth_client_credentials.go.tmpl: drop misleading comment claiming the
helper is shared with the client refresh path (it isn't; client.go.tmpl
has its own parallel mintClientCredentials)
- auth_env_precedence_test.go: split into two tests pinning both sides of
the gating (client_credentials AccessToken-first; bearer/cookie/composed
env-first)
* feat(cli): serialize OAuth2 client_credentials token mints with sync.Mutex
Without serialization, N parallel API calls inside the 60s pre-expiry
window each see needsClientCredentialsMint == true, each call
mintClientCredentials concurrently, race on *config.Config field writes,
and concurrently os.WriteFile to the same config path. Go's race detector
would fire on -race; production behavior is benign extra mints + a final
last-writer-wins file (the OS write is atomic for small payloads), but
the data race itself is real.
Add a per-grant ccMu sync.Mutex on the Client struct (only emitted when
EffectiveOAuth2Grant=client_credentials) and use a double-checked lock in
authHeader so only one goroutine mints under contention.
* test(cli): add golden case for OAuth2 client_credentials auth shape
AGENTS.md (Golden Output Harness) requires extending the golden suite
when the machine gains a new auth shape. Adds:
- testdata/golden/fixtures/golden-api-oauth2-cc.yaml — minimal spec
declaring only flows.clientCredentials.
- testdata/golden/cases/generate-golden-api-oauth2-cc/ — runs generate
and snapshots the three files where the auth shape contract lives:
internal/cli/auth.go (auth_client_credentials.go.tmpl output),
internal/client/client.go (mint helpers, ccMu mutex, double-checked
lock), internal/config/config.go (AuthHeader AccessToken-first gated
branch).
Locks the parser → generator → templates pipeline against silent
regression. If a future refactor breaks the OAuth2Grant gating, the
template selection ladder, or the AuthHeader precedence, the case
fails with a content diff.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/auth_env_precedence_test.goM internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/auth_client_credentials.go.tmplM internal/generator/templates/client.go.tmplM internal/generator/templates/config.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.goM internal/spec/spec_test.goA testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txtA testdata/golden/cases/generate-golden-api-oauth2-cc/command.txtA testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txtA testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.goA testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.goA testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.goA testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txtA testdata/golden/expected/generate-golden-api-oauth2-cc/stdout.txtA testdata/golden/fixtures/golden-api-oauth2-cc.yaml
Diff
commit 593ce970a252630f7da42f78dcb885564bf518da
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 3 02:24:11 2026 -0700
feat(cli): OAuth2 client_credentials grant + bearer_token AuthHeader precedence fix (#528)
* feat(cli): AuthConfig.OAuth2Grant field + validation
Add a discriminator field on AuthConfig for OAuth2 sub-flows. Defaults to
"authorization_code" (the existing 3-legged user-OAuth flow with callback
server) when empty. "client_credentials" enables the 2-legged
server-to-server flow used by Auth0 Management, Microsoft Graph daemon
apps, FedEx, etc. — POST to TokenURL with form-encoded credentials, no
user redirect, no refresh_token.
This commit only adds the field, the EffectiveOAuth2Grant() helper, the
public OAuth2Grant* constants, and validation. Generator templates that
branch on the grant ship in subsequent commits (U3, U4 of the OAuth2
client_credentials WU plan).
Field is meaningless for non-oauth2 Auth.Type values; validation
intentionally does not cross-check type/grant combos to keep the
validator simple. Templates that consume the field should ignore it for
non-oauth2 types, mirroring how SessionTTLHours and similar fields
behave.
Refs #525 (FedEx retro WU-1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): OpenAPI parser detects oauth2 client_credentials flow
When a securityScheme of type oauth2 declares a clientCredentials flow,
the parser now sets Auth.OAuth2Grant to "client_credentials" alongside
the existing TokenURL + Scopes extraction. AuthorizationURL stays empty
because the cc flow has no user redirect.
When a single scheme declares both authorizationCode and clientCredentials
flows, clientCredentials wins. Server-to-server is the more common shape
for printed CLIs (which run in CI/scripts, not interactive browsers); the
spec author can override post-import by setting OAuth2Grant explicitly.
Documented in a code comment so future readers don't reorder accidentally.
When a clientCredentials block exists but its tokenUrl is empty
(malformed spec), the parser falls through to the next flow rather than
crashing. Tested as TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch.
Auth.Type stays at "bearer_token" (the existing parser convention for all
oauth2 schemes) — OAuth2Grant is the discriminator that downstream
templates branch on.
Refs #525 (FedEx retro WU-1, U2 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): generator emits OAuth2 client_credentials login command
Adds a new auth template (auth_client_credentials.go.tmpl) and wires its
selection in the generator's per-spec template-choice ladder. Specs with
auth.OAuth2Grant=client_credentials and a non-empty TokenURL get a
generated `auth login` command that:
- POSTs grant_type=client_credentials&client_id=&client_secret= to the
spec's TokenURL with form-encoded body
- Defaults --client-id / --client-secret to the spec's first two
auth.env_vars (FedEx pattern: FEDEX_API_KEY / FEDEX_SECRET_KEY)
- Caches the access_token + expires_in via cfg.SaveTokens
- Short-circuits under PRINTING_PRESS_VERIFY=1 (cliutil.IsVerifyEnv)
so shipcheck side-effect detection treats it as safe
- Carries Annotations{"mcp:hidden": "true"} so the MCP surface skips
it (it's a setup command, not an agent tool)
Template selection priority is documented inline:
1. OAuth2 client_credentials (server-to-server, no user redirect)
2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
3. Browser-cookie / composed / persisted-query
4. Simple token-management (catch-all)
The new template emits its own mintClientCredentialsToken helper —
client.go.tmpl will reuse the same algorithm in U4 for proactive
refresh, but the helper lives with the login command since that's its
primary call site.
Tests:
- TestGenerateOAuth2ClientCredentialsAuthTemplate pins the new
template's contents (login command, token URL, env-var defaults,
verify-env short-circuit, MCP-hidden annotation).
- TestGenerateOAuth2AuthorizationCodeRegression pins the existing
3-legged path: spec with AuthorizationURL=non-empty + no
OAuth2Grant continues to select auth.go.tmpl, NOT the new template.
Refs #525 (FedEx retro WU-1, U3 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): config + client templates honor oauth2 client_credentials
Three related changes in the auth-flow plumbing for OAuth2
client_credentials specs:
1. config.go.tmpl bearer_token precedence reorder. AccessToken (cached
after auth login or set-token) now wins over the env-var-as-bearer
fallback. Was the FedEx "Invalid CXS JWT" bug class: env var holds
the Client ID for OAuth2 specs, and sending it as Bearer 401s every
request after a successful login. The env-var fallback remains for
the simple case where the env var IS a usable bearer JWT (personal
access tokens, GitHub PATs).
2. client.go.tmpl proactive client_credentials refresh. When
Auth.OAuth2Grant=client_credentials, the client emits new helpers
needsClientCredentialsMint, resolveClientCredentials, and
mintClientCredentials. authHeader() consults them on each request
and re-mints via the same grant when the cached token is missing or
within a 60-second safety window of expiry.
3. cookie / composed AuthHeader behavior unchanged. Only bearer_token
flipped; cookie and composed still check env-var first.
Tests:
- TestAuthHeader_BearerTokenAccessTokenWinsOverEnv (new precedence)
- TestAuthHeader_EnvVarWinsOverFileTokenForCookieComposed (unchanged)
- TestGenerateOAuth2ClientCredentialsClientRefresh
- TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged
Refs #525 (FedEx retro WU-1, U4 of the implementation plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): simplify OAuth2 client_credentials surface from /simplify pass
- Drop local truncateForError; use cliutil.SanitizeErrorBody so error
bodies are also redacted of credential-shaped strings.
- Drop unused tokenResponse.TokenType / Scope fields.
- Trim over-documented OAuth2Grant doc comments and the bearer_token
AuthHeader precedence comment.
- Drop incident-name reference per AGENTS.md (no incident references in
code comments).
No behavior change.
* fix(cli): scope bearer_token AccessToken-first precedence to client_credentials
Review surfaced that the bearer_token AuthHeader precedence flip applied to
all bearer_token CLIs unconditionally, regressing the env-first convention
established for plain bearer_token / PAT-style flows. Gate the new precedence
on EffectiveOAuth2Grant=client_credentials so:
- client_credentials: cached AccessToken wins (env var is the Client ID,
not a bearer)
- plain bearer_token, cookie, composed: env-first preserved (kubectl/gh/aws
convention; freshly-rotated env var must not be silently shadowed by a
stale on-disk AccessToken)
Also from the review pass:
- generator.go: read OAuth2Grant via EffectiveOAuth2Grant() to honor the
default-lives-in-one-place rule the spec doc states
- auth_client_credentials.go.tmpl: drop misleading comment claiming the
helper is shared with the client refresh path (it isn't; client.go.tmpl
has its own parallel mintClientCredentials)
- auth_env_precedence_test.go: split into two tests pinning both sides of
the gating (client_credentials AccessToken-first; bearer/cookie/composed
env-first)
* feat(cli): serialize OAuth2 client_credentials token mints with sync.Mutex
Without serialization, N parallel API calls inside the 60s pre-expiry
window each see needsClientCredentialsMint == true, each call
mintClientCredentials concurrently, race on *config.Config field writes,
and concurrently os.WriteFile to the same config path. Go's race detector
would fire on -race; production behavior is benign extra mints + a final
last-writer-wins file (the OS write is atomic for small payloads), but
the data race itself is real.
Add a per-grant ccMu sync.Mutex on the Client struct (only emitted when
EffectiveOAuth2Grant=client_credentials) and use a double-checked lock in
authHeader so only one goroutine mints under contention.
* test(cli): add golden case for OAuth2 client_credentials auth shape
AGENTS.md (Golden Output Harness) requires extending the golden suite
when the machine gains a new auth shape. Adds:
- testdata/golden/fixtures/golden-api-oauth2-cc.yaml — minimal spec
declaring only flows.clientCredentials.
- testdata/golden/cases/generate-golden-api-oauth2-cc/ — runs generate
and snapshots the three files where the auth shape contract lives:
internal/cli/auth.go (auth_client_credentials.go.tmpl output),
internal/client/client.go (mint helpers, ccMu mutex, double-checked
lock), internal/config/config.go (AuthHeader AccessToken-first gated
branch).
Locks the parser → generator → templates pipeline against silent
regression. If a future refactor breaks the OAuth2Grant gating, the
template selection ladder, or the AuthHeader precedence, the case
fails with a content diff.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/auth_env_precedence_test.go | 80 +++-
internal/generator/generator.go | 21 +-
internal/generator/generator_test.go | 194 ++++++++
.../templates/auth_client_credentials.go.tmpl | 248 ++++++++++
internal/generator/templates/client.go.tmpl | 101 ++++
internal/generator/templates/config.go.tmpl | 28 ++
internal/openapi/parser.go | 15 +-
internal/openapi/parser_test.go | 131 ++++++
internal/spec/spec.go | 40 ++
internal/spec/spec_test.go | 58 +++
.../generate-golden-api-oauth2-cc/artifacts.txt | 26 ++
.../generate-golden-api-oauth2-cc/command.txt | 2 +
.../generate-golden-api-oauth2-cc/exit.txt | 1 +
.../printing-press-oauth2-cc/internal/cli/auth.go | 218 +++++++++
.../internal/client/client.go | 515 +++++++++++++++++++++
.../internal/config/config.go | 125 +++++
.../generate-golden-api-oauth2-cc/stderr.txt | 1 +
.../generate-golden-api-oauth2-cc/stdout.txt | 0
testdata/golden/fixtures/golden-api-oauth2-cc.yaml | 47 ++
19 files changed, 1822 insertions(+), 29 deletions(-)
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index 4c8a6af3..18650b0d 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -11,10 +11,47 @@ import (
"github.com/stretchr/testify/require"
)
-// TestAuthHeader_EnvVarWinsOverFileToken pins that the generated
-// Config.AuthHeader() checks the env-var-backed field BEFORE the
-// file-stored AccessToken for bearer_token, cookie, and composed auth
-// types — env > config convention (kubectl, gh, aws, gcloud).
+// TestAuthHeader_ClientCredentialsAccessTokenWinsOverEnv pins that under
+// OAuth2 client_credentials the cached AccessToken wins over the env-var
+// fallback. The env var is the Client ID, not a usable bearer JWT.
+func TestAuthHeader_ClientCredentialsAccessTokenWinsOverEnv(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("cc-precedence")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ EnvVars: []string{"CC_AUTH_TEST_CLIENT_ID"},
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://example.com/token",
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "cc-precedence-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ content := string(cfgSrc)
+
+ envCheck := "if c." + resolveEnvVarField("CC_AUTH_TEST_CLIENT_ID") + ` != ""`
+ tokenCheck := `if c.AccessToken != ""`
+
+ require.Contains(t, content, envCheck, "AuthHeader must keep the env-var fallback")
+ require.Contains(t, content, tokenCheck, "AuthHeader must check AccessToken")
+
+ body := authHeaderBody(t, content)
+ envIdx := strings.Index(body, envCheck)
+ tokenIdx := strings.Index(body, tokenCheck)
+ require.NotEqual(t, -1, envIdx)
+ require.NotEqual(t, -1, tokenIdx)
+ assert.Less(t, tokenIdx, envIdx,
+ "AccessToken check must appear BEFORE env-var fallback under OAuth2 client_credentials")
+}
+
+// TestAuthHeader_EnvVarWinsOverFileToken pins env-first precedence for
+// the non-client_credentials cases — plain bearer_token (PAT-style),
+// cookie, and composed all follow the env > config convention so a
+// freshly-rotated env var wins over a stale on-disk AccessToken.
func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
t.Parallel()
@@ -49,27 +86,28 @@ func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
envCheck := "if c." + resolveEnvVarField(tc.envVar) + ` != ""`
tokenCheck := `if c.AccessToken != ""`
- require.Contains(t, content, envCheck,
- "AuthHeader must check the env-var field for type %q", tc.authType)
- require.Contains(t, content, tokenCheck,
- "AuthHeader must still check AccessToken for type %q", tc.authType)
-
- authHeaderStart := strings.Index(content, "func (c *Config) AuthHeader() string {")
- require.NotEqual(t, -1, authHeaderStart, "AuthHeader function must be emitted")
- body := content[authHeaderStart:]
- // Bound the search to AuthHeader's body. Skip the first byte so
- // the "next func" lookup finds the func AFTER AuthHeader, not
- // AuthHeader's own opener.
- if next := strings.Index(body[1:], "\nfunc "); next != -1 {
- body = body[:next+1]
- }
+ require.Contains(t, content, envCheck)
+ require.Contains(t, content, tokenCheck)
+ body := authHeaderBody(t, content)
envIdx := strings.Index(body, envCheck)
tokenIdx := strings.Index(body, tokenCheck)
- require.NotEqual(t, -1, envIdx)
- require.NotEqual(t, -1, tokenIdx)
assert.Less(t, envIdx, tokenIdx,
- "env-var check must appear BEFORE AccessToken check in AuthHeader for type %q", tc.authType)
+ "env-var check must appear BEFORE AccessToken check for type %q", tc.authType)
})
}
}
+
+// 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.
+func authHeaderBody(t *testing.T, content string) string {
+ t.Helper()
+ start := strings.Index(content, "func (c *Config) AuthHeader() string {")
+ require.NotEqual(t, -1, start, "AuthHeader function must be emitted")
+ body := content[start:]
+ if next := strings.Index(body[1:], "\nfunc "); next != -1 {
+ body = body[:next+1]
+ }
+ return body
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 70eb4cf5..518fc78d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1588,16 +1588,23 @@ func (g *Generator) renderAuthFiles() error {
if !g.shouldEmitAuth() {
return nil
}
- // Render auth command - use full OAuth2 template when authorization URL is present,
- // browser cookie template for cookie-auth APIs, otherwise simple token-management template
+ // Render auth command. Template selection priority:
+ // 1. OAuth2 client_credentials (server-to-server, no user redirect)
+ // 2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
+ // 3. Browser-cookie / composed / persisted-query
+ // 4. Simple token-management (catch-all)
authPath := filepath.Join("internal", "cli", "auth.go")
authTmpl := "auth_simple.go.tmpl"
- if g.Spec.Auth.AuthorizationURL != "" {
+ switch {
+ case g.Spec.Auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && g.Spec.Auth.TokenURL != "":
+ authTmpl = "auth_client_credentials.go.tmpl"
+ case g.Spec.Auth.AuthorizationURL != "":
authTmpl = "auth.go.tmpl"
- } else if g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query") {
- // Select the browser-aware auth template for browser-cookie auth or a
- // persisted-query registry, even for auth.type:none. Query refresh flows
- // need temporary browser capture support, not a resident browser transport.
+ case g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query"):
+ // Browser-aware auth template for browser-cookie auth or a
+ // persisted-query registry, even for auth.type:none. Query refresh
+ // flows need temporary browser capture support, not a resident
+ // browser transport.
authTmpl = "auth_browser.go.tmpl"
}
authData := &authTemplateData{
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 17432509..9c2df7c8 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,200 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
}
+func TestGenerateOAuth2ClientCredentialsAuthTemplate(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "ccgrant",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://api.example.com/oauth/token",
+ EnvVars: []string{"CCGRANT_API_KEY", "CCGRANT_SECRET_KEY"},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ccgrant-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ authBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+ require.NoError(t, err)
+ body := string(authBytes)
+
+ // New login command with the right token URL hardcoded.
+ assert.Contains(t, body, `newAuthLoginCmd(flags)`,
+ "client_credentials template emits a login command")
+ assert.Contains(t, body, `"https://api.example.com/oauth/token"`,
+ "login command POSTs to the spec's TokenURL")
+ assert.Contains(t, body, `"grant_type": {"client_credentials"}`,
+ "login command uses client_credentials grant")
+ assert.Contains(t, body, `os.Getenv("CCGRANT_API_KEY")`,
+ "client-id defaults to first env var")
+ assert.Contains(t, body, `os.Getenv("CCGRANT_SECRET_KEY")`,
+ "client-secret defaults to second env var")
+ // Verify-env short-circuit is present so the side-effect classifier
+ // doesn't false-positive during shipcheck.
+ assert.Contains(t, body, `cliutil.IsVerifyEnv()`,
+ "login command short-circuits under PRINTING_PRESS_VERIFY=1")
+ assert.Contains(t, body, `Annotations: map[string]string{"mcp:hidden": "true"}`,
+ "auth login is hidden from MCP")
+}
+
+func TestGenerateOAuth2AuthorizationCodeRegression(t *testing.T) {
+ t.Parallel()
+
+ // Spec with OAuth2 authorization_code (the existing 3-legged flow):
+ // AuthorizationURL non-empty, OAuth2Grant unset (defaults to
+ // authorization_code). Must continue to select auth.go.tmpl, NOT
+ // auth_client_credentials.go.tmpl.
+ apiSpec := &spec.APISpec{
+ Name: "ac3legged",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ AuthorizationURL: "https://api.example.com/oauth/authorize",
+ TokenURL: "https://api.example.com/oauth/token",
+ EnvVars: []string{"AC3LEGGED_TOKEN"},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ac3legged-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ authBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+ require.NoError(t, err)
+ body := string(authBytes)
+
+ // auth.go.tmpl emits the authorization_code grant body — should be
+ // present, NOT the client_credentials body.
+ assert.Contains(t, body, `"grant_type": {"authorization_code"}`,
+ "authorization_code spec keeps the existing 3-legged template")
+ assert.NotContains(t, body, `"grant_type": {"client_credentials"}`,
+ "authorization_code spec must NOT pick the client_credentials template")
+}
+
+func TestGenerateOAuth2ClientCredentialsClientRefresh(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "ccclient",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://api.example.com/oauth/token",
+ EnvVars: []string{"CCCLIENT_API_KEY", "CCCLIENT_SECRET_KEY"},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ccclient-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/items"}},
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ body := string(clientBytes)
+
+ assert.Contains(t, body, "func needsClientCredentialsMint",
+ "client_credentials spec emits the safety-window helper")
+ assert.Contains(t, body, "func resolveClientCredentials",
+ "client_credentials spec emits the env-var-fallback resolver")
+ assert.Contains(t, body, "func (c *Client) mintClientCredentials",
+ "client_credentials spec emits the proactive-mint helper")
+ assert.Contains(t, body, `"grant_type": {"client_credentials"}`,
+ "refresh path uses client_credentials grant, not refresh_token")
+ assert.Contains(t, body, `"https://api.example.com/oauth/token"`,
+ "mint POSTs to the spec's TokenURL")
+ assert.Contains(t, body, "time.Until(cfg.TokenExpiry) < 60*time.Second",
+ "60-second proactive refresh window")
+ assert.Contains(t, body, `os.Getenv("CCCLIENT_API_KEY")`,
+ "client-id resolver falls back to first env var")
+ assert.Contains(t, body, `os.Getenv("CCCLIENT_SECRET_KEY")`,
+ "client-secret resolver falls back to second env var")
+ assert.Contains(t, body, "ccMu sync.Mutex",
+ "client_credentials spec emits a mutex to serialize concurrent mints")
+ assert.Contains(t, body, "c.ccMu.Lock()",
+ "authHeader takes the mint mutex before re-checking the window")
+}
+
+func TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged(t *testing.T) {
+ t.Parallel()
+
+ // Spec with the existing 3-legged flow (AuthorizationURL non-empty,
+ // no OAuth2Grant). The client.go template's refresh path must keep
+ // using the refresh_token grant — no regression.
+ apiSpec := &spec.APISpec{
+ Name: "acclient",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ AuthorizationURL: "https://api.example.com/oauth/authorize",
+ TokenURL: "https://api.example.com/oauth/token",
+ EnvVars: []string{"ACCLIENT_TOKEN"},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/acclient-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/items"}},
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ body := string(clientBytes)
+
+ assert.Contains(t, body, `"grant_type": {"refresh_token"}`,
+ "authorization_code spec keeps refresh_token grant in refresh path")
+ assert.NotContains(t, body, "func mintClientCredentials",
+ "authorization_code spec must NOT emit client_credentials helpers")
+ assert.NotContains(t, body, "func needsClientCredentialsMint",
+ "authorization_code spec must NOT emit safety-window helper")
+ assert.NotContains(t, body, "ccMu sync.Mutex",
+ "authorization_code spec must NOT emit the client_credentials mint mutex")
+}
+
func TestGenerateAPIKeyAuthFormatSupportsTokenPlaceholder(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/auth_client_credentials.go.tmpl b/internal/generator/templates/auth_client_credentials.go.tmpl
new file mode 100644
index 00000000..d3bdd719
--- /dev/null
+++ b/internal/generator/templates/auth_client_credentials.go.tmpl
@@ -0,0 +1,248 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "{{modulePath}}/internal/cliutil"
+ "{{modulePath}}/internal/config"
+ "github.com/spf13/cobra"
+)
+
+// OAuth2 client_credentials grant: 2-legged server-to-server flow.
+// POST to TokenURL with form-encoded client_id/client_secret. No user
+// redirect, no refresh_token. The client re-mints when within 60s of expiry.
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+{{- if .Auth.EnvVars}}
+ Short: "Manage {{index .Auth.EnvVars 0}} credentials (login, status, logout)",
+{{- else}}
+ Short: "Manage OAuth2 credentials (login, status, logout)",
+{{- end}}
+ }
+
+ cmd.AddCommand(newAuthLoginCmd(flags))
+ cmd.AddCommand(newAuthStatusCmd(flags))
+ cmd.AddCommand(newAuthSetTokenCmd(flags))
+ cmd.AddCommand(newAuthLogoutCmd(flags))
+
+ return cmd
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+ var (
+ clientID string
+ clientSecret string
+ )
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Mint an OAuth2 bearer token via the client_credentials grant",
+ Long: strings.TrimSpace(` + "`" + `
+Mint an OAuth2 bearer token via POST {{.Auth.TokenURL}} with
+grant_type=client_credentials and cache it on disk. Subsequent commands
+auto-refresh the token before expiry.
+
+Credentials default to {{if .Auth.EnvVars}}{{index .Auth.EnvVars 0}} (Client ID){{if gt (len .Auth.EnvVars) 1}} and {{index .Auth.EnvVars 1}} (Client Secret){{end}}{{else}}your project's Client ID and Client Secret{{end}} environment variables.
+` + "`" + `),
+ Example: strings.Trim(` + "`" + `
+ # Use env vars
+ {{.Name}}-pp-cli auth login
+
+ # Explicit credentials
+ {{.Name}}-pp-cli auth login --client-id <id> --client-secret <secret>
+` + "`" + `, "\n"),
+ Annotations: map[string]string{"mcp:hidden": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliutil.IsVerifyEnv() {
+ fmt.Fprintln(cmd.OutOrStdout(), "would mint token via OAuth2 client_credentials")
+ return nil
+ }
+
+ if clientID == "" {
+{{- if .Auth.EnvVars}}
+ clientID = os.Getenv("{{index .Auth.EnvVars 0}}")
+{{- end}}
+ }
+ if clientSecret == "" {
+{{- if gt (len .Auth.EnvVars) 1}}
+ clientSecret = os.Getenv("{{index .Auth.EnvVars 1}}")
+{{- end}}
+ }
+ if clientID == "" || clientSecret == "" {
+{{- if gt (len .Auth.EnvVars) 1}}
+ return authErr(fmt.Errorf("client ID and secret required (set --client-id/--client-secret or {{index .Auth.EnvVars 0}}/{{index .Auth.EnvVars 1}})"))
+{{- else}}
+ return authErr(fmt.Errorf("client ID and secret required (set --client-id and --client-secret)"))
+{{- end}}
+ }
+
+ tok, err := mintClientCredentialsToken(http.DefaultClient, "{{.Auth.TokenURL}}", clientID, clientSecret)
+ if err != nil {
+ return authErr(err)
+ }
+
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ cfg.AuthHeaderVal = ""
+ expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+ if err := cfg.SaveTokens(clientID, clientSecret, tok.AccessToken, "", expiry); err != nil {
+ return configErr(fmt.Errorf("saving token: %w", err))
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+ expiry.Format(time.RFC3339), tok.ExpiresIn)
+ return nil
+ },
+ }
+
+{{- if .Auth.EnvVars}}
+ cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to ${{index .Auth.EnvVars 0}}")
+{{- if gt (len .Auth.EnvVars) 1}}
+ cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret; defaults to ${{index .Auth.EnvVars 1}}")
+{{- else}}
+ cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+{{- end}}
+{{- else}}
+ cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID")
+ cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+{{- end}}
+
+ return cmd
+}
+
+type tokenResponse struct {
+ AccessToken string ` + "`" + `json:"access_token"` + "`" + `
+ ExpiresIn int ` + "`" + `json:"expires_in"` + "`" + `
+}
+
+// mintClientCredentialsToken POSTs grant_type=client_credentials to the
+// token endpoint and returns the parsed token response.
+func mintClientCredentialsToken(httpClient *http.Client, tokenURL, clientID, clientSecret string) (*tokenResponse, error) {
+ form := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ }
+ req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("building token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("calling token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token endpoint returned HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+ }
+ var tok tokenResponse
+ if err := json.Unmarshal(body, &tok); err != nil {
+ return nil, fmt.Errorf("parsing token response: %w", err)
+ }
+ if tok.AccessToken == "" {
+ return nil, fmt.Errorf("token response missing access_token")
+ }
+ return &tok, nil
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status",
+ Example: " {{.Name}}-pp-cli auth status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ w := cmd.OutOrStdout()
+ header := cfg.AuthHeader()
+ if header == "" {
+ fmt.Fprintln(w, red("Not authenticated"))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Mint a token:")
+{{- if .Auth.EnvVars}}
+ fmt.Fprintln(w, " export {{index .Auth.EnvVars 0}}=\"<client-id>\"")
+{{- if gt (len .Auth.EnvVars) 1}}
+ fmt.Fprintln(w, " export {{index .Auth.EnvVars 1}}=\"<client-secret>\"")
+{{- end}}
+{{- end}}
+ fmt.Fprintf(w, " {{.Name}}-pp-cli auth login\n")
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+
+ fmt.Fprintln(w, green("Authenticated"))
+ fmt.Fprintf(w, " Source: %s\n", cfg.AuthSource)
+ fmt.Fprintf(w, " Config: %s\n", cfg.Path)
+ if !cfg.TokenExpiry.IsZero() {
+ fmt.Fprintf(w, " Expires: %s\n", cfg.TokenExpiry.Format(time.RFC3339))
+ }
+ return nil
+ },
+ }
+}
+
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "set-token <token>",
+ Short: "Save an API token to the config file (override the OAuth flow)",
+ Example: " {{.Name}}-pp-cli auth set-token <bearer-jwt>",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ cfg.AuthHeaderVal = ""
+ if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+ return configErr(fmt.Errorf("saving token: %w", err))
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+ return nil
+ },
+ }
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "logout",
+ Short: "Clear stored credentials",
+ Example: " {{.Name}}-pp-cli auth logout",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ if err := cfg.ClearTokens(); err != nil {
+ return configErr(fmt.Errorf("clearing tokens: %w", err))
+ }
+{{- range .Auth.EnvVars}}
+ if os.Getenv("{{.}}") != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: {{.}} env var is still set.\n")
+ return nil
+ }
+{{- end}}
+ fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+ return nil
+ },
+ }
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 562f2e2f..399540c1 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -19,6 +19,9 @@ import (
"sort"
{{- end}}
"strings"
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+ "sync"
+{{- end}}
"time"
{{- if .UsesBrowserHTTPTransport}}
@@ -36,6 +39,12 @@ type Client struct {
NoCache bool
cacheDir string
limiter *cliutil.AdaptiveLimiter
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+ // ccMu serializes OAuth2 client_credentials token mints so concurrent
+ // API calls inside the 60s pre-expiry window don't all dial the token
+ // endpoint and race on Config field writes / file persistence.
+ ccMu sync.Mutex
+{{- end}}
{{- if eq .Auth.Type "session_handshake"}}
Session *SessionManager
{{- end}}
@@ -738,14 +747,106 @@ func (c *Client) authHeader() (string, error) {
if c.Config == nil {
return "", nil
}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+ // 60s window avoids in-flight requests racing the expiry boundary.
+ // Double-checked lock so only one goroutine mints under contention.
+ if needsClientCredentialsMint(c.Config) {
+ c.ccMu.Lock()
+ if needsClientCredentialsMint(c.Config) {
+ clientID, clientSecret := resolveClientCredentials(c.Config)
+ if clientID != "" && clientSecret != "" {
+ if err := c.mintClientCredentials(clientID, clientSecret); err != nil {
+ c.ccMu.Unlock()
+ return "", err
+ }
+ }
+ }
+ c.ccMu.Unlock()
+ }
+{{- else}}
if c.Config.AccessToken != "" && !c.Config.TokenExpiry.IsZero() && time.Now().After(c.Config.TokenExpiry) && c.Config.RefreshToken != "" {
if err := c.refreshAccessToken(); err != nil {
return "", err
}
}
+{{- end}}
return c.Config.AuthHeader(), nil
}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+
+func needsClientCredentialsMint(cfg *config.Config) bool {
+ if cfg.AccessToken == "" {
+ return true
+ }
+ if cfg.TokenExpiry.IsZero() {
+ return false
+ }
+ return time.Until(cfg.TokenExpiry) < 60*time.Second
+}
+
+// resolveClientCredentials prefers what was passed to `auth login` (cached
+// in config) and falls back to environment variables so first-time users
+// can ship without a separate login step.
+func resolveClientCredentials(cfg *config.Config) (string, string) {
+ id := cfg.ClientID
+ secret := cfg.ClientSecret
+{{- if .Auth.EnvVars}}
+ if id == "" {
+ id = os.Getenv("{{index .Auth.EnvVars 0}}")
+ }
+{{- if gt (len .Auth.EnvVars) 1}}
+ if secret == "" {
+ secret = os.Getenv("{{index .Auth.EnvVars 1}}")
+ }
+{{- end}}
+{{- end}}
+ return id, secret
+}
+
+func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
+ tokenURL := "{{.Auth.TokenURL}}"
+ if tokenURL == "" {
+ return nil
+ }
+ form := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ }
+ req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return fmt.Errorf("building token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("calling token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("OAuth client_credentials mint: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+ }
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.Unmarshal(body, &tokenResp); err != nil {
+ return fmt.Errorf("parsing token response: %w", err)
+ }
+ if tokenResp.AccessToken == "" {
+ return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
+ }
+ expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+ c.Config.AuthHeaderVal = "" // force AuthHeader() to use the new AccessToken path
+ if err := c.Config.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, "", expiry); err != nil {
+ return fmt.Errorf("saving minted token: %w", err)
+ }
+ return nil
+}
+{{- end}}
+
func (c *Client) refreshAccessToken() error {
if c.Config == nil {
return nil
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 908a1b1a..1dfcd814 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -146,6 +146,33 @@ func (c *Config) AuthHeader() string {
return ""
{{- end}}
{{- else if eq .Auth.Type "bearer_token"}}
+{{- 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 c.AccessToken != "" {
+ c.AuthSource = "oauth2"
+ {{- if .Auth.Format}}
+ return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
+ {{- else}}
+ return "Bearer " + c.AccessToken
+ {{- end}}
+ }
+ {{- if gt (len .Auth.EnvVars) 0}}
+ if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
+ c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
+ {{- if .Auth.Format}}
+ return applyAuthFormat("{{.Auth.Format}}", map[string]string{
+ "{{envVarPlaceholder (index .Auth.EnvVars 0)}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+ "{{index .Auth.EnvVars 0}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+ "token": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+ })
+ {{- else}}
+ return "Bearer " + c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+ {{- end}}
+ }
+ {{- end}}
+ return ""
+{{- else}}
// Env-var token wins over file-stored AccessToken (env > config convention).
{{- if gt (len .Auth.EnvVars) 0}}
if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
@@ -170,6 +197,7 @@ func (c *Config) AuthHeader() string {
{{- end}}
}
return ""
+{{- end}}
{{- else if eq .Auth.Type "cookie"}}
// Env-var token wins over file-stored AccessToken (env > config convention).
{{- if gt (len .Auth.EnvVars) 0}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 4e21a06d..f893ae51 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -379,7 +379,20 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
auth.Type = "bearer_token"
auth.Header = "Authorization"
if scheme.Flows != nil {
- if ac := scheme.Flows.AuthorizationCode; ac != nil {
+ // Prefer client_credentials when both flows are declared.
+ // Server-to-server is the more common shape for printed CLIs
+ // (which run in CI/scripts, not interactive browsers); the spec
+ // author can override post-import by setting OAuth2Grant
+ // explicitly. AuthorizationURL stays empty for the cc flow
+ // (no user redirect), which is the correct shape.
+ if cc := scheme.Flows.ClientCredentials; cc != nil && strings.TrimSpace(cc.TokenURL) != "" {
+ auth.OAuth2Grant = spec.OAuth2GrantClientCredentials
+ auth.TokenURL = cc.TokenURL
+ for scope := range cc.Scopes {
+ auth.Scopes = append(auth.Scopes, scope)
+ }
+ sort.Strings(auth.Scopes)
+ } else if ac := scheme.Flows.AuthorizationCode; ac != nil {
auth.AuthorizationURL = ac.AuthorizationURL
auth.TokenURL = ac.TokenURL
for scope := range ac.Scopes {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a8263386..dd382796 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -158,6 +158,137 @@ func TestParseGmailOAuth2(t *testing.T) {
assert.Equal(t, "https://accounts.google.com/o/oauth2/auth", parsed.Auth.AuthorizationURL)
assert.Equal(t, "https://accounts.google.com/o/oauth2/token", parsed.Auth.TokenURL)
assert.NotEmpty(t, parsed.Auth.Scopes)
+ // gmail uses authorization_code flow; OAuth2Grant stays empty so the
+ // EffectiveOAuth2Grant() default of "authorization_code" applies.
+ assert.Equal(t, "", parsed.Auth.OAuth2Grant)
+}
+
+func TestParseOAuth2ClientCredentialsFlow(t *testing.T) {
+ t.Parallel()
+
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Auth0Mgmt
+ version: "1.0"
+servers:
+ - url: https://example.auth0.com
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: https://example.auth0.com/oauth/token
+ scopes:
+ read:users: Read user profiles
+ write:users: Manage users
+paths:
+ /api/v2/users:
+ get:
+ operationId: list users
+ security:
+ - OAuth2: []
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(specBytes)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type, "parser keeps bearer_token shape; grant lives on OAuth2Grant")
+ assert.Equal(t, "client_credentials", parsed.Auth.OAuth2Grant)
+ assert.Equal(t, "https://example.auth0.com/oauth/token", parsed.Auth.TokenURL)
+ assert.Empty(t, parsed.Auth.AuthorizationURL, "client_credentials flow has no user redirect")
+ assert.Equal(t, []string{"read:users", "write:users"}, parsed.Auth.Scopes)
+}
+
+func TestParseOAuth2BothFlowsPrefersClientCredentials(t *testing.T) {
+ t.Parallel()
+
+ // When a single OAuth2 scheme declares both authorizationCode and
+ // clientCredentials flows, the parser prefers clientCredentials —
+ // server-to-server is the more common shape for printed CLIs (which
+ // run in CI/scripts, not interactive browsers). Spec authors override
+ // post-import by setting OAuth2Grant explicitly.
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Hybrid
+ version: "1.0"
+servers:
+ - url: https://example.com
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://example.com/oauth/authorize
+ tokenUrl: https://example.com/oauth/token-ac
+ scopes:
+ user: User access
+ clientCredentials:
+ tokenUrl: https://example.com/oauth/token-cc
+ scopes:
+ admin: Admin access
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - OAuth2: []
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(specBytes)
+ require.NoError(t, err)
+
+ assert.Equal(t, "client_credentials", parsed.Auth.OAuth2Grant)
+ assert.Equal(t, "https://example.com/oauth/token-cc", parsed.Auth.TokenURL,
+ "clientCredentials tokenUrl wins, not authorizationCode's")
+ assert.Empty(t, parsed.Auth.AuthorizationURL, "no user redirect for the cc flow")
+ assert.Equal(t, []string{"admin"}, parsed.Auth.Scopes,
+ "clientCredentials scopes win, not authorizationCode's")
+}
+
+func TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch(t *testing.T) {
+ t.Parallel()
+
+ // Malformed spec: clientCredentials block exists but has no tokenUrl.
+ // Parser should skip the cc branch and fall through to the next flow
+ // (or leave fields empty if no other flows exist), not crash.
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Malformed
+ version: "1.0"
+servers:
+ - url: https://example.com
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: ""
+ scopes: {}
+ authorizationCode:
+ authorizationUrl: https://example.com/auth
+ tokenUrl: https://example.com/token
+ scopes: {}
+paths:
+ /v1/foo:
+ get:
+ operationId: foo
+ security:
+ - OAuth2: []
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := Parse(specBytes)
+ require.NoError(t, err)
+
+ // Falls through to authorizationCode since cc had no tokenUrl.
+ assert.Equal(t, "", parsed.Auth.OAuth2Grant)
+ assert.Equal(t, "https://example.com/token", parsed.Auth.TokenURL)
+ assert.Equal(t, "https://example.com/auth", parsed.Auth.AuthorizationURL)
}
func TestBearerSchemeNameCanSpecializeEnvVar(t *testing.T) {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 62db7e05..1751e164 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -366,6 +366,43 @@ type AuthConfig struct {
TokenParamIn string `yaml:"token_param_in,omitempty" json:"token_param_in,omitempty"` // "query" or "header"; default "query"
InvalidateOnStatus []int `yaml:"invalidate_on_status,omitempty" json:"invalidate_on_status,omitempty"` // HTTP status codes that should invalidate the cached token and re-bootstrap (e.g. [401, 403])
SessionTTLHours int `yaml:"session_ttl_hours,omitempty" json:"session_ttl_hours,omitempty"` // how long to trust a cached session (default 24)
+
+ // OAuth2Grant selects the OAuth2 sub-flow when Type=="oauth2". Defaults
+ // to authorization_code; ignored for non-oauth2 types. Read via
+ // EffectiveOAuth2Grant() so the default lives in one place.
+ OAuth2Grant string `yaml:"oauth2_grant,omitempty" json:"oauth2_grant,omitempty"`
+}
+
+// OAuth2GrantAuthorizationCode is the 3-legged user-OAuth flow (browser
+// redirect, callback server, code exchange at TokenURL).
+const OAuth2GrantAuthorizationCode = "authorization_code"
+
+// OAuth2GrantClientCredentials is the 2-legged server-to-server flow used
+// by M2M APIs (Auth0 Management, Microsoft Graph daemon apps): POST to
+// TokenURL with form-encoded client_id/client_secret, no user redirect.
+const OAuth2GrantClientCredentials = "client_credentials"
+
+// EffectiveOAuth2Grant returns the configured OAuth2 grant type, defaulting
+// to OAuth2GrantAuthorizationCode when unset.
+func (c AuthConfig) EffectiveOAuth2Grant() string {
+ if strings.TrimSpace(c.OAuth2Grant) == "" {
+ return OAuth2GrantAuthorizationCode
+ }
+ return c.OAuth2Grant
+}
+
+// validateOAuth2Grant ensures OAuth2Grant is empty or one of the supported
+// values. Empty is accepted (treated as the default). Cross-checking against
+// 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 {
+ switch c.OAuth2Grant {
+ case "", OAuth2GrantAuthorizationCode, OAuth2GrantClientCredentials:
+ return nil
+ default:
+ return fmt.Errorf("auth.oauth2_grant %q is not recognized (valid: %q, %q)",
+ c.OAuth2Grant, OAuth2GrantAuthorizationCode, OAuth2GrantClientCredentials)
+ }
}
type ConfigSpec struct {
@@ -1085,6 +1122,9 @@ func (s *APISpec) Validate() error {
if err := validateThrottling(s.Throttling); err != nil {
return err
}
+ if err := validateOAuth2Grant(s.Auth); err != nil {
+ return err
+ }
if s.ClientPattern == "proxy-envelope" && s.HasResourceBaseURLOverride() {
return fmt.Errorf("resource base_url overrides are incompatible with client_pattern=proxy-envelope; the proxy POSTs every request to the spec-level BaseURL, so per-resource overrides would be silently ignored")
}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 137fabf9..57d363cf 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -144,6 +144,64 @@ func TestThrottlingValidate(t *testing.T) {
}
}
+func TestOAuth2GrantValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg AuthConfig
+ wantErr string
+ }{
+ {name: "empty is valid (defaults to authorization_code)", cfg: AuthConfig{}},
+ {name: "explicit authorization_code is valid", cfg: AuthConfig{OAuth2Grant: OAuth2GrantAuthorizationCode}},
+ {name: "client_credentials is valid", cfg: AuthConfig{OAuth2Grant: OAuth2GrantClientCredentials}},
+ {
+ // Cross-checking against AuthConfig.Type is intentionally skipped
+ // (the field is meaningless for non-oauth2 types but harmless to
+ // declare); validation should accept this combo.
+ name: "set on a non-oauth2 type is accepted (ignored at template time)",
+ cfg: AuthConfig{Type: "api_key", OAuth2Grant: OAuth2GrantClientCredentials},
+ },
+ {
+ name: "unknown grant is rejected with valid set in error",
+ cfg: AuthConfig{OAuth2Grant: "device_code"},
+ wantErr: `auth.oauth2_grant "device_code" is not recognized`,
+ },
+ {
+ name: "typo (e.g. authorisation) is rejected",
+ cfg: AuthConfig{OAuth2Grant: "authorisation_code"},
+ wantErr: "not recognized",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateOAuth2Grant(tt.cfg)
+ if tt.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestEffectiveOAuth2Grant(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg AuthConfig
+ want string
+ }{
+ {name: "empty defaults to authorization_code", cfg: AuthConfig{}, want: OAuth2GrantAuthorizationCode},
+ {name: "whitespace-only also defaults", cfg: AuthConfig{OAuth2Grant: " "}, want: OAuth2GrantAuthorizationCode},
+ {name: "explicit authorization_code round-trips", cfg: AuthConfig{OAuth2Grant: OAuth2GrantAuthorizationCode}, want: OAuth2GrantAuthorizationCode},
+ {name: "client_credentials round-trips", cfg: AuthConfig{OAuth2Grant: OAuth2GrantClientCredentials}, want: OAuth2GrantClientCredentials},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, tt.cfg.EffectiveOAuth2Grant())
+ })
+ }
+}
+
func TestVersionPassedThrough(t *testing.T) {
base := func(v string) APISpec {
return APISpec{
diff --git a/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt b/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt
new file mode 100644
index 00000000..935fd009
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt
@@ -0,0 +1,26 @@
+# Locks the OAuth2 client_credentials auth shape contract end-to-end.
+# The fixture declares only flows.clientCredentials; every artifact
+# below proves a chokepoint in the auth shape pipeline ran:
+#
+# parser.go → AuthConfig.OAuth2Grant=client_credentials, TokenURL set
+# generator.go → auth_client_credentials.go.tmpl selected (not auth.go.tmpl)
+# config.go.tmpl → AuthHeader emits AccessToken-first (the gated branch)
+# client.go.tmpl → emits needsClientCredentialsMint, mintClientCredentials,
+# ccMu sync.Mutex, double-checked lock in authHeader
+#
+# If a future refactor regresses the parser branch, template selection,
+# or the gating, these files either disappear or contain the wrong
+# auth shape and the harness fails with a content diff.
+
+# auth.go is the auth_client_credentials.go.tmpl output: login command
+# does form-encoded POST to TokenURL with grant_type=client_credentials.
+printing-press-oauth2-cc/internal/cli/auth.go
+
+# client.go carries the mint helpers + the mutex + the gated authHeader
+# path. The presence of needsClientCredentialsMint, ccMu, and the
+# double-checked-lock pattern in this file is the load-bearing contract.
+printing-press-oauth2-cc/internal/client/client.go
+
+# config.go's AuthHeader must emit the AccessToken-first gated branch
+# (under client_credentials the env var is the Client ID, not a bearer).
+printing-press-oauth2-cc/internal/config/config.go
diff --git a/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt b/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt
new file mode 100644
index 00000000..9d4295b0
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/golden-api-oauth2-cc.yaml --output "$CASE_ACTUAL_DIR/printing-press-oauth2-cc" --force --spec-url file://testdata/golden/fixtures/golden-api-oauth2-cc.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
new file mode 100644
index 00000000..acaddf70
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
@@ -0,0 +1,218 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "printing-press-oauth2-pp-cli/internal/cliutil"
+ "printing-press-oauth2-pp-cli/internal/config"
+ "github.com/spf13/cobra"
+)
+
+// OAuth2 client_credentials grant: 2-legged server-to-server flow.
+// POST to TokenURL with form-encoded client_id/client_secret. No user
+// redirect, no refresh_token. The client re-mints when within 60s of expiry.
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage PRINTING_PRESS_OAUTH2_OAUTH2_CC credentials (login, status, logout)",
+ }
+
+ cmd.AddCommand(newAuthLoginCmd(flags))
+ cmd.AddCommand(newAuthStatusCmd(flags))
+ cmd.AddCommand(newAuthSetTokenCmd(flags))
+ cmd.AddCommand(newAuthLogoutCmd(flags))
+
+ return cmd
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+ var (
+ clientID string
+ clientSecret string
+ )
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Mint an OAuth2 bearer token via the client_credentials grant",
+ Long: strings.TrimSpace(` + "`" + `
+Mint an OAuth2 bearer token via POST https://api.cc.example/oauth/token with
+grant_type=client_credentials and cache it on disk. Subsequent commands
+auto-refresh the token before expiry.
+
+Credentials default to PRINTING_PRESS_OAUTH2_OAUTH2_CC (Client ID) environment variables.
+` + "`" + `),
+ Example: strings.Trim(` + "`" + `
+ # Use env vars
+ printing-press-oauth2-pp-cli auth login
+
+ # Explicit credentials
+ printing-press-oauth2-pp-cli auth login --client-id <id> --client-secret <secret>
+` + "`" + `, "\n"),
+ Annotations: map[string]string{"mcp:hidden": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliutil.IsVerifyEnv() {
+ fmt.Fprintln(cmd.OutOrStdout(), "would mint token via OAuth2 client_credentials")
+ return nil
+ }
+
+ if clientID == "" {
+ clientID = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+ }
+ if clientSecret == "" {
+ }
+ if clientID == "" || clientSecret == "" {
+ return authErr(fmt.Errorf("client ID and secret required (set --client-id and --client-secret)"))
+ }
+
+ tok, err := mintClientCredentialsToken(http.DefaultClient, "https://api.cc.example/oauth/token", clientID, clientSecret)
+ if err != nil {
+ return authErr(err)
+ }
+
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ cfg.AuthHeaderVal = ""
+ expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+ if err := cfg.SaveTokens(clientID, clientSecret, tok.AccessToken, "", expiry); err != nil {
+ return configErr(fmt.Errorf("saving token: %w", err))
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+ expiry.Format(time.RFC3339), tok.ExpiresIn)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to $PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+ cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+
+ return cmd
+}
+
+type tokenResponse struct {
+ AccessToken string ` + "`" + `json:"access_token"` + "`" + `
+ ExpiresIn int ` + "`" + `json:"expires_in"` + "`" + `
+}
+
+// mintClientCredentialsToken POSTs grant_type=client_credentials to the
+// token endpoint and returns the parsed token response.
+func mintClientCredentialsToken(httpClient *http.Client, tokenURL, clientID, clientSecret string) (*tokenResponse, error) {
+ form := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ }
+ req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("building token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("calling token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token endpoint returned HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+ }
+ var tok tokenResponse
+ if err := json.Unmarshal(body, &tok); err != nil {
+ return nil, fmt.Errorf("parsing token response: %w", err)
+ }
+ if tok.AccessToken == "" {
+ return nil, fmt.Errorf("token response missing access_token")
+ }
+ return &tok, nil
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status",
+ Example: " printing-press-oauth2-pp-cli auth status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ w := cmd.OutOrStdout()
+ header := cfg.AuthHeader()
+ if header == "" {
+ fmt.Fprintln(w, red("Not authenticated"))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Mint a token:")
+ fmt.Fprintln(w, " export PRINTING_PRESS_OAUTH2_OAUTH2_CC=\"<client-id>\"")
+ fmt.Fprintf(w, " printing-press-oauth2-pp-cli auth login\n")
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+
+ fmt.Fprintln(w, green("Authenticated"))
+ fmt.Fprintf(w, " Source: %s\n", cfg.AuthSource)
+ fmt.Fprintf(w, " Config: %s\n", cfg.Path)
+ if !cfg.TokenExpiry.IsZero() {
+ fmt.Fprintf(w, " Expires: %s\n", cfg.TokenExpiry.Format(time.RFC3339))
+ }
+ return nil
+ },
+ }
+}
+
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "set-token <token>",
+ Short: "Save an API token to the config file (override the OAuth flow)",
+ Example: " printing-press-oauth2-pp-cli auth set-token <bearer-jwt>",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ cfg.AuthHeaderVal = ""
+ if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+ return configErr(fmt.Errorf("saving token: %w", err))
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+ return nil
+ },
+ }
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "logout",
+ Short: "Clear stored credentials",
+ Example: " printing-press-oauth2-pp-cli auth logout",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ if err := cfg.ClearTokens(); err != nil {
+ return configErr(fmt.Errorf("clearing tokens: %w", err))
+ }
+ if os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC") != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: PRINTING_PRESS_OAUTH2_OAUTH2_CC env var is still set.\n")
+ return nil
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+ return nil
+ },
+ }
+}
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
new file mode 100644
index 00000000..ad221050
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -0,0 +1,515 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+ "printing-press-oauth2-pp-cli/internal/cliutil"
+ "printing-press-oauth2-pp-cli/internal/config"
+)
+
+type Client struct {
+ BaseURL string
+ Config *config.Config
+ HTTPClient *http.Client
+ DryRun bool
+ NoCache bool
+ cacheDir string
+ limiter *cliutil.AdaptiveLimiter
+ // ccMu serializes OAuth2 client_credentials token mints so concurrent
+ // API calls inside the 60s pre-expiry window don't all dial the token
+ // endpoint and race on Config field writes / file persistence.
+ ccMu sync.Mutex
+}
+
+
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+ Method string
+ Path string
+ StatusCode int
+ Body string
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
+}
+
+func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
+ return &http.Client{Timeout: timeout, Jar: jar}
+}
+
+func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
+ homeDir, _ := os.UserHomeDir()
+ cacheDir := filepath.Join(homeDir, ".cache", "printing-press-oauth2-pp-cli")
+ httpClient := newHTTPClient(timeout, nil)
+ return &Client{
+ BaseURL: strings.TrimRight(cfg.BaseURL, "/"),
+ Config: cfg,
+ HTTPClient: httpClient,
+ cacheDir: cacheDir,
+ limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+ }
+}
+
+// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
+func (c *Client) RateLimit() float64 {
+ return c.limiter.Rate()
+}
+
+func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeaders(path, params, nil)
+}
+
+func (c *Client) GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ // Check cache for GET requests
+ if !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ if cached, ok := c.readCache(path, params); ok {
+ return cached, nil
+ }
+ }
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
+func (c *Client) ProbeGet(path string) (int, error) {
+ _, status, err := c.do("GET", path, nil, nil, nil)
+ return status, err
+}
+
+func (c *Client) cacheKey(path string, params map[string]string) string {
+ key := path
+ for k, v := range params {
+ key += k + "=" + v
+ }
+ h := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(h[:8])
+}
+
+func (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ info, err := os.Stat(cacheFile)
+ if err != nil || time.Since(info.ModTime()) > 5*time.Minute {
+ return nil, false
+ }
+ data, err := os.ReadFile(cacheFile)
+ if err != nil {
+ return nil, false
+ }
+ return json.RawMessage(data), true
+}
+
+func (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {
+ os.MkdirAll(c.cacheDir, 0o755)
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ os.WriteFile(cacheFile, []byte(data), 0o644)
+}
+
+func (c *Client) Post(path string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, body, nil)
+}
+
+func (c *Client) PostWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, body, headers)
+}
+
+func (c *Client) Delete(path string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, nil, nil, nil)
+}
+
+func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, nil, nil, headers)
+}
+
+func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, body, nil)
+}
+
+func (c *Client) PutWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, body, headers)
+}
+
+func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, body, nil)
+}
+
+func (c *Client) PatchWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, body, headers)
+}
+
+// do executes an HTTP request. headerOverrides, when non-nil, override global
+// RequiredHeaders for this specific request (used for per-endpoint API versioning).
+func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
+ targetURL := c.BaseURL + path
+
+ var bodyBytes []byte
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshaling body: %w", err)
+ }
+ bodyBytes = b
+ }
+
+ // Resolve auth material before the dry-run branch so --dry-run can preview
+ // exactly what would be sent. Uses only cached credentials; a token that
+ // requires a network refresh will be re-fetched on the live request path,
+ // not during dry-run.
+ authHeader, err := c.authHeader()
+ if err != nil {
+ return nil, 0, err
+ }
+
+ // Build the request for dry-run display or actual execution
+ if c.DryRun {
+ return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)
+ }
+
+ const maxRetries = 3
+ var lastErr error
+
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ // Proactive rate limiting — wait before sending
+ c.limiter.Wait()
+ var bodyReader io.Reader
+ if bodyBytes != nil {
+ bodyReader = strings.NewReader(string(bodyBytes))
+ }
+
+ req, err := http.NewRequest(method, targetURL, bodyReader)
+ if err != nil {
+ return nil, 0, fmt.Errorf("creating request: %w", err)
+ }
+ if bodyBytes != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ if params != nil {
+ q := req.URL.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ if authHeader != "" {
+ req.Header.Set("Authorization", authHeader)
+ }
+ // Per-endpoint header overrides (e.g., different API version per resource)
+ for k, v := range headerOverrides {
+ req.Header.Set(k, v)
+ }
+ req.Header.Set("User-Agent", "printing-press-oauth2-pp-cli/1.0.0")
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ lastErr = fmt.Errorf("%s %s: %w", method, path, err)
+ continue
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return nil, 0, fmt.Errorf("reading response: %w", err)
+ }
+ respBody = sanitizeJSONResponse(respBody)
+
+ // Success
+ if resp.StatusCode < 400 {
+ c.limiter.OnSuccess()
+ return json.RawMessage(respBody), resp.StatusCode, nil
+ }
+
+ apiErr := &APIError{
+ Method: method,
+ Path: path,
+ StatusCode: resp.StatusCode,
+ Body: truncateBody(respBody),
+ }
+
+ // Rate limited - adjust adaptive limiter and retry
+ if resp.StatusCode == 429 && attempt < maxRetries {
+ c.limiter.OnRateLimit()
+ wait := cliutil.RetryAfter(resp)
+ fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Server error - retry with backoff
+ if resp.StatusCode >= 500 && attempt < maxRetries {
+ wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Client error or retries exhausted - return the error
+ return nil, resp.StatusCode, apiErr
+ }
+
+ return nil, 0, lastErr
+}
+
+// dryRun prints the outgoing request exactly as the live path would send it,
+// using the auth material already resolved in `do()`. Never triggers a network
+// call — the caller is responsible for passing cached auth material only.
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
+ fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
+ queryPrinted := false
+ if params != nil {
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if params[k] != "" {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, k, params[k])
+ queryPrinted = true
+ }
+ }
+ _ = queryPrinted
+ if body != nil {
+ var pretty json.RawMessage
+ if json.Unmarshal(body, &pretty) == nil {
+ enc := json.NewEncoder(os.Stderr)
+ enc.SetIndent(" ", " ")
+ fmt.Fprintf(os.Stderr, " Body:\n")
+ enc.Encode(pretty)
+ }
+ }
+ if authHeader != "" {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", "Authorization", maskToken(authHeader))
+ }
+ fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+ return json.RawMessage(`{"dry_run": true}`), 0, nil
+}
+
+func (c *Client) ConfiguredTimeout() time.Duration {
+ if c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {
+ return c.HTTPClient.Timeout
+ }
+ return 30 * time.Second
+}
+
+func (c *Client) authHeader() (string, error) {
+ if c.Config == nil {
+ return "", nil
+ }
+ // 60s window avoids in-flight requests racing the expiry boundary.
+ // Double-checked lock so only one goroutine mints under contention.
+ if needsClientCredentialsMint(c.Config) {
+ c.ccMu.Lock()
+ if needsClientCredentialsMint(c.Config) {
+ clientID, clientSecret := resolveClientCredentials(c.Config)
+ if clientID != "" && clientSecret != "" {
+ if err := c.mintClientCredentials(clientID, clientSecret); err != nil {
+ c.ccMu.Unlock()
+ return "", err
+ }
+ }
+ }
+ c.ccMu.Unlock()
+ }
+ return c.Config.AuthHeader(), nil
+}
+
+func needsClientCredentialsMint(cfg *config.Config) bool {
+ if cfg.AccessToken == "" {
+ return true
+ }
+ if cfg.TokenExpiry.IsZero() {
+ return false
+ }
+ return time.Until(cfg.TokenExpiry) < 60*time.Second
+}
+
+// resolveClientCredentials prefers what was passed to `auth login` (cached
+// in config) and falls back to environment variables so first-time users
+// can ship without a separate login step.
+func resolveClientCredentials(cfg *config.Config) (string, string) {
+ id := cfg.ClientID
+ secret := cfg.ClientSecret
+ if id == "" {
+ id = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+ }
+ return id, secret
+}
+
+func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
+ tokenURL := "https://api.cc.example/oauth/token"
+ if tokenURL == "" {
+ return nil
+ }
+ form := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ }
+ req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return fmt.Errorf("building token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("calling token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("OAuth client_credentials mint: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+ }
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.Unmarshal(body, &tokenResp); err != nil {
+ return fmt.Errorf("parsing token response: %w", err)
+ }
+ if tokenResp.AccessToken == "" {
+ return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
+ }
+ expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+ c.Config.AuthHeaderVal = "" // force AuthHeader() to use the new AccessToken path
+ if err := c.Config.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, "", expiry); err != nil {
+ return fmt.Errorf("saving minted token: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) refreshAccessToken() error {
+ if c.Config == nil {
+ return nil
+ }
+ if c.Config.RefreshToken == "" {
+ return nil
+ }
+
+ tokenURL := "https://api.cc.example/oauth/token"
+ if tokenURL == "" {
+ return nil
+ }
+
+ params := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {c.Config.RefreshToken},
+ "client_id": {c.Config.ClientID},
+ }
+ if c.Config.ClientSecret != "" {
+ params.Set("client_secret", c.Config.ClientSecret)
+ }
+
+ resp, err := c.HTTPClient.PostForm(tokenURL, params)
+ if err != nil {
+ return fmt.Errorf("refreshing access token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("refreshing access token: HTTP %d: %s", resp.StatusCode, truncateBody(body))
+ }
+
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+ return fmt.Errorf("parsing refresh response: %w", err)
+ }
+ if tokenResp.AccessToken == "" {
+ return fmt.Errorf("refreshing access token: no access token in response")
+ }
+
+ refreshToken := c.Config.RefreshToken
+ if tokenResp.RefreshToken != "" {
+ refreshToken = tokenResp.RefreshToken
+ }
+
+ expiry := time.Time{}
+ if tokenResp.ExpiresIn > 0 {
+ expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+ }
+
+ if err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {
+ return fmt.Errorf("saving refreshed token: %w", err)
+ }
+
+ return nil
+}
+
+// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
+// response bodies so that downstream JSON parsing succeeds. For clean JSON
+// responses these checks are no-ops.
+func sanitizeJSONResponse(body []byte) []byte {
+ // UTF-8 BOM
+ body = bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))
+
+ // JSONP/XSSI prefixes, ordered longest-first where prefixes overlap
+ prefixes := [][]byte{
+ []byte(")]}'\n"),
+ []byte(")]}'"),
+ []byte("{}&&"),
+ []byte("for(;;);"),
+ []byte("while(1);"),
+ }
+ for _, p := range prefixes {
+ if bytes.HasPrefix(body, p) {
+ body = bytes.TrimPrefix(body, p)
+ body = bytes.TrimLeft(body, " \t\r\n")
+ break
+ }
+ }
+ return body
+}
+
+
+// maskToken redacts all but the last 4 characters of a token for safe display.
+func maskToken(token string) string {
+ if token == "" {
+ return ""
+ }
+ if len(token) <= 4 {
+ return "****"
+ }
+ return "****" + token[len(token)-4:]
+}
+
+func truncateBody(b []byte) string {
+ s := string(b)
+ if len(s) > 200 {
+ return s[:200] + "..."
+ }
+ return s
+}
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
new file mode 100644
index 00000000..d1bb9e62
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
@@ -0,0 +1,125 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+ BaseURL string `toml:"base_url"`
+ AuthHeaderVal string `toml:"auth_header"`
+ AuthSource string `toml:"-"`
+ AccessToken string `toml:"access_token"`
+ RefreshToken string `toml:"refresh_token"`
+ TokenExpiry time.Time `toml:"token_expiry"`
+ ClientID string `toml:"client_id"`
+ ClientSecret string `toml:"client_secret"`
+ Path string `toml:"-"`
+ PrintingPressOauth2Oauth2Cc string `toml:"press_oauth2_oauth2_cc"`
+}
+
+func Load(configPath string) (*Config, error) {
+ cfg := &Config{
+ BaseURL: "https://api.cc.example/v1",
+ }
+
+ // Resolve config path
+ path := configPath
+ if path == "" {
+ path = os.Getenv("PRINTING_PRESS_OAUTH2_CONFIG")
+ }
+ if path == "" {
+ home, _ := os.UserHomeDir()
+ path = filepath.Join(home, ".config", "printing-press-oauth2-pp-cli", "config.toml")
+ }
+ cfg.Path = path
+
+ // Try to load config file
+ data, err := os.ReadFile(path)
+ if err == nil {
+ if err := toml.Unmarshal(data, cfg); err != nil {
+ return nil, fmt.Errorf("parsing config %s: %w", path, err)
+ }
+ }
+
+ // Env var overrides
+ if v := os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC"); v != "" {
+ cfg.PrintingPressOauth2Oauth2Cc = v
+ cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+ }
+
+ // Base URL override (used by printing-press verify to point at mock/test servers)
+ if v := os.Getenv("PRINTING_PRESS_OAUTH2_BASE_URL"); v != "" {
+ cfg.BaseURL = v
+ }
+ return cfg, nil
+}
+
+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.
+ if c.AccessToken != "" {
+ c.AuthSource = "oauth2"
+ return "Bearer " + c.AccessToken
+ }
+ if c.PrintingPressOauth2Oauth2Cc != "" {
+ c.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+ return "Bearer " + c.PrintingPressOauth2Oauth2Cc
+ }
+ return ""
+}
+
+func applyAuthFormat(format string, replacements map[string]string) string {
+ if format == "" {
+ return ""
+ }
+ for key, value := range replacements {
+ format = strings.ReplaceAll(format, "{"+key+"}", value)
+ }
+ if strings.Contains(format, "{") {
+ return ""
+ }
+ return format
+}
+
+func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {
+ c.ClientID = clientID
+ c.ClientSecret = clientSecret
+ c.AccessToken = accessToken
+ c.RefreshToken = refreshToken
+ c.TokenExpiry = expiry
+ return c.save()
+}
+
+func (c *Config) ClearTokens() error {
+ c.AccessToken = ""
+ c.RefreshToken = ""
+ c.TokenExpiry = time.Time{}
+ return c.save()
+}
+
+func (c *Config) save() error {
+ dir := filepath.Dir(c.Path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("creating config dir: %w", err)
+ }
+ data, err := toml.Marshal(c)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ return os.WriteFile(c.Path, data, 0o600)
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
new file mode 100644
index 00000000..7bcea578
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
@@ -0,0 +1 @@
+Generated printing-press-oauth2 at <ARTIFACT_DIR>/generate-golden-api-oauth2-cc/printing-press-oauth2-cc
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/stdout.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/golden-api-oauth2-cc.yaml b/testdata/golden/fixtures/golden-api-oauth2-cc.yaml
new file mode 100644
index 00000000..eadd1e55
--- /dev/null
+++ b/testdata/golden/fixtures/golden-api-oauth2-cc.yaml
@@ -0,0 +1,47 @@
+openapi: "3.0.3"
+info:
+ title: Printing Press OAuth2 CC API
+ version: "1.0.0"
+ description: Purpose-built fixture for the OAuth2 client_credentials auth shape.
+servers:
+ - url: https://api.cc.example/v1
+security:
+ - OAuth2CC: []
+components:
+ securitySchemes:
+ # clientCredentials flow declared. The OpenAPI parser detects this
+ # branch and sets AuthConfig.OAuth2Grant=client_credentials, which
+ # gates: (1) generator template selection (auth_client_credentials
+ # vs auth.go.tmpl), (2) AuthHeader precedence (AccessToken-first vs
+ # env-first), (3) client.go mint helpers + ccMu mutex emission.
+ OAuth2CC:
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: https://api.cc.example/oauth/token
+ scopes:
+ read: Read access
+ write: Write access
+ schemas:
+ Item:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+paths:
+ /items:
+ get:
+ tags: [items]
+ operationId: listItems
+ summary: List items
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/Item"
← e761d04d fix(cli): FedEx retro batch — 5 small fixes (WU-2, WU-3, WU-
·
back to Cli Printing Press
·
chore(main): release 3.8.0 (#527) 7364324a →