[object Object]

← back to Cli Printing Press

feat(cli): add auth set-token escape hatch for cookie-auth CLIs (#1641)

275646a3f87e8f9f7f088ef2cb048a068eabb22b · 2026-05-19 03:33:06 -0500 · Nick Walker

* feat(cli): add auth set-token escape hatch for cookie-auth CLIs

The auth_browser template now emits an `auth set-token <jwt>` subcommand
alongside login/status/logout. This is the escape hatch for sites whose
access token cannot be reached by the cookie-jar extractor in
`auth login --chrome` — most commonly Auth0 SPA SDK v2+ deployments that
keep the JWT in JS heap memory and never expose it to cookies or
localStorage. Users (or agents) paste the bearer from DevTools instead of
hand-editing config.toml.

Validation runs through cliutil.LooksLikeJWT (shipped in PR #1602 / WU-2),
so short Cloudflare-shaped tracking cookies like `__cf_bm` get rejected
with a hint pointing back at the DevTools paste flow. Emission is gated
on Auth.Type != "none", so auth.type=none + persisted-query CLIs (the
only path that routes a no-credentials spec through this template) don't
ship a subcommand they have nothing to save into.

Two stale assertions in the cookie/composed auth_browser regression
tests used the literal "set-token" as their proxy for "this is not the
auth_simple template." That proxy no longer holds — auth_browser now
legitimately emits set-token too. Swapped to `newAuthSetupCmd` as the
auth_simple-only signature, since auth_browser uses newAuthLoginCmd for
the cookie/chrome flow instead.

Refs #1598 (WU-3a).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli): zero TokenExpiry in auth set-token to avoid stale write

SaveTokens used to receive cfg.TokenExpiry from the loaded config, which
in the escape-hatch scenario is a past timestamp from an expired prior
session. The auth_browser status command doesn't consult TokenExpiry
today, so the bug wasn't immediately surfacing — but writing a known-
stale value violates the zero-on-write invariant that ClearTokens and
SaveBearerToken already follow in config.go.tmpl, and would mislead any
future expiry-aware status check.

Passing time.Time{} keeps the API "we don't know the lifetime"; the real
expiry surfaces via 401 on the next call, same as the rest of the
browser-auth flow.

Refs #1598 (WU-3a).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit 275646a3f87e8f9f7f088ef2cb048a068eabb22b
Author: Nick Walker <nick@stormforge.io>
Date:   Tue May 19 03:33:06 2026 -0500

    feat(cli): add auth set-token escape hatch for cookie-auth CLIs (#1641)
    
    * feat(cli): add auth set-token escape hatch for cookie-auth CLIs
    
    The auth_browser template now emits an `auth set-token <jwt>` subcommand
    alongside login/status/logout. This is the escape hatch for sites whose
    access token cannot be reached by the cookie-jar extractor in
    `auth login --chrome` — most commonly Auth0 SPA SDK v2+ deployments that
    keep the JWT in JS heap memory and never expose it to cookies or
    localStorage. Users (or agents) paste the bearer from DevTools instead of
    hand-editing config.toml.
    
    Validation runs through cliutil.LooksLikeJWT (shipped in PR #1602 / WU-2),
    so short Cloudflare-shaped tracking cookies like `__cf_bm` get rejected
    with a hint pointing back at the DevTools paste flow. Emission is gated
    on Auth.Type != "none", so auth.type=none + persisted-query CLIs (the
    only path that routes a no-credentials spec through this template) don't
    ship a subcommand they have nothing to save into.
    
    Two stale assertions in the cookie/composed auth_browser regression
    tests used the literal "set-token" as their proxy for "this is not the
    auth_simple template." That proxy no longer holds — auth_browser now
    legitimately emits set-token too. Swapped to `newAuthSetupCmd` as the
    auth_simple-only signature, since auth_browser uses newAuthLoginCmd for
    the cookie/chrome flow instead.
    
    Refs #1598 (WU-3a).
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
    
    * fix(cli): zero TokenExpiry in auth set-token to avoid stale write
    
    SaveTokens used to receive cfg.TokenExpiry from the loaded config, which
    in the escape-hatch scenario is a past timestamp from an expired prior
    session. The auth_browser status command doesn't consult TokenExpiry
    today, so the bug wasn't immediately surfacing — but writing a known-
    stale value violates the zero-on-write invariant that ClearTokens and
    SaveBearerToken already follow in config.go.tmpl, and would mislead any
    future expiry-aware status check.
    
    Passing time.Time{} keeps the API "we don't know the lifetime"; the real
    expiry surfaces via 401 on the next call, same as the rest of the
    browser-auth flow.
    
    Refs #1598 (WU-3a).
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/generator/generator_test.go              | 90 ++++++++++++++++++++++-
 internal/generator/templates/auth_browser.go.tmpl | 76 +++++++++++++++++++
 2 files changed, 162 insertions(+), 4 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 65cca4e3..59ed68b6 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1849,6 +1849,81 @@ func TestGenerateBrowserHTTPTransportDisablesHTTP2(t *testing.T) {
 	assert.NotContains(t, gomod, "github.com/enetx/surf")
 }
 
+// TestGenerateCookieAuthEmitsSetTokenSubcommand verifies that the
+// auth_browser template emits the `set-token` subcommand for cookie-auth
+// CLIs. The subcommand is the escape hatch for Auth0-SPA-in-memory sites
+// (retro #1598 WU-3) where `auth login --chrome` can't reach the JWT, so
+// users (or agents) paste the bearer from DevTools instead of hand-editing
+// config.toml. Validation runs through cliutil.LooksLikeJWT (retro #1598
+// WU-2) so short Cloudflare-shaped tracking cookies don't silently land
+// as access tokens.
+func TestGenerateCookieAuthEmitsSetTokenSubcommand(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("settokencookie")
+	apiSpec.BaseURL = "https://www.example.com"
+	apiSpec.Auth = spec.AuthConfig{
+		Type:         "cookie",
+		Header:       "Cookie",
+		In:           "cookie",
+		CookieDomain: ".example.com",
+		EnvVars:      []string{"SETTOKENCOOKIE_COOKIES"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "settokencookie-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	authGo := readGeneratedFile(t, outputDir, "internal", "cli", "auth.go")
+	assert.Contains(t, authGo, "func newAuthSetTokenCmd(",
+		"cookie-auth CLI should emit newAuthSetTokenCmd")
+	assert.Contains(t, authGo, "cmd.AddCommand(newAuthSetTokenCmd(flags))",
+		"newAuthCmd should register the set-token subcommand")
+	assert.Contains(t, authGo, "cliutil.LooksLikeJWT(",
+		"set-token should validate via the shared cliutil JWT-shape helper")
+	assert.Contains(t, authGo, "settokencookie-pp-cli/internal/cliutil",
+		"auth.go should import cliutil to call LooksLikeJWT")
+
+	// Help wiring sanity: build the CLI and confirm `auth set-token --help`
+	// exits 0 with the subcommand listed under `auth`.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	binPath := filepath.Join(outputDir, "settokencookie-pp-cli")
+	runGoCommand(t, outputDir, "build", "-o", binPath, "./cmd/settokencookie-pp-cli")
+	out, err := exec.Command(binPath, "auth", "--help").CombinedOutput()
+	require.NoError(t, err, "auth --help failed: %s", string(out))
+	assert.Contains(t, string(out), "set-token",
+		"set-token should appear in `auth --help` listing")
+}
+
+// TestGenerateNoAuthPersistedQueryOmitsSetToken verifies that the
+// auth_browser template does NOT emit set-token (or import cliutil) when
+// Auth.Type == "none" + a graphql_persisted_query hint routes the spec
+// through auth_browser purely for the query-refresh flow. Saving a token
+// has no meaning when there are no credentials to save; emitting the
+// subcommand would also produce an unused cliutil import and break build.
+func TestGenerateNoAuthPersistedQueryOmitsSetToken(t *testing.T) {
+	t.Parallel()
+
+	// auth.Type "none" alone wouldn't route to auth_browser, so the
+	// generator's persisted-query traffic-analysis hint pulls it into the
+	// browser-aware template purely for the query-refresh flow.
+	apiSpec := minimalSpec("noauthpq")
+	apiSpec.BaseURL = "https://api.example.com"
+	apiSpec.Auth = spec.AuthConfig{Type: "none"}
+
+	outputDir := filepath.Join(t.TempDir(), "noauthpq-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.TrafficAnalysis = &browsersniff.TrafficAnalysis{GenerationHints: []string{"graphql_persisted_query"}}
+	require.NoError(t, gen.Generate())
+
+	authGo := readGeneratedFile(t, outputDir, "internal", "cli", "auth.go")
+	assert.NotContains(t, authGo, "newAuthSetTokenCmd",
+		"auth.type=none should not emit a set-token subcommand")
+	assert.NotContains(t, authGo, "cliutil.LooksLikeJWT",
+		"auth.type=none should not reference the JWT-shape helper")
+	assert.NotContains(t, authGo, "internal/cliutil\"",
+		"auth.type=none must not import cliutil — would trigger unused-import")
+}
+
 func TestGenerateCookieHTMLDefaultsBrowserChromeTransport(t *testing.T) {
 	t.Parallel()
 
@@ -7055,8 +7130,12 @@ func TestGenerate_CookieAuthUsesBrowserTemplate(t *testing.T) {
 	assert.Contains(t, content, "Complete any login or browser challenge in Chrome")
 	assert.NotContains(t, content, "No browser runtime found.")
 	assert.NotContains(t, content, "newAuthRefreshQueriesCmd")
-	// Should NOT contain simple token template indicators
-	assert.NotContains(t, content, "set-token")
+	// Should NOT contain auth_simple template indicators. newAuthSetupCmd
+	// is the auth_simple signature — auth_browser uses newAuthLoginCmd
+	// for the cookie/chrome flow instead. (set-token used to be the
+	// proxy here, but auth_browser now also emits it as the Auth0-SPA
+	// escape hatch — retro #1598 WU-3a.)
+	assert.NotContains(t, content, "newAuthSetupCmd")
 
 	// Config should have cookie branch in AuthHeader
 	configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
@@ -7608,8 +7687,11 @@ func TestGenerate_ComposedAuthUsesBrowserTemplate(t *testing.T) {
 	assert.Contains(t, content, "detectCookieTool")
 	assert.Contains(t, content, "extractCookies")
 	assert.Contains(t, content, "pagliacci.com")
-	// Should NOT contain simple token template
-	assert.NotContains(t, content, "set-token")
+	// Should NOT contain auth_simple template signature. newAuthSetupCmd
+	// is auth_simple-only — auth_browser uses newAuthLoginCmd instead.
+	// (set-token used to be the proxy, but auth_browser now also emits
+	// it as the Auth0-SPA escape hatch — retro #1598 WU-3a.)
+	assert.NotContains(t, content, "newAuthSetupCmd")
 
 	runGoCommand(t, outputDir, "mod", "tidy")
 	runGoCommand(t, outputDir, "build", "./...")
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index d92432a9..c882be3c 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -42,6 +42,9 @@ import (
 
 {{- if .Auth.RequiresBrowserSession}}
 	"{{modulePath}}/internal/client"
+{{- end}}
+{{- if not (eq .Auth.Type "none")}}
+	"{{modulePath}}/internal/cliutil"
 {{- end}}
 	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
@@ -62,6 +65,9 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd.AddCommand(newAuthRefreshQueriesCmd(flags))
 {{- end}}
 	cmd.AddCommand(newAuthStatusCmd(flags))
+{{- if not (eq .Auth.Type "none")}}
+	cmd.AddCommand(newAuthSetTokenCmd(flags))
+{{- end}}
 	cmd.AddCommand(newAuthLogoutCmd(flags))
 
 	return cmd
@@ -903,6 +909,76 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 	}
 }
 
+{{- if not (eq .Auth.Type "none")}}
+
+// newAuthSetTokenCmd is the escape hatch for sites whose access token can't
+// be reached by the cookie-jar extractor in `auth login --chrome` — most
+// commonly Auth0 SPA SDK v2+ deployments using the default in-memory cache,
+// where the JWT lives in JS heap and never touches cookies or localStorage.
+// Users (or agents) grab the bearer from DevTools → Network → Authorization
+// and paste it here instead of hand-editing config.toml.
+//
+// The cliutil.LooksLikeJWT length floor is the cookie-vs-JWT-shape gate from
+// retro #1598 WU-2: short tracking cookies like Cloudflare's `__cf_bm`
+// (~31 chars) happen to have three base64url segments and would otherwise
+// silently land as the access token.
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "set-token <jwt>",
+		Short:   "Save a bearer JWT pasted from DevTools (escape hatch for in-memory tokens)",
+		Example: "  {{.Name}}-pp-cli auth set-token eyJhbGciOi...",
+		Long: "Save a bearer JWT pasted from DevTools (escape hatch for in-memory tokens).\n\n" +
+			"Use this when `auth login --chrome` can't reach the access token — most\n" +
+			"commonly Auth0 SPA SDK deployments that keep the JWT in JS heap memory.\n" +
+			"Open DevTools → Network → any authenticated request → copy the\n" +
+			"`Authorization: Bearer ...` value and paste the JWT portion here.\n\n" +
+			"The token is validated for JWT shape (three base64url segments, length\n" +
+			"floor) so short tracking cookies don't get saved as credentials.",
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			token := strings.TrimSpace(args[0])
+			token = strings.TrimPrefix(token, "Bearer ")
+			if !cliutil.LooksLikeJWT(token) {
+				return authErr(fmt.Errorf("argument is not JWT-shaped (three base64url segments, ≥150 chars total). " +
+					"This is the cookie-vs-JWT-shape gate — short tracking cookies like Cloudflare's __cf_bm " +
+					"share the segment shape but aren't credentials. Paste the `Authorization: Bearer ...` value " +
+					"from DevTools → Network, not a cookie jar"))
+			}
+
+			// Clear any legacy auth_header so AuthHeader() falls through to
+			// the newly-saved bearer. Mirrors auth_simple's set-token.
+			cfg.AuthHeaderVal = ""
+			// Pass a zero expiry. We can't decode the JWT to learn its real
+			// lifetime, and preserving a stale cfg.TokenExpiry from a prior
+			// session (the exact case escape-hatch users reach this from)
+			// would later mislead any expiry-aware status check into treating
+			// this fresh token as already expired. Actual expiry surfaces via
+			// 401 on the next API call, same as the rest of the browser-auth
+			// flow. Matches ClearTokens / SaveBearerToken's zero-on-write
+			// invariant in config.go.tmpl.
+			if err := cfg.SaveTokens("", "", token, "", time.Time{}); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+
+			// JSON envelope: {saved, config_path}.
+			if flags.asJSON {
+				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+					"saved":       true,
+					"config_path": cfg.Path,
+				}, flags)
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+			return nil
+		},
+	}
+}
+{{- end}}
+
 func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:     "logout",

← f9bb82c8 fix(skills): reprint discovers and carries prior patches (#1  ·  back to Cli Printing Press  ·  fix(cli): sync pagination, auth aliases, narrative timing, a ac3e15b4 →