[object Object]

← back to Cli Printing Press

fix(cli): gate OAuth refresh-token params on x-oauth-refresh-token-mechanism (#1099)

cba06db19aa2dd0af1b161686873e98f1ae2a71a · 2026-05-11 12:31:32 -0700 · Trevin Chow

Closes #916

The OAuth template emitted access_type=offline and prompt=consent on every
authorization_code CLI it generated. Both are Google-specific; non-Google
providers ignore them and require a magic scope value instead (offline,
offline.access, offline_access). For WHOOP, every call after the access-token
TTL 401'd until interactive re-auth -- invisible until production.

Adds a new x-oauth-refresh-token-mechanism spec extension parsed into
AuthConfig.RefreshTokenMechanism. ParseRefreshTokenMechanism returns a
typed struct; the template branches on Kind:
- scope:<value>  -> scopes = append(scopes, <value>)
- query:<k>=<v>  -> params.Set(<k>, <v>)
- (empty/malformed) -> no emission

Default is silent: a Google-shaped default that silently breaks other
providers is worse than no default. Reserved authorization-URL params
(client_id, redirect_uri, response_type, state, scope) are rejected from
the query form to prevent silently clobbering the generated CSRF state token.

Google CLIs regenerated without the extension lose the existing
access_type+prompt=consent params; they still issue refresh tokens on
initial consent but lose forced re-consent on subsequent logins. The
two-param recipe cannot be expressed with a single mechanism string and is
documented as a known constraint.

Files touched

Diff

commit cba06db19aa2dd0af1b161686873e98f1ae2a71a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 12:31:32 2026 -0700

    fix(cli): gate OAuth refresh-token params on x-oauth-refresh-token-mechanism (#1099)
    
    Closes #916
    
    The OAuth template emitted access_type=offline and prompt=consent on every
    authorization_code CLI it generated. Both are Google-specific; non-Google
    providers ignore them and require a magic scope value instead (offline,
    offline.access, offline_access). For WHOOP, every call after the access-token
    TTL 401'd until interactive re-auth -- invisible until production.
    
    Adds a new x-oauth-refresh-token-mechanism spec extension parsed into
    AuthConfig.RefreshTokenMechanism. ParseRefreshTokenMechanism returns a
    typed struct; the template branches on Kind:
    - scope:<value>  -> scopes = append(scopes, <value>)
    - query:<k>=<v>  -> params.Set(<k>, <v>)
    - (empty/malformed) -> no emission
    
    Default is silent: a Google-shaped default that silently breaks other
    providers is worse than no default. Reserved authorization-URL params
    (client_id, redirect_uri, response_type, state, scope) are rejected from
    the query form to prevent silently clobbering the generated CSRF state token.
    
    Google CLIs regenerated without the extension lose the existing
    access_type+prompt=consent params; they still issue refresh tokens on
    initial consent but lose forced re-consent on subsequent logins. The
    two-param recipe cannot be expressed with a single mechanism string and is
    documented as a known constraint.
---
 docs/SPEC-EXTENSIONS.md                   |  53 ++++++++++++++
 internal/generator/generator_test.go      | 113 ++++++++++++++++++++++++++++++
 internal/generator/templates/auth.go.tmpl |   9 ++-
 internal/openapi/parser.go                |  40 ++++++-----
 internal/openapi/parser_test.go           |  51 ++++++++++++++
 internal/spec/spec.go                     |  60 ++++++++++++++++
 internal/spec/spec_test.go                |  32 +++++++++
 7 files changed, 338 insertions(+), 20 deletions(-)

diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index b534f694..8b348e09 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -31,6 +31,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-auth-description` | `components.securitySchemes.<name>` | `APISpec.Auth.Description` | No |
 | `x-auth-cookie-domain` | `components.securitySchemes.<name>` | `APISpec.Auth.CookieDomain` | No |
 | `x-auth-cookies` | `components.securitySchemes.<name>` | `APISpec.Auth.Cookies` | No |
+| `x-oauth-refresh-token-mechanism` | `components.securitySchemes.<name>` | `APISpec.Auth.RefreshTokenMechanism` | No |
 | `x-resource-id` | path item | `Endpoint.IDField` | No |
 | `x-critical` | path item | `Endpoint.Critical` | No |
 | `x-tier` | path item or operation | `Endpoint.Tier` | No |
@@ -496,6 +497,58 @@ components:
         - csrf_token
 ```
 
+### `x-oauth-refresh-token-mechanism`
+
+Declares how the authorization endpoint should be asked to issue a refresh
+token. Providers diverge: Google reads `access_type=offline` as a query
+parameter, while WHOOP, X/Twitter, and others read a magic scope value
+(`offline`, `offline.access`, `offline_access`) instead. The generator emits
+neither by default because a Google-shaped silent default silently breaks
+other providers (broken refresh path is invisible until access-token TTL
+expires).
+
+Parsed field: `APISpec.Auth.RefreshTokenMechanism`
+
+Rules:
+
+- Optional. Only consumed by the authorization_code grant template; ignored
+  for other grants and non-OAuth2 auth.
+- Must be a string. Leading and trailing whitespace on the whole value is
+  trimmed.
+- Two exact-match prefixes are accepted:
+  - `scope:<value>` appends `<value>` to the scope list. No query param is
+    added.
+  - `query:<key>=<value>` sets the query parameter exactly once. No scope
+    change.
+- Malformed values (empty key, empty value, missing `=` for `query`, unknown
+  prefix, uppercase prefix) are ignored and produce no emission.
+- For `query:<key>=<value>`, the reserved authorization-URL parameter names
+  `client_id`, `redirect_uri`, `response_type`, `state`, and `scope` are
+  rejected. Permitting them would let a spec author silently overwrite the
+  generator's CSRF state token or core OAuth params.
+- Note: the single-mechanism shape cannot express Google's two-param recipe
+  (`access_type=offline` + `prompt=consent`). The first param is sufficient
+  for refresh-token issuance on initial consent; the second forces re-consent
+  on subsequent logins to keep the refresh-token contract alive. Specs that
+  need both should declare one via this extension and add the other through a
+  future multi-mechanism syntax (out of scope here).
+
+Example:
+
+```yaml
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      x-oauth-refresh-token-mechanism: scope:offline
+      flows:
+        authorizationCode:
+          authorizationUrl: https://api.example.com/oauth/authorize
+          tokenUrl: https://api.example.com/oauth/token
+          scopes:
+            read: Read access
+```
+
 ## Path Item Extensions
 
 Path item extensions are read from a path object, beside its HTTP operations.
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index a66e3a65..17e07ff4 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -659,6 +659,119 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 	})
 }
 
