← back to Cli Printing Press
feat(cli): add cliutil.LooksLikeJWT shared validator with length floor (#1602)
b5b1dd594989b0335adebcae66930237b6cc2e03 · 2026-05-18 19:26:15 -0500 · Nick Walker
* docs(cli): file factor75 retro — 3 systemic findings from live dogfooding
Live debugging surfaced three machine-side gaps that compound on any
sniffed BFF CLI:
1. Hand-authored sync silently drops URL personalization params,
degrading responses to a generic preselect without changing JSON shape.
2. looksLikeJWT has no minimum-length floor and saves short Cloudflare /
tracking cookies as access tokens.
3. auth login --chrome can't reach Auth0 SPA SDK in-memory tokens (now
the SDK default since v2 deprecated localStorage as XSS-unsafe).
Tracked as GitHub issue #1598 with three WUs that can be picked up
separately. The Factor75 printed CLI's matching syncer fix is in the
local library (~/printing-press/library/factor75/) and is unaffected by
this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): add cliutil.LooksLikeJWT shared validator with length floor
Adds a generator-emitted cliutil helper that every printed CLI now
carries: LooksLikeJWT(string) bool and FindJWTInCookieJar(jar) string,
both with a 150-char total / 20-char header minimum that filters out
Cloudflare bot-management cookies, CSRF tokens, and other short shapes
that share JWT's three-base64url-segments structure.
Until now, sniffed CLIs that need to validate a JWT shape (e.g. when
extracting a Bearer from a cookie jar) had to hand-author the check,
and the hand-authored versions inherit a false-positive: any three-
segment base64url string passes regardless of length. The factor75
printed CLI hit this with the Cloudflare `__cf_bm`-shaped value
`01KRPVRYA2SNQT9BAGD6984WAG_.tt.1` (31 chars), which got saved as the
access token and produced confusing HTTP 401 "invalid character"
decode errors on every subsequent API call.
The helpers are purely additive — no current template calls them, so
no rendered output changes for existing CLIs. They become useful as
soon as auth templates start needing them (next PR: WU-3a `auth
set-token` subcommand uses LooksLikeJWT as its validator).
Refs #1598 (WU-2).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): address PR review feedback on jwtshape helper (#1602)
- Tighten boundary tests to hit 149/150 exactly so constant changes
(150 -> 155) or off-by-one comparison drift surface immediately.
Previous fixtures sat at 108 and 158 chars, neither exercising
the floor.
- FindJWTInCookieJar now strips a leading "Bearer " from the cookie
value before returning, matching LooksLikeJWT's input normalization.
A cookie carrying the Authorization wire value ("Bearer eyJ...")
used to return the prefixed form, which would produce a double-
prefixed Authorization header when a caller built the header from
the result. New test case proves the bare token comes back.
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
A docs/retros/2026-05-17-factor75-retro.mdM internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/cliutil_jwtshape.go.tmplA internal/generator/templates/cliutil_jwtshape_test.go.tmpl
Diff
commit b5b1dd594989b0335adebcae66930237b6cc2e03
Author: Nick Walker <nwalker@cloudbolt.io>
Date: Mon May 18 19:26:15 2026 -0500
feat(cli): add cliutil.LooksLikeJWT shared validator with length floor (#1602)
* docs(cli): file factor75 retro — 3 systemic findings from live dogfooding
Live debugging surfaced three machine-side gaps that compound on any
sniffed BFF CLI:
1. Hand-authored sync silently drops URL personalization params,
degrading responses to a generic preselect without changing JSON shape.
2. looksLikeJWT has no minimum-length floor and saves short Cloudflare /
tracking cookies as access tokens.
3. auth login --chrome can't reach Auth0 SPA SDK in-memory tokens (now
the SDK default since v2 deprecated localStorage as XSS-unsafe).
Tracked as GitHub issue #1598 with three WUs that can be picked up
separately. The Factor75 printed CLI's matching syncer fix is in the
local library (~/printing-press/library/factor75/) and is unaffected by
this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): add cliutil.LooksLikeJWT shared validator with length floor
Adds a generator-emitted cliutil helper that every printed CLI now
carries: LooksLikeJWT(string) bool and FindJWTInCookieJar(jar) string,
both with a 150-char total / 20-char header minimum that filters out
Cloudflare bot-management cookies, CSRF tokens, and other short shapes
that share JWT's three-base64url-segments structure.
Until now, sniffed CLIs that need to validate a JWT shape (e.g. when
extracting a Bearer from a cookie jar) had to hand-author the check,
and the hand-authored versions inherit a false-positive: any three-
segment base64url string passes regardless of length. The factor75
printed CLI hit this with the Cloudflare `__cf_bm`-shaped value
`01KRPVRYA2SNQT9BAGD6984WAG_.tt.1` (31 chars), which got saved as the
access token and produced confusing HTTP 401 "invalid character"
decode errors on every subsequent API call.
The helpers are purely additive — no current template calls them, so
no rendered output changes for existing CLIs. They become useful as
soon as auth templates start needing them (next PR: WU-3a `auth
set-token` subcommand uses LooksLikeJWT as its validator).
Refs #1598 (WU-2).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): address PR review feedback on jwtshape helper (#1602)
- Tighten boundary tests to hit 149/150 exactly so constant changes
(150 -> 155) or off-by-one comparison drift surface immediately.
Previous fixtures sat at 108 and 158 chars, neither exercising
the floor.
- FindJWTInCookieJar now strips a leading "Bearer " from the cookie
value before returning, matching LooksLikeJWT's input normalization.
A cookie carrying the Authorization wire value ("Bearer eyJ...")
used to return the prefixed form, which would produce a double-
prefixed Authorization header when a caller built the header from
the result. New test case proves the bare token comes back.
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>
---
docs/retros/2026-05-17-factor75-retro.md | 92 +++++++++++++++
internal/generator/generator.go | 4 +
internal/generator/generator_test.go | 10 +-
.../generator/templates/cliutil_jwtshape.go.tmpl | 96 ++++++++++++++++
.../templates/cliutil_jwtshape_test.go.tmpl | 128 +++++++++++++++++++++
5 files changed, 327 insertions(+), 3 deletions(-)
diff --git a/docs/retros/2026-05-17-factor75-retro.md b/docs/retros/2026-05-17-factor75-retro.md
new file mode 100644
index 00000000..2693f7eb
--- /dev/null
+++ b/docs/retros/2026-05-17-factor75-retro.md
@@ -0,0 +1,92 @@
+# Printing Press Retro: factor75
+
+## Session Stats
+
+- API: factor75 (Factor75 / HelloFresh BFF on `www.factor75.com`, Auth0 SPA SDK + Cloudflare)
+- Spec source: browser-sniffed, hand-augmented; the printed CLI has been live for some time and was being dogfooded against a real user account
+- Retro trigger: live dogfooding turned up `deliveries pending` misclassifying the user's already-customized cart as "auto-preselected." Three further machine-side gaps surfaced during the debug session.
+- Outcome: printed-CLI fix shipped in the same change as this retro (syncer.go now forwards `servings` + `delivery-option` to `/gw/my-deliveries/menu`). Three systemic findings filed against the machine.
+
+## Findings
+
+### F1. Hand-authored sync drops URL personalization params; cart shape silently degrades to generic preselect
+
+- **What happened:** The Factor75 printed CLI's `internal/syncer/syncer.go` calls `GET /gw/my-deliveries/menu?week=...` with 5 query params (`week`, `country`, `locale`, `subscription`, `product-sku`). The live website calls the same endpoint with 11 params, adding `servings`, `delivery-option`, `postcode`, `preference`, `customerPlanId`, and `include-future-feedback`. With the trimmed param set, the gateway returns a generic plan preselect (3 distinct meals × qty 6 = 18) instead of the user's actual cart (11 distinct meals × qty 1–3 = 15). The CLI's local store has been silently storing a non-user-specific snapshot for weeks; downstream features (`deliveries pending` classification, `favorites suggest` recency-exclusion, `history` analytics) all inherit the bad data.
+- **Scorer correct?** N/A — neither the live-dogfood gate nor scorecard compares the agent's hand-written HTTP request shape against the browser-sniff capture of the same endpoint. Both signed off.
+- **Root cause:** Hand-authored sync code under `internal/syncer/` is exempt from `cobratree`'s mechanical endpoint surface checks. The agent who wrote the syncer picked the params that looked obviously needed and dropped the rest. There is no skill checklist gate or scorer rule that flags param-cardinality drop against the captured network log for the same endpoint.
+- **Cross-API check:** Will recur on any CLI whose sync is hand-authored against a sniffed BFF endpoint that personalizes its response based on plan / locale / household params. Examples likely affected today by inspection of similar shapes:
+ - HelloFresh / Green Chef / EveryPlate (same BFF family as Factor75 — `/gw/my-deliveries/menu` is the HF whitelabel endpoint, every brand on this gateway has the same personalization param contract)
+ - Most subscription-meal / household-plan APIs (Blue Apron, Sun Basket if reverse-engineered, Daily Harvest)
+ - Any sniffed CLI whose sync target is a list-or-menu endpoint with `customerPlanId`/`accountId`/`tenantId`-style scoping
+- **Frequency:** subclass `personalization-param-drop`. Tightly bounded to APIs whose response *shape* matches across personalized vs default modes but whose *content* diverges — the difference isn't visible at structural test time, only when comparing against the user's reality.
+- **Fallback if Printing Press doesn't fix:** the printed CLI ships with stale data forever, and the user only catches it by visually comparing the CLI to the website. Caught in this run because the user pointed at the website mid-debug; would not have been caught by any existing gate.
+- **Worth a Printing Press fix?** Yes — this is a class of bug, not a one-off.
+- **Inherent or fixable:** Fixable. The sniff capture already records the full URL (with all 11 params) for each endpoint. A skill-side review gate or scorer check can grep the generated/hand-authored sync code for the endpoint path and compare param sets.
+- **Durable fix (skill + scorer):** Two complementary gates:
+ 1. **Skill Phase 4.x diff check:** for every `client.Get(<path>, params)` (or equivalent for POST bodies) the printed CLI invokes in its sync path, locate the matching entry in `research.json` / browser-sniff `traffic-analysis.json` for the same path. If the captured query set is a strict superset of what the code passes, fail the phase with the dropped keys named in the diagnostic. Acceptance: a Factor75-shaped diff (5 of 11 params used) flags loudly.
+ 2. **Scorer dogfood enrichment (`comp:scorer`):** Add a `personalization_param_drop` check that compares the live CLI's outgoing request URLs (capturable via the existing live-dogfood request log) to the sniff capture for the same endpoint. Same diff, different layer. Catches drift introduced after the spec was authored.
+- **Test:**
+ - positive: fixture spec where browser-sniff captured `GET /menu?a=1&b=2&c=3&d=4` and the generated/authored sync code calls `GET /menu?a=1&b=2` — flag with "dropped params: c, d on /menu".
+ - positive: same path called with extra params not in the capture (`a,b,c,d,e`) — no flag.
+ - negative: path that doesn't appear in the sniff capture (transcendence-only synthetic call) — no flag.
+- **Evidence:** `~/printing-press/library/factor75/internal/syncer/syncer.go:102-112` (pre-fix, 5 params); browser-sniff network log row for `/gw/my-deliveries/menu` shows 11 params at request time; comparison against the user's website-rendered W22 cart shows distinct meals = 11 vs CLI store = 3.
+
+### F2. `looksLikeJWT` accepts short tracking cookies; `auth login --chrome` saves them silently
+
+- **What happened:** `factor75-pp-cli auth login --chrome` reported `OK Extracted JWT from cookie jar for factor75.com` and saved a 31-character string (`01KRPVRYA2SNQT9BAGD6984WAG_.tt.1`) as the access token. That string is a Cloudflare visitor/session cookie that happens to have three dot-separated base64url segments. Every subsequent API call returned HTTP 401 with `"invalid character 'Ó' looking for beginning of value"` — the gateway tried to JSON-decode the JWT payload and got binary garbage.
+- **Scorer correct?** N/A — this is generator-emitted runtime code, not scored content.
+- **Root cause:** `internal/cli/auth.go::looksLikeJWT` (lines 824-846 in the Factor75 printed CLI, but this is generator-emitted template code) validates a JWT by checking segment count (3) and per-segment charset (base64url). It does not validate minimum length. A real Auth0 RS256 access token is 800+ characters; a Cloudflare `__cf_bm`-shaped cookie can be 30. Both pass the heuristic.
+- **Cross-API check:** Every sniffed CLI emitted from the `auth login --chrome` (cookie-jar) template inherits this `looksLikeJWT`. The bug fires whenever the target domain's cookie jar contains any value with 3 base64url segments — Cloudflare bot-manager cookies (`__cf_bm`), some Mixpanel distinct IDs, segment-style A/B test identifiers, etc. Tightly bounded by which sites front their auth with Cloudflare, but that's a large fraction of consumer SaaS today.
+- **Frequency:** subclass `false-positive-jwt-shape`. Any Cloudflare-fronted site (Factor75 here; likely Substack, Reddit-adjacent, many SaaS consumer endpoints). The CF cookie is set even when the user *is* authenticated, so this isn't restricted to "unauthenticated" scrapes.
+- **Fallback if Printing Press doesn't fix:** the user gets a confusing 401 chain that points at "Token is expired" or "invalid character" decode errors and has to manually inspect `config.toml` to discover the saved "JWT" is 31 chars long.
+- **Worth a Printing Press fix?** Yes — the heuristic is the gate; tightening it costs nothing.
+- **Inherent or fixable:** Fixable. RS256/HS256 Auth0/OIDC tokens have predictable minimum sizes: the header alone is typically 36+ chars after base64url, the payload 100+, signature 256+ for RS256. A floor of total length ≥ 150 chars with header segment ≥ 20 chars eliminates every observed false-positive without rejecting legitimate JWTs (smallest realistic JWT seen in the wild ≈ 200 chars total).
+- **Durable fix (template):** In `internal/cli/auth.go::looksLikeJWT` template:
+ - Reject if total length < 150 chars
+ - Reject if header segment < 20 chars
+ - Keep current segment-count + charset checks
+ - Emit a more specific user-facing diagnostic when the rejection is "looks like a JWT but is too short" — "this looks like a Cloudflare or tracking cookie, not a JWT; check the site's auth flow."
+- **Test:**
+ - positive: real Auth0 RS256 access token (800+ chars) accepted
+ - positive: minimal HS256 JWT with realistic 30-char header / 80-char payload / 40-char signature (~150 chars) accepted
+ - negative: `01KRPVRYA2SNQT9BAGD6984WAG_.tt.1` rejected with the "too short" reason
+ - negative: `__cf_bm=AAA.BBB.CCC` shape rejected
+- **Evidence:** `/Users/noway/.config/factor75-pp-cli/config.toml` post-`auth login --chrome` contained the short cookie verbatim; `find` in Chrome localStorage for the same domain showed no JWT-shaped key (confirming no localStorage path either, see F3).
+
+### F3. `auth login --chrome` cannot reach Auth0 SPA SDK tokens stored in-memory
+
+- **What happened:** Factor75 uses the Auth0 SPA SDK with the `memory` cache option — the access token lives in JavaScript heap memory and is injected into each XHR via an HTTP interceptor. It is **not** in the cookie jar (only short-lived session/CSRF cookies are) and it is **not** in localStorage (we confirmed by enumerating every key on factor75.com). `auth login --chrome` shells out to `pycookiecheat`/`cookies`, both of which are cookie-SQLite readers; neither has any path to in-memory state or localStorage. Net effect: no working Chrome-driven auth import for any Auth0-SPA-SDK-on-`memory` site. The workaround is to copy-paste the `Authorization: Bearer ...` value from DevTools Network tab.
+- **Scorer correct?** N/A.
+- **Root cause:** The `auth login --chrome` template only knows one strategy (cookie jar → `findJWTInCookies`). It has no fallback for the (increasingly common) case where the SPA SDK holds the token in JS-memory. There's also no detector that warns "the captured network log shows `/oauth/token` calls but the site exposes no JWT-shaped cookie" — that signature would predict the gap before the user hits it.
+- **Cross-API check:** Affects every site using Auth0 SPA SDK v2+ with `cacheLocation: 'memory'` (which is the SDK default since v2.0). Factor75 / HelloFresh family is one example. Anecdotally many B2C SaaS use this pattern post-2024 because `cacheLocation: 'localstorage'` was deprecated as XSS-unsafe. The localStorage variant would at least be readable through a different extractor.
+- **Frequency:** subclass `auth0-spa-in-memory`. Bounded to Auth0 SPA SDK users but that's a meaningful slice of B2C SaaS.
+- **Fallback if Printing Press doesn't fix:** Manual DevTools paste each time the token expires (every 30 minutes for Factor75). The user also needs to know how to edit `config.toml` directly because no `auth set-token <jwt>` subcommand exists.
+- **Worth a Printing Press fix?** Yes — both for the import path *and* for the missing escape hatch.
+- **Inherent or fixable:** Two-part fixable.
+ - **Detection (sniff-time):** if the browser-sniff capture has `/oauth/token` calls and the response carries `access_token` but no JWT-shaped cookie is observed in the response Set-Cookie headers, mark the site as `auth0_spa_in_memory: true` in the spec / research.json. This routes the generator to emit a different `auth login` flow.
+ - **Runtime extraction:** spawn Chrome via CDP with `--remote-debugging-port`, attach to an open factor75.com tab, execute `window.<auth0SdkVariable>.getTokenSilently()` from page context to extract the token. Falls back gracefully when the SDK variable isn't pinnable. Alternatively, install a service-worker-style fetch interceptor via CDP `Fetch.enable` and grab the next outbound Authorization header.
+ - **Escape hatch:** a built-in `auth set-token <jwt>` subcommand so the manual paste flow doesn't require editing config.toml. Validates against `looksLikeJWT` (post-F2 fix). Trivial to add to the auth template.
+- **Test:**
+ - positive: fixture sniff capture with `/oauth/token` + `cacheLocation: memory` Auth0 SDK signature → spec carries `auth.subtype: auth0_spa_in_memory` and generated `auth login --chrome --auth0-spa` uses CDP path.
+ - positive: `auth set-token <real-jwt>` saves and validates; subsequent `doctor` passes.
+ - negative: `auth set-token <31-char-cookie>` is rejected by `looksLikeJWT` floor (F2's fix).
+- **Evidence:** factor75.com localStorage enumeration showed 50+ keys, zero matching `/auth|token|jwt|bearer/i`; cookie jar contained only Cloudflare/CSRF cookies (no JWT shape); the user's manual workflow (paste from DevTools Network → Authorization header → manually edit `config.toml`) was the only path that worked.
+
+## Frequency rollup
+
+- **F1:** subclass `personalization-param-drop` — every CLI whose hand-authored sync targets a personalization-aware list/menu endpoint. Tight enough not to require a generator change, broad enough to need a skill-level gate.
+- **F2:** subclass `false-positive-jwt-shape` — every CLI using the `auth login --chrome` cookie-jar template against a Cloudflare-fronted site (large fraction).
+- **F3:** subclass `auth0-spa-in-memory` — every Auth0 SPA SDK v2+ site with default `cacheLocation: memory` (growing).
+
+## Related printed-CLI fix (already shipped in this change)
+
+`~/printing-press/library/factor75/internal/syncer/syncer.go`:
+- Added `DeliveryOption.Handle` to `apiDelivery` struct.
+- Per-week `menuParams` now sources `subscription`/`product-sku` from the current week's delivery (not the primary), and adds `servings` and `delivery-option`.
+- Test added: `TestSyncForwardsPersonalizationParamsToMenuEndpoint`.
+
+The `customerPlanId`/`postcode`/`preference` params are still missing; verifying whether they're additionally required will need a fresh JWT round trip. If the 4-param-added shape still returns the generic preselect, a follow-up will introduce a `/gw/v1/profile/me` call to source those.
+
+## Labels (intent — apply on file)
+
+`retro`, `priority:P2` (dominant), `comp:skill` (F1's primary gate), `comp:generator` (F2 + F3 template work), `comp:scorer` (F1's scorer-side enrichment).
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3d26ebfb..7387aade 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1464,6 +1464,8 @@ func (g *Generator) renderSingleFiles() error {
"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
"cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
+ "cliutil_jwtshape.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape.go"),
+ "cliutil_jwtshape_test.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape_test.go"),
"cliutil_test.go.tmpl": filepath.Join("internal", "cliutil", "cliutil_test.go"),
"cobratree/walker.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "walker.go"),
"cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
@@ -1755,6 +1757,8 @@ func (g *Generator) GenerateMCPSurface() error {
"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
"cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
+ "cliutil_jwtshape.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape.go"),
+ "cliutil_jwtshape_test.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape_test.go"),
}
for tmplName, outPath := range mcpFiles {
if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 8588ec01..65cca4e3 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -59,6 +59,8 @@ func TestGenerateProjectsCompile(t *testing.T) {
"internal/cliutil/verifyenv.go",
"internal/cliutil/extractnumber.go",
"internal/cliutil/extractnumber_test.go",
+ "internal/cliutil/jwtshape.go",
+ "internal/cliutil/jwtshape_test.go",
"internal/cliutil/cliutil_test.go",
"internal/client/client.go",
"internal/client/client_test.go",
@@ -81,9 +83,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
// Bump it AND add to mustInclude above when adding always-emitted
// templates. Per-spec dynamic files (per-resource command files,
// generated tests) account for the difference between fixtures.
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 60},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 65},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 62},
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 62},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 67},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 64},
}
for _, tt := range tests {
@@ -153,6 +155,8 @@ func TestGenerateCliutilPackage(t *testing.T) {
{"text.go", "func SanitizeErrorBody("},
{"extractnumber.go", "func ExtractNumber("},
{"extractnumber.go", "func ExtractInt("},
+ {"jwtshape.go", "func LooksLikeJWT("},
+ {"jwtshape.go", "func FindJWTInCookieJar("},
} {
data, err := os.ReadFile(filepath.Join(cliutilDir, probe.file))
require.NoError(t, err)
diff --git a/internal/generator/templates/cliutil_jwtshape.go.tmpl b/internal/generator/templates/cliutil_jwtshape.go.tmpl
new file mode 100644
index 00000000..51af7ffb
--- /dev/null
+++ b/internal/generator/templates/cliutil_jwtshape.go.tmpl
@@ -0,0 +1,96 @@
+// 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 cliutil
+
+import "strings"
+
+// JWT shape floors — empirically chosen.
+//
+// Real-world Auth0 / Cognito / Firebase / Supabase RS256 access tokens are
+// 600–1200 chars. The smallest plausible legitimate HS256 JWT (header ~30
+// chars, minimal claims ~80 chars, signature ~40 chars) is around 150 chars
+// total with a header segment ≥ 20 chars.
+//
+// Anti-targets: Cloudflare Bot Management cookies (`__cf_bm`, ~30 chars), some
+// Mixpanel distinct IDs, segment-style A/B test identifiers — all of which
+// happen to have three dot-separated base64url chunks but contain no JWT
+// payload. Without a length floor these slip past a segment-count + charset
+// check and get saved as access tokens, with the failure mode of a confusing
+// HTTP 401 chain on every subsequent API call.
+const (
+ minJWTTotalLen = 150
+ minJWTHeaderLen = 20
+)
+
+// LooksLikeJWT reports whether s is shaped like a JWT — three base64url
+// segments separated by dots, with a length floor that filters out short
+// tracking cookies and CSRF tokens that share the segment shape.
+//
+// The function is permissive about a leading `Bearer ` prefix (it strips it
+// before measuring) so callers can pass either the raw token or the wire
+// value of an Authorization header.
+//
+// This is a shape check, not a signature verification. Callers that need to
+// know whether a token is currently valid for a specific audience should
+// decode it via the standard JWT library; this helper exists for the
+// upstream gate — "should we even attempt to save this string as a
+// credential" — that runs before the token ever reaches the API.
+func LooksLikeJWT(s string) bool {
+ s = strings.TrimSpace(s)
+ s = strings.TrimPrefix(s, "Bearer ")
+ if len(s) < minJWTTotalLen {
+ return false
+ }
+ parts := strings.Split(s, ".")
+ if len(parts) != 3 {
+ return false
+ }
+ if len(parts[0]) < minJWTHeaderLen {
+ return false
+ }
+ for _, p := range parts {
+ if p == "" {
+ return false
+ }
+ for _, r := range p {
+ isAlnum := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
+ if !isAlnum && r != '-' && r != '_' && r != '=' {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// FindJWTInCookieJar scans a "name=value; name=value; ..." cookie jar string
+// for the first value that satisfies LooksLikeJWT, returning "" when nothing
+// matches. Useful when a CLI's auth flow extracts a browser cookie jar and
+// the underlying API actually expects a Bearer JWT (which some sites surface
+// as a cookie alongside their session cookies).
+//
+// The length floor in LooksLikeJWT does the heavy lifting here — without it,
+// jar scans on Cloudflare-fronted sites trip on `__cf_bm` and similar.
+//
+// The returned value is the bare token (any leading `Bearer ` is stripped),
+// matching LooksLikeJWT's input normalization. A caller that builds an
+// Authorization header from the result therefore prepends `Bearer ` exactly
+// once; passing the value through verbatim does not produce a double prefix
+// even when the cookie's value carries the Authorization wire form.
+func FindJWTInCookieJar(jar string) string {
+ for _, raw := range strings.Split(jar, ";") {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ eq := strings.IndexByte(raw, '=')
+ if eq < 0 {
+ continue
+ }
+ value := strings.TrimPrefix(strings.TrimSpace(raw[eq+1:]), "Bearer ")
+ if LooksLikeJWT(value) {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/internal/generator/templates/cliutil_jwtshape_test.go.tmpl b/internal/generator/templates/cliutil_jwtshape_test.go.tmpl
new file mode 100644
index 00000000..62c06a90
--- /dev/null
+++ b/internal/generator/templates/cliutil_jwtshape_test.go.tmpl
@@ -0,0 +1,128 @@
+// 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 cliutil
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLooksLikeJWT(t *testing.T) {
+ t.Parallel()
+
+ // realAuth0RS256 is a realistically-sized RS256 token (header ~64 chars,
+ // payload ~430 chars, signature ~344 chars). Synthetic — not a credential.
+ realAuth0RS256 := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFvNi1PYzAyQlZHTF9GbnBDeE5JMiJ9." +
+ strings.Repeat("a", 430) + "." + strings.Repeat("b", 344)
+
+ // Exact boundary fixtures. The floor is 150 chars total; these straddle it.
+ // atFloor149: h×36 + "." + p×72 + "." + s×39 = 36+1+72+1+39 = 149 — must reject.
+ // atFloor150: h×36 + "." + p×72 + "." + s×40 = 36+1+72+1+40 = 150 — must accept.
+ // A constant change (150 -> 155) or an off-by-one (< vs <=) would flip one
+ // of these and trip the test.
+ atFloor149 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 72) + "." + strings.Repeat("s", 39)
+ atFloor150 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 72) + "." + strings.Repeat("s", 40)
+
+ // minimalHS256 is a realistic minimum-sized JWT (~158 chars). The exact
+ // boundary cases above test the floor; this case exists so a regression
+ // that breaks normal-sized small JWTs surfaces independently.
+ minimalHS256 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 80) + "." + strings.Repeat("s", 40)
+
+ // Factor75 false-positive — Cloudflare-shaped cookie value with 3
+ // base64url segments and 31 chars total. The original looksLikeJWT
+ // heuristic accepted this; the length-floored shape check rejects it.
+ cfCookieShaped := "01KRPVRYA2SNQT9BAGD6984WAG_.tt.1"
+
+ cases := []struct {
+ name string
+ in string
+ want bool
+ }{
+ {"real Auth0 RS256 token", realAuth0RS256, true},
+ {"exactly at 150-char floor", atFloor150, true},
+ {"realistic minimum HS256", minimalHS256, true},
+ {"with Bearer prefix", "Bearer " + realAuth0RS256, true},
+
+ {"exactly one char under floor (149)", atFloor149, false},
+ {"factor75 Cloudflare cookie", cfCookieShaped, false},
+ {"empty string", "", false},
+ {"whitespace only", " \t\n", false},
+ {"single segment", strings.Repeat("a", 200), false},
+ {"two segments", strings.Repeat("a", 100) + "." + strings.Repeat("b", 100), false},
+ {"four segments", "aaa.bbb.ccc.ddd", false},
+ {"empty middle segment", strings.Repeat("a", 80) + ".." + strings.Repeat("b", 80), false},
+ {"header segment too short", "aaa." + strings.Repeat("p", 80) + "." + strings.Repeat("s", 80), false},
+ {"invalid charset", strings.Repeat("a", 60) + ".pay!load." + strings.Repeat("s", 80), false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := LooksLikeJWT(tc.in)
+ if got != tc.want {
+ t.Fatalf("LooksLikeJWT(%q) = %v, want %v", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindJWTInCookieJar(t *testing.T) {
+ t.Parallel()
+
+ realJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFvNi1PYzAyQlZHTF9GbnBDeE5JMiJ9." +
+ strings.Repeat("a", 430) + "." + strings.Repeat("b", 344)
+
+ cases := []struct {
+ name string
+ jar string
+ want string
+ }{
+ {
+ name: "JWT alongside CF tracking cookies",
+ jar: "__cf_bm=01KRPVRYA2SNQT9BAGD6984WAG_.tt.1; auth_token=" + realJWT + "; csrf=abc123",
+ want: realJWT,
+ },
+ {
+ // A cookie whose value carries the Authorization wire form
+ // ("Bearer eyJ...") must not double-prefix downstream. The
+ // returned value should be the bare token so callers building
+ // an Authorization header always prepend "Bearer " exactly once.
+ name: "cookie value carries Bearer prefix",
+ jar: "auth=Bearer " + realJWT + "; csrf=abc",
+ want: realJWT,
+ },
+ {
+ name: "no JWT, only short shaped cookies",
+ jar: "__cf_bm=01KRPVRYA2SNQT9BAGD6984WAG_.tt.1; _ga=GA1.1.123.456",
+ want: "",
+ },
+ {
+ name: "empty jar",
+ jar: "",
+ want: "",
+ },
+ {
+ name: "single JWT cookie",
+ jar: "token=" + realJWT,
+ want: realJWT,
+ },
+ {
+ name: "malformed cookie without equals",
+ jar: "notacookie; " + realJWT,
+ want: "",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := FindJWTInCookieJar(tc.jar)
+ if got != tc.want {
+ t.Fatalf("FindJWTInCookieJar(...) returned %d-char string; want %d-char", len(got), len(tc.want))
+ }
+ })
+ }
+}
← ed17d302 fix(cli): gate ship plans on agent readiness (#1638)
·
back to Cli Printing Press
·
fix(skills): reprint discovers and carries prior patches (#1 f9bb82c8 →