← 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
M internal/generator/generator_test.goM internal/generator/templates/auth_browser.go.tmpl
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 →