+func TestGenerateOAuth2RefreshTokenMechanism(t *testing.T) {
+	t.Parallel()
+
+	baseSpec := func() *spec.APISpec {
+		return &spec.APISpec{
+			Name:    "oauthapi",
+			Version: "0.1.0",
+			BaseURL: "https://api.example.com",
+			Auth: spec.AuthConfig{
+				Type:             "bearer_token",
+				Header:           "Authorization",
+				Format:           "Bearer {token}",
+				EnvVars:          []string{"OAUTHAPI_TOKEN"},
+				AuthorizationURL: "https://api.example.com/oauth/authorize",
+				TokenURL:         "https://api.example.com/oauth/token",
+				Scopes:           []string{"read"},
+				OAuth2Grant:      spec.OAuth2GrantAuthorizationCode,
+			},
+			Config: spec.ConfigSpec{
+				Format: "toml",
+				Path:   "~/.config/oauthapi-pp-cli/config.toml",
+			},
+			Resources: map[string]spec.Resource{
+				"items": {
+					Description: "Manage items",
+					Endpoints: map[string]spec.Endpoint{
+						"list": {Method: "GET", Path: "/items", Description: "List items"},
+					},
+				},
+			},
+		}
+	}
+
+	tests := []struct {
+		name         string
+		mechanism    string
+		wantContains []string
+		wantAbsent   []string
+	}{
+		{
+			name:      "default omits Google-specific params",
+			mechanism: "",
+			wantAbsent: []string{
+				`"access_type":`,
+				`"prompt":`,
+				`params.Set("access_type"`,
+				`params.Set("prompt"`,
+			},
+		},
+		{
+			name:      "scope mechanism appends to scopes",
+			mechanism: "scope:offline",
+			wantContains: []string{
+				`scopes = append(scopes, "offline")`,
+			},
+			wantAbsent: []string{
+				`params.Set("access_type"`,
+				`"access_type":`,
+			},
+		},
+		{
+			name:      "query mechanism emits exact param",
+			mechanism: "query:access_type=offline",
+			wantContains: []string{
+				`params.Set("access_type", "offline")`,
+			},
+			wantAbsent: []string{
+				`"access_type":   {"offline"}`,
+				`scopes = append(scopes,`,
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			apiSpec := baseSpec()
+			apiSpec.Auth.RefreshTokenMechanism = tt.mechanism
+
+			outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+			require.NoError(t, New(apiSpec, outputDir).Generate())
+
+			authGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+			require.NoError(t, err)
+			content := string(authGo)
+			for _, want := range tt.wantContains {
+				assert.Contains(t, content, want, "missing expected emission")
+			}
+			for _, absent := range tt.wantAbsent {
+				assert.NotContains(t, content, absent, "should not emit")
+			}
+		})
+	}
+
+	t.Run("client_credentials grant ignores mechanism", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := baseSpec()
+		apiSpec.Auth.OAuth2Grant = spec.OAuth2GrantClientCredentials
+		apiSpec.Auth.AuthorizationURL = ""
+		apiSpec.Auth.RefreshTokenMechanism = "scope:offline"
+
+		outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+		require.NoError(t, New(apiSpec, outputDir).Generate())
+
+		authGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+		require.NoError(t, err)
+		content := string(authGo)
+		assert.NotContains(t, content, `scopes = append(scopes, "offline")`,
+			"client_credentials grant must not emit authorization_code refresh-token mechanism")
+		assert.NotContains(t, content, `params.Set(`,
+			"client_credentials grant uses a token-endpoint POST, not an authorization URL")
+	})
+}
+
 func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index f9eb72d2..1565bfa1 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -147,15 +147,20 @@ func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 				authURL = "{{.Auth.AuthorizationURL}}"
 			}
 {{- end}}
