[object Object]

← back to Cli Printing Press

fix(cli): allow runtime override of OAuth2 OIDC URLs (#970)

a9e9d006af266b5b147cd7bd27bd00733be8829d · 2026-05-10 17:42:08 -0700 · Trevin Chow

* fix(cli): allow runtime override of OAuth2 OIDC URLs

The generator baked AuthorizationURL and TokenURL straight into auth.go
and client.go as string literals, so a printed CLI pointed at a non-default
deployment had no way to override the OIDC endpoints without regenerating.
For etherpad-style specs that declared http://localhost:9001 as the default,
auth login and token refresh would dial localhost:9001 even when --base-url
was set to a remote host.

Emit AuthorizationURL and TokenURL as Config fields (with omitempty) when
the spec declares them, and read PRINTING_PRESS_*_AUTHORIZATION_URL /
PRINTING_PRESS_*_TOKEN_URL env-var overrides at Load() time. Templates
prefer cfg.* and fall back to the spec literal so existing behavior is
preserved when no override is set.

Also guard the auth-login expiry calculation against expires_in: 0 in both
auth.go and auth_client_credentials.go (matching the pattern client.go's
refreshAccessToken already uses). A non-conformant server returning 0 used
to make every subsequent call think the token had expired.

Closes #952

* fix(cli): guard mintClientCredentials expiry and clean refresh template

Greptile review on #970 caught two follow-ups:

1. mintClientCredentials still computed expiry unconditionally from
   tokenResp.ExpiresIn, so a server returning expires_in: 0 made every
   subsequent request re-trigger the mint in a loop. This is the
   runtime auto-refresh path called from authHeader(), so it matters
   more than the auth-login paths the first commit covered.

2. refreshAccessToken initialised tokenURL to "" and then
   unconditionally overwrote it with c.Config.TokenURL, which
   ineffassign would flag in downstream CLIs. Restructured to declare
   tokenURL inside the TokenURL-enabled branch and have the no-OAuth2
   branch return nil early — eliminates the unused net/url import for
   bearer-token specs in the same change.

Strengthened auth_oauth_url_override_test.go to pin the
mintClientCredentials ExpiresIn>0 guard so the loop bug can't regress.

Files touched

Diff

commit a9e9d006af266b5b147cd7bd27bd00733be8829d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 17:42:08 2026 -0700

    fix(cli): allow runtime override of OAuth2 OIDC URLs (#970)
    
    * fix(cli): allow runtime override of OAuth2 OIDC URLs
    
    The generator baked AuthorizationURL and TokenURL straight into auth.go
    and client.go as string literals, so a printed CLI pointed at a non-default
    deployment had no way to override the OIDC endpoints without regenerating.
    For etherpad-style specs that declared http://localhost:9001 as the default,
    auth login and token refresh would dial localhost:9001 even when --base-url
    was set to a remote host.
    
    Emit AuthorizationURL and TokenURL as Config fields (with omitempty) when
    the spec declares them, and read PRINTING_PRESS_*_AUTHORIZATION_URL /
    PRINTING_PRESS_*_TOKEN_URL env-var overrides at Load() time. Templates
    prefer cfg.* and fall back to the spec literal so existing behavior is
    preserved when no override is set.
    
    Also guard the auth-login expiry calculation against expires_in: 0 in both
    auth.go and auth_client_credentials.go (matching the pattern client.go's
    refreshAccessToken already uses). A non-conformant server returning 0 used
    to make every subsequent call think the token had expired.
    
    Closes #952
    
    * fix(cli): guard mintClientCredentials expiry and clean refresh template
    
    Greptile review on #970 caught two follow-ups:
    
    1. mintClientCredentials still computed expiry unconditionally from
       tokenResp.ExpiresIn, so a server returning expires_in: 0 made every
       subsequent request re-trigger the mint in a loop. This is the
       runtime auto-refresh path called from authHeader(), so it matters
       more than the auth-login paths the first commit covered.
    
    2. refreshAccessToken initialised tokenURL to "" and then
       unconditionally overwrote it with c.Config.TokenURL, which
       ineffassign would flag in downstream CLIs. Restructured to declare
       tokenURL inside the TokenURL-enabled branch and have the no-OAuth2
       branch return nil early — eliminates the unused net/url import for
       bearer-token specs in the same change.
    
    Strengthened auth_oauth_url_override_test.go to pin the
    mintClientCredentials ExpiresIn>0 guard so the loop bug can't regress.
---
 internal/generator/auth_oauth_url_override_test.go | 149 +++++++++++++++++++++
 internal/generator/templates/auth.go.tmpl          |  30 ++++-
 .../templates/auth_client_credentials.go.tmpl      |  29 +++-
 internal/generator/templates/client.go.tmpl        |  27 +++-
 internal/generator/templates/config.go.tmpl        |  22 +++
 .../printing-press-oauth2-cc/internal/cli/auth.go  |  29 +++-
 .../internal/client/client.go                      |  21 ++-
 .../internal/config/config.go                      |   6 +
 .../internal/client/client.go                      |  55 --------
 .../tier-routing-golden/internal/client/client.go  |  55 --------
 10 files changed, 288 insertions(+), 135 deletions(-)

diff --git a/internal/generator/auth_oauth_url_override_test.go b/internal/generator/auth_oauth_url_override_test.go
new file mode 100644
index 00000000..887b8cc7
--- /dev/null
+++ b/internal/generator/auth_oauth_url_override_test.go
@@ -0,0 +1,149 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// Pins the runtime override path for the OAuth2 OIDC URLs reported in #952.
+// The generator-baked URL was treated as the only source of truth, leaving
+// users no way to point a printed CLI at a non-default deployment without
+// regenerating. The fix: emit AuthorizationURL/TokenURL as Config fields
+// (with env-var overrides) and prefer cfg.* over the spec literal.
+func TestOAuth2URLs_RuntimeOverrideEmittedForAuthCodeGrant(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("oauth-url-override")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:             "oauth2",
+		Header:           "Authorization",
+		Format:           "Bearer {token}",
+		EnvVars:          []string{"OAUTH_URL_OVERRIDE_TOKEN"},
+		AuthorizationURL: "http://localhost:9001/oidc/auth",
+		TokenURL:         "http://localhost:9001/oidc/token",
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "oauth-url-override-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	cfg := string(cfgSrc)
+
+	require.Contains(t, cfg, "AuthorizationURL string", "Config must expose AuthorizationURL override field")
+	require.Contains(t, cfg, "TokenURL       string", "Config must expose TokenURL override field")
+	require.Contains(t, cfg, `os.Getenv("OAUTH_URL_OVERRIDE_AUTHORIZATION_URL")`,
+		"Load() must read AuthorizationURL env override")
+	require.Contains(t, cfg, `os.Getenv("OAUTH_URL_OVERRIDE_TOKEN_URL")`,
+		"Load() must read TokenURL env override")
+
+	authSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	auth := string(authSrc)
+
+	require.Contains(t, auth, "authURL = cfg.AuthorizationURL",
+		"login flow must read cfg.AuthorizationURL before falling back to spec default")
+	require.Contains(t, auth, "tokenURL = cfg.TokenURL",
+		"login flow must read cfg.TokenURL before falling back to spec default")
+
+	// The expiry calc must guard against ExpiresIn==0 so a non-conformant
+	// server doesn't make every subsequent call think the token has expired.
+	require.Contains(t, auth, "if tokenResp.ExpiresIn > 0 {",
+		"login flow must guard expiry calc against ExpiresIn==0")
+	expiryIdx := strings.Index(auth, "if tokenResp.ExpiresIn > 0 {")
+	saveIdx := strings.Index(auth, "cfg.SaveTokens(clientID, clientSecret, tokenResp.AccessToken")
+	assert.Less(t, expiryIdx, saveIdx, "guard must precede SaveTokens call")
+
+	clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	client := string(clientSrc)
+	require.Contains(t, client, "tokenURL := c.Config.TokenURL",
+		"refreshAccessToken must read c.Config.TokenURL before falling back to spec default")
+}
+
+func TestOAuth2URLs_RuntimeOverrideEmittedForClientCredentialsGrant(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("cc-url-override")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:        "bearer_token",
+		Header:      "Authorization",
+		EnvVars:     []string{"CC_URL_OVERRIDE_CLIENT_ID", "CC_URL_OVERRIDE_CLIENT_SECRET"},
+		OAuth2Grant: spec.OAuth2GrantClientCredentials,
+		TokenURL:    "http://localhost:9001/oidc/token",
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "cc-url-override-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	cfg := string(cfgSrc)
+	require.Contains(t, cfg, "TokenURL       string")
+	require.NotContains(t, cfg, "AuthorizationURL string",
+		"client_credentials grant has no authorization URL, so the field must not be emitted")
+	require.Contains(t, cfg, `os.Getenv("CC_URL_OVERRIDE_TOKEN_URL")`)
+
+	authSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	auth := string(authSrc)
+	require.Contains(t, auth, "tokenURL := cfg.TokenURL",
+		"client_credentials login must read cfg.TokenURL before mint")
+	require.Contains(t, auth, "if tok.ExpiresIn > 0 {",
+		"client_credentials login must guard expiry calc against ExpiresIn==0")
+
+	clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	client := string(clientSrc)
+	require.Contains(t, client, "tokenURL = c.Config.TokenURL",
+		"mintClientCredentials must read c.Config.TokenURL via the c.Config != nil guard")
+
+	// Pin the auto-refresh expiry guard: without it a server returning
+	// expires_in: 0 makes every request re-trigger mintClientCredentials in
+	// a loop. mintClientCredentials is the runtime auto-refresh path called
+	// from authHeader(), so this guard matters more than the login-path one.
+	mintIdx := strings.Index(client, "func (c *Client) mintClientCredentials")
+	require.NotEqual(t, -1, mintIdx, "mintClientCredentials must be emitted")
+	mintBody := client[mintIdx:]
+	if next := strings.Index(mintBody[1:], "\nfunc "); next != -1 {
+		mintBody = mintBody[:next+1]
+	}
+	require.Contains(t, mintBody, "if tokenResp.ExpiresIn > 0 {",
+		"mintClientCredentials must guard expiry calc against ExpiresIn==0")
+}
+
+// Pins that bearer_token / api_key specs (no OAuth2 URLs) still build cleanly:
+// the cfg.TokenURL access in client.go must be gated so the field isn't
+// referenced when it's not emitted.
+func TestOAuth2URLs_NoFieldsForBearerTokenSpec(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("bearer-no-oauth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"BEARER_NO_OAUTH_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "bearer-no-oauth-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	cfg := string(cfgSrc)
+	require.NotContains(t, cfg, "AuthorizationURL string",
+		"plain bearer_token has no OAuth2 URLs; field must not be emitted")
+	require.NotContains(t, cfg, "TokenURL       string",
+		"plain bearer_token has no OAuth2 URLs; field must not be emitted")
+
+	clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	require.NotContains(t, string(clientSrc), "c.Config.TokenURL",
+		"client.go must not reference c.Config.TokenURL when the field isn't emitted")
+}
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index e9d9e09d..71bb8a30 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -67,7 +67,13 @@ func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 
 			redirectURI := fmt.Sprintf("http://localhost:%d/callback", listener.Addr().(*net.TCPAddr).Port)
 
-			authURL := "{{.Auth.AuthorizationURL}}"
+			authURL := ""
+{{- if .Auth.AuthorizationURL}}
+			authURL = cfg.AuthorizationURL
+			if authURL == "" {
+				authURL = "{{.Auth.AuthorizationURL}}"
+			}
+{{- end}}
 			params := url.Values{
 				"client_id":     {clientID},
 				"redirect_uri":  {redirectURI},
@@ -126,7 +132,13 @@ func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 
 			server.Shutdown(context.Background())
 
-			tokenURL := "{{.Auth.TokenURL}}"
+			tokenURL := ""
+{{- if .Auth.TokenURL}}
+			tokenURL = cfg.TokenURL
+			if tokenURL == "" {
+				tokenURL = "{{.Auth.TokenURL}}"
+			}
+{{- end}}
 			tokenParams := url.Values{
 				"grant_type":   {"authorization_code"},
 				"code":         {code},
@@ -164,12 +176,22 @@ func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 				return fmt.Errorf("no access token in response")
 			}
 
-			expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+			// Guard against non-conformant servers that return expires_in: 0.
+			// A zero-duration expiry resolves to time.Now(), which authHeader()
+			// then treats as expired and triggers a refresh on every call.
+			expiry := time.Time{}
+			if tokenResp.ExpiresIn > 0 {
+				expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+			}
 			if err := cfg.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, tokenResp.RefreshToken, expiry); err != nil {
 				return fmt.Errorf("saving tokens: %w", err)
 			}
 
-			fmt.Fprintf(os.Stderr, "%s Authentication successful! Token expires at %s\n", green("OK"), expiry.Format(time.RFC3339))
+			if expiry.IsZero() {
+				fmt.Fprintf(os.Stderr, "%s Authentication successful! Token expiry not provided.\n", green("OK"))
+			} else {
+				fmt.Fprintf(os.Stderr, "%s Authentication successful! Token expires at %s\n", green("OK"), expiry.Format(time.RFC3339))
+			}
 			return nil
 		},
 	}
diff --git a/internal/generator/templates/auth_client_credentials.go.tmpl b/internal/generator/templates/auth_client_credentials.go.tmpl
index 65335dc5..1cfd7d01 100644
--- a/internal/generator/templates/auth_client_credentials.go.tmpl
+++ b/internal/generator/templates/auth_client_credentials.go.tmpl
@@ -84,23 +84,38 @@ Credentials default to {{if .Auth.EnvVars}}{{index .Auth.EnvVars 0}} (Client ID)
 {{- end}}
 			}
 