+{{- $refreshMech := .Auth.ParseRefreshTokenMechanism}}
 			params := url.Values{
 				"client_id":     {clientID},
 				"redirect_uri":  {redirectURI},
 				"response_type": {"code"},
 				"state":         {state},
-				"access_type":   {"offline"},
-				"prompt":        {"consent"},
 			}
+{{- if eq $refreshMech.Kind "query"}}
+			params.Set({{printf "%q" $refreshMech.Key}}, {{printf "%q" $refreshMech.Value}})
+{{- end}}
 			scopes := []string{ {{- range .Auth.Scopes}}"{{.}}", {{end}} }
+{{- if eq $refreshMech.Kind "scope"}}
+			scopes = append(scopes, {{printf "%q" $refreshMech.Scope}})
+{{- end}}
 			if len(scopes) > 0 {
 				params.Set("scope", strings.Join(scopes, " "))
 			}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 2e800e40..48848b5a 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -31,24 +31,25 @@ var (
 )
 
 const (
-	extensionAuthEnvVars      = "x-auth-env-vars"
-	extensionAuthVars         = "x-auth-vars"
-	extensionAuthOptional     = "x-auth-optional"
-	extensionAuthKeyURL       = "x-auth-key-url"
-	extensionAuthInstructions = "x-auth-instructions"
-	extensionAuthTitle        = "x-auth-title"
-	extensionAuthDescription  = "x-auth-description"
-	extensionSpeakeasyExample = "x-speakeasy-example"
-	extensionTierRouting      = "x-tier-routing"
-	extensionTier             = "x-tier"
-	extensionMCP              = "x-mcp"
-	extensionSyncWalker       = "x-pp-sync-walker"
-	extensionAPIName          = "x-api-name"
-	extensionDisplayName      = "x-display-name"
-	extensionWebsite          = "x-website"
-	extensionProxyRoutes      = "x-proxy-routes"
-	extensionOrigin           = "x-origin"
-	extensionProviderName     = "x-providerName"
+	extensionAuthEnvVars           = "x-auth-env-vars"
+	extensionAuthVars              = "x-auth-vars"
+	extensionAuthOptional          = "x-auth-optional"
+	extensionAuthKeyURL            = "x-auth-key-url"
+	extensionAuthInstructions      = "x-auth-instructions"
+	extensionAuthTitle             = "x-auth-title"
+	extensionAuthDescription       = "x-auth-description"
+	extensionOAuthRefreshTokenMech = "x-oauth-refresh-token-mechanism"
+	extensionSpeakeasyExample      = "x-speakeasy-example"
+	extensionTierRouting           = "x-tier-routing"
+	extensionTier                  = "x-tier"
+	extensionMCP                   = "x-mcp"
+	extensionSyncWalker            = "x-pp-sync-walker"
+	extensionAPIName               = "x-api-name"
+	extensionDisplayName           = "x-display-name"
+	extensionWebsite               = "x-website"
+	extensionProxyRoutes           = "x-proxy-routes"
+	extensionOrigin                = "x-origin"
+	extensionProviderName          = "x-providerName"
 )
 
 // SetMaxResources overrides the default resource limit. When not called,