-			tok, err := mintClientCredentialsToken(http.DefaultClient, "{{.Auth.TokenURL}}", clientID, clientSecret)
+			cfg, err := config.Load(flags.configPath)
 			if err != nil {
-				return authErr(err)
+				return configErr(err)
 			}
 
-			cfg, err := config.Load(flags.configPath)
+			tokenURL := cfg.TokenURL
+			if tokenURL == "" {
+				tokenURL = "{{.Auth.TokenURL}}"
+			}
+			tok, err := mintClientCredentialsToken(http.DefaultClient, tokenURL, clientID, clientSecret)
 			if err != nil {
-				return configErr(err)
+				return authErr(err)
 			}
+
 			cfg.AuthHeaderVal = ""
-			expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+			// Guard against non-conformant servers that return expires_in: 0.
+			// A zero-duration expiry resolves to time.Now(), which the client's
+			// auto-refresh logic then treats as expired immediately.
+			expiry := time.Time{}
+			if tok.ExpiresIn > 0 {
+				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)
+			if expiry.IsZero() {
+				fmt.Fprintln(cmd.OutOrStdout(), "Logged in. Token expiry not provided.")
+			} else {
+				fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+					expiry.Format(time.RFC3339), tok.ExpiresIn)
+			}
 			return nil
 		},
 	}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 818d1685..c7e16826 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -18,7 +18,7 @@ import (
 	"mime/multipart"
 {{- end}}
 	"net/http"
-{{- if or .HasAuthCommand .HasFormRequest}}
+{{- if or (and .HasAuthCommand .Auth.TokenURL) .HasFormRequest}}
 	"net/url"
 {{- end}}
 	"os"
@@ -1243,7 +1243,15 @@ func resolveClientCredentials(cfg *config.Config) (string, string) {
 }
 
 func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
-	tokenURL := "{{.Auth.TokenURL}}"
+	tokenURL := ""
+{{- if .Auth.TokenURL}}
+	if c.Config != nil {
+		tokenURL = c.Config.TokenURL
+	}
+	if tokenURL == "" {
+		tokenURL = "{{.Auth.TokenURL}}"
+	}
+{{- end}}
 	if tokenURL == "" {
 		return nil
 	}
@@ -1276,7 +1284,13 @@ func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
 	if tokenResp.AccessToken == "" {
 		return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
 	}
-	expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	// Guard against non-conformant servers that return expires_in: 0.
+	// A zero-duration expiry resolves to time.Now(), which authHeader()
+	// then treats as expired, causing every request to re-mint in a loop.
+	expiry := time.Time{}
+	if tokenResp.ExpiresIn > 0 {
+		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)
@@ -1291,11 +1305,15 @@ func (c *Client) refreshAccessToken() error {
 	if c.Config == nil {
 		return nil
 	}
+{{- if .Auth.TokenURL}}
 	if c.Config.RefreshToken == "" {
 		return nil
 	}
 
-	tokenURL := "{{.Auth.TokenURL}}"
+	tokenURL := c.Config.TokenURL
+	if tokenURL == "" {
+		tokenURL = "{{.Auth.TokenURL}}"
+	}
 	if tokenURL == "" {
 		return nil
 	}
@@ -1345,6 +1363,7 @@ func (c *Client) refreshAccessToken() error {
 	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)
 	}
+{{- end}}
 
 	return nil
 }
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 219fd1bb..7a7b0719 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -37,6 +37,18 @@ type Config struct {
 {{- end}}
 	ClientID       string `{{configTag .Config.Format}}:"client_id"`
 	ClientSecret   string `{{configTag .Config.Format}}:"client_secret"`
+{{- if .Auth.AuthorizationURL}}
+	// AuthorizationURL overrides the spec-baked OAuth2 authorization endpoint.
+	// Falls back to the generator default at the call site when empty so a
+	// user pointing the CLI at a non-default deployment can override without
+	// regenerating.
+	AuthorizationURL string `{{configTag .Config.Format}}:"authorization_url,omitempty"`
+{{- end}}
+{{- if .Auth.TokenURL}}
+	// TokenURL overrides the spec-baked OAuth2 token endpoint. Same fallback
+	// pattern as AuthorizationURL.
+	TokenURL       string `{{configTag .Config.Format}}:"token_url,omitempty"`
+{{- end}}
 {{- end}}
 	Path           string `{{configTag .Config.Format}}:"-"`
 {{- if .Auth.EnvVarSpecs}}
@@ -151,6 +163,16 @@ func Load(configPath string) (*Config, error) {
 	if v := os.Getenv("{{envName .Name}}_BASE_URL"); v != "" {
 		cfg.BaseURL = v
 	}
+{{- if and .HasAuthCommand .Auth.AuthorizationURL}}
+	if v := os.Getenv("{{envName .Name}}_AUTHORIZATION_URL"); v != "" {
+		cfg.AuthorizationURL = v
+	}
+{{- end}}
+{{- if and .HasAuthCommand .Auth.TokenURL}}
+	if v := os.Getenv("{{envName .Name}}_TOKEN_URL"); v != "" {
+		cfg.TokenURL = v
+	}
+{{- end}}
 {{- if .EndpointTemplateVars}}
 
 	// Endpoint template vars: resolve each {placeholder} in BaseURL or the
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
index 9c60c980..b41ee7e3 100644
--- 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
@@ -76,23 +76,38 @@ Credentials default to PRINTING_PRESS_OAUTH2_CLIENT_ID (Client ID) and PRINTING_
 				return authErr(fmt.Errorf("client ID and secret required (set --client-id/--client-secret or PRINTING_PRESS_OAUTH2_CLIENT_ID/PRINTING_PRESS_OAUTH2_CLIENT_SECRET)"))
 			}
 
-			tok, err := mintClientCredentialsToken(http.DefaultClient, "https://api.cc.example/oauth/token", clientID, clientSecret)
+			cfg, err := config.Load(flags.configPath)
 			if err != nil {
-				return authErr(err)
+				return configErr(err)
 			}
 
-			cfg, err := config.Load(flags.configPath)
+			tokenURL := cfg.TokenURL
+			if tokenURL == "" {
+				tokenURL = "https://api.cc.example/oauth/token"
+			}
+			tok, err := mintClientCredentialsToken(http.DefaultClient, tokenURL, clientID, clientSecret)
 			if err != nil {
-				return configErr(err)
+				return authErr(err)
 			}
+
 			cfg.AuthHeaderVal = ""
-			expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+			// Guard against non-conformant servers that return expires_in: 0.
+			// A zero-duration expiry resolves to time.Now(), which the client's
+			// auto-refresh logic then treats as expired immediately.
+			expiry := time.Time{}
+			if tok.ExpiresIn > 0 {
+				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)
+			if expiry.IsZero() {
+				fmt.Fprintln(cmd.OutOrStdout(), "Logged in. Token expiry not provided.")
+			} else {
+				fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+					expiry.Format(time.RFC3339), tok.ExpiresIn)
+			}
 			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
index 497d4973..42d9f144 100644
--- 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
@@ -413,7 +413,13 @@ func resolveClientCredentials(cfg *config.Config) (string, string) {
 }
 
 func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
-	tokenURL := "https://api.cc.example/oauth/token"
+	tokenURL := ""
+	if c.Config != nil {
+		tokenURL = c.Config.TokenURL
+	}
+	if tokenURL == "" {
+		tokenURL = "https://api.cc.example/oauth/token"
+	}
 	if tokenURL == "" {
 		return nil
 	}
@@ -446,7 +452,13 @@ func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
 	if tokenResp.AccessToken == "" {
 		return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
 	}
-	expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	// Guard against non-conformant servers that return expires_in: 0.
+	// A zero-duration expiry resolves to time.Now(), which authHeader()
+	// then treats as expired, causing every request to re-mint in a loop.
+	expiry := time.Time{}
+	if tokenResp.ExpiresIn > 0 {
+		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)
@@ -462,7 +474,10 @@ func (c *Client) refreshAccessToken() error {
 		return nil
 	}
 
-	tokenURL := "https://api.cc.example/oauth/token"
+	tokenURL := c.Config.TokenURL
+	if tokenURL == "" {
+		tokenURL = "https://api.cc.example/oauth/token"
+	}
 	if tokenURL == "" {
 		return nil
 	}
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 9d50f491..e4c43288 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
@@ -23,6 +23,9 @@ type Config struct {
 	TokenExpiry    time.Time `toml:"token_expiry"`
 	ClientID       string `toml:"client_id"`
 	ClientSecret   string `toml:"client_secret"`
+	// TokenURL overrides the spec-baked OAuth2 token endpoint. Same fallback
+	// pattern as AuthorizationURL.
+	TokenURL       string `toml:"token_url,omitempty"`
 	Path           string `toml:"-"`
 	PrintingPressOauth2ClientId string `toml:"press_oauth2_client_id"`
 	PrintingPressOauth2ClientSecret string `toml:"press_oauth2_client_secret"`
@@ -84,6 +87,9 @@ func Load(configPath string) (*Config, error) {
 	if v := os.Getenv("PRINTING_PRESS_OAUTH2_BASE_URL"); v != "" {
 		cfg.BaseURL = v
 	}
+	if v := os.Getenv("PRINTING_PRESS_OAUTH2_TOKEN_URL"); v != "" {
+		cfg.TokenURL = v
+	}
 	return cfg, nil
 }
 
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 331598ff..fa42b072 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -12,7 +12,6 @@ import (
 	"io"
 	"math"
 	"net/http"
-	"net/url"
 	"os"
 	"path/filepath"
 	"sort"
@@ -369,60 +368,6 @@ func (c *Client) refreshAccessToken() error {
 	if c.Config == nil {
 		return nil
 	}
-	if c.Config.RefreshToken == "" {
-		return nil
-	}
-
-	tokenURL := ""
-	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
 }
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index d271cd0b..cf2edc6c 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -12,7 +12,6 @@ import (
 	"io"
 	"math"
 	"net/http"
-	"net/url"
 	"os"
 	"path/filepath"
 	"sort"
@@ -482,60 +481,6 @@ func (c *Client) refreshAccessToken() error {
 	if c.Config == nil {
 		return nil
 	}
-	if c.Config.RefreshToken == "" {
-		return nil
-	}
-
-	tokenURL := ""
-	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
 }

← e952e807 fix(generator): route receiver JSON helper through filters (  ·  back to Cli Printing Press  ·  fix(cli): fold per-spec base URL path prefixes on multi-spec 6e086c00 →