@@ -740,6 +741,9 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 	if description := stringExtension(extensions, extensionAuthDescription); description != "" {
 		auth.Description = description
 	}
+	if mech := stringExtension(extensions, extensionOAuthRefreshTokenMech); mech != "" {
+		auth.RefreshTokenMechanism = mech
+	}
 }
 
 func applyAuthEnvVarDefaults(auth *spec.AuthConfig, envPrefix string) {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a39d74ab..750755b4 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -2288,6 +2288,57 @@ paths:
 	assert.Equal(t, []string{"OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET"}, parsed.Auth.EnvVars)
 }
 
+func TestOpenAPIOAuthRefreshTokenMechanism(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		ext  string
+		want string
+	}{
+		{name: "absent leaves field empty", ext: "", want: ""},
+		{name: "scope:offline (WHOOP shape)", ext: "scope:offline", want: "scope:offline"},
+		{name: "scope:offline.access (X/Twitter shape)", ext: "scope:offline.access", want: "scope:offline.access"},
+		{name: "query:access_type=offline (Google shape)", ext: "query:access_type=offline", want: "query:access_type=offline"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ext := ""
+			if tt.ext != "" {
+				ext = "      x-oauth-refresh-token-mechanism: \"" + tt.ext + "\"\n"
+			}
+			yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: OAuth API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+` + ext + `      flows:
+        authorizationCode:
+          authorizationUrl: https://api.example.com/oauth/authorize
+          tokenUrl: https://api.example.com/oauth/token
+          scopes:
+            read: Read access
+paths:
+  /widgets:
+    get:
+      security:
+        - OAuth2: [read]
+      responses:
+        "200":
+          description: OK
+`)
+			parsed, err := Parse(yamlSpec)
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, parsed.Auth.RefreshTokenMechanism)
+		})
+	}
+}
+
 func TestOpenAPIAuthOverrideExtensions(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 6aa6f2f8..93360638 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -502,6 +502,66 @@ type AuthConfig struct {
 	// 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"`
+
+	// RefreshTokenMechanism declares how the authorization endpoint should be
+	// asked to issue a refresh token. Distinct mechanisms across providers:
+	// Google reads "access_type=offline" as a query param; WHOOP, X/Twitter,
+	// and others read a magic scope value ("offline", "offline.access",
+	// "offline_access") instead. Format: "scope:<value>" or "query:<k=v>".
+	// When empty, the template emits neither -- silent default is preferable
+	// to a Google-shaped default that silently breaks other providers.
+	// Used by the authorization_code flow only; ignored for other grants.
+	RefreshTokenMechanism string `yaml:"refresh_token_mechanism,omitempty" json:"refresh_token_mechanism,omitempty"`
+}
+
+const (
+	RefreshTokenMechanismKindScope = "scope"
+	RefreshTokenMechanismKindQuery = "query"
+)
+
+// ParsedRefreshTokenMechanism is the decoded form of AuthConfig.RefreshTokenMechanism.
+// Kind is "scope", "query", or "" when the field is empty or malformed. Scope is set
+// when Kind=="scope"; Key/Value are set when Kind=="query".
+type ParsedRefreshTokenMechanism struct {
+	Kind  string
+	Scope string
+	Key   string
+	Value string
+}
+
+// ParseRefreshTokenMechanism decodes RefreshTokenMechanism once for templates to
+// pin to a local variable. Malformed input returns the zero value silently --
+// authoring mistakes degrade to today's no-emission default rather than erroring.
+func (a AuthConfig) ParseRefreshTokenMechanism() ParsedRefreshTokenMechanism {
+	prefix, rest, ok := strings.Cut(strings.TrimSpace(a.RefreshTokenMechanism), ":")
+	if !ok || rest == "" {
+		return ParsedRefreshTokenMechanism{}
+	}
+	switch prefix {
+	case RefreshTokenMechanismKindScope:
+		return ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindScope, Scope: rest}
+	case RefreshTokenMechanismKindQuery:
+		k, v, ok := strings.Cut(rest, "=")
+		if !ok || k == "" || v == "" {
+			return ParsedRefreshTokenMechanism{}
+		}
+		// Authoring guard: refuse to overwrite reserved authorization-URL
+		// params. Letting query:state=... slip through would clobber the
+		// generated CSRF state token.
+		if reservedOAuthAuthURLParam(k) {
+			return ParsedRefreshTokenMechanism{}
+		}
+		return ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindQuery, Key: k, Value: v}
+	}
+	return ParsedRefreshTokenMechanism{}
+}
+
+func reservedOAuthAuthURLParam(key string) bool {
+	switch key {
+	case "client_id", "redirect_uri", "response_type", "state", "scope":
+		return true
+	}
+	return false
 }
 
 type AuthEnvVar struct {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 6b1f46fb..f6693b39 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1092,6 +1092,38 @@ func TestEffectiveOAuth2Grant(t *testing.T) {
 	}
 }
 
+func TestParseRefreshTokenMechanism(t *testing.T) {
+	tests := []struct {
+		name string
+		raw  string
+		want ParsedRefreshTokenMechanism
+	}{
+		{name: "empty", raw: "", want: ParsedRefreshTokenMechanism{}},
+		{name: "whitespace only", raw: "   ", want: ParsedRefreshTokenMechanism{}},
+		{name: "scope offline", raw: "scope:offline", want: ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindScope, Scope: "offline"}},
+		{name: "scope offline.access", raw: "scope:offline.access", want: ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindScope, Scope: "offline.access"}},
+		{name: "scope offline_access", raw: "scope:offline_access", want: ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindScope, Scope: "offline_access"}},
+		{name: "query access_type", raw: "query:access_type=offline", want: ParsedRefreshTokenMechanism{Kind: RefreshTokenMechanismKindQuery, Key: "access_type", Value: "offline"}},
+		{name: "query empty key rejected", raw: "query:=offline", want: ParsedRefreshTokenMechanism{}},
+		{name: "query empty value rejected", raw: "query:access_type=", want: ParsedRefreshTokenMechanism{}},
+		{name: "query missing equals rejected", raw: "query:access_type", want: ParsedRefreshTokenMechanism{}},
+		{name: "unknown prefix rejected", raw: "header:Foo=bar", want: ParsedRefreshTokenMechanism{}},
+		{name: "missing colon rejected", raw: "scope offline", want: ParsedRefreshTokenMechanism{}},
+		{name: "case-sensitive prefix rejected", raw: "Scope:offline", want: ParsedRefreshTokenMechanism{}},
+		{name: "reserved key state rejected", raw: "query:state=foo", want: ParsedRefreshTokenMechanism{}},
+		{name: "reserved key client_id rejected", raw: "query:client_id=foo", want: ParsedRefreshTokenMechanism{}},
+		{name: "reserved key redirect_uri rejected", raw: "query:redirect_uri=foo", want: ParsedRefreshTokenMechanism{}},
+		{name: "reserved key response_type rejected", raw: "query:response_type=token", want: ParsedRefreshTokenMechanism{}},
+		{name: "reserved key scope rejected", raw: "query:scope=offline", want: ParsedRefreshTokenMechanism{}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := AuthConfig{RefreshTokenMechanism: tt.raw}
+			assert.Equal(t, tt.want, cfg.ParseRefreshTokenMechanism())
+		})
+	}
+}
+
 func TestVersionPassedThrough(t *testing.T) {
 	base := func(v string) APISpec {
 		return APISpec{

← 1ca94b91 fix(cli): gofmt rendered .go output in generator emit phase  ·  back to Cli Printing Press  ·  fix(cli): surface API body in sync_error events and add --pa cb3c0974 →