[object Object]

← back to Cli Printing Press

feat(cli): detect Auth0 SPA in-memory auth + emit CDP extractor (#1645)

015478bfe179f2d7bfd26e850466c800e7f71540 · 2026-05-19 17:12:40 -0500 · Nick Walker

* feat(cli): detect Auth0 SPA in-memory auth at sniff time and emit CDP extractor

Some sites (Auth0 SPA SDK v2+ with cacheLocation: memory) keep the access
token in JS heap and never write it to cookies or localStorage. The
cookie-jar extractor in auth login --chrome has no path to those tokens —
DevTools paste was the only way to land them in config until WU-3a (#1641)
added auth set-token.

This change closes the automation gap. Sniff-time detection looks for
/oauth/token responses that carry access_token in the body without a
JWT-shaped Set-Cookie on the same response, and annotates the spec with
auth.subtype: auth0_spa_in_memory. The generator routes that subtype to
auth_browser.go.tmpl, which emits an --auth0-spa flag on auth login --chrome.
The CDP path attaches to a running Chrome at --debug-port, installs a
Fetch.enable interceptor via chromedp, and captures the next outbound
Authorization: Bearer JWT — validated via cliutil.LooksLikeJWT before
SaveTokens.

Side-effect-command floor applies: the CDP path short-circuits when
cliutil.IsVerifyEnv() or cliutil.IsDogfoodEnv() is set, so the verifier
matrix and dogfood --live runs never attempt a real Chrome attach.

Refs #1598 (WU-3b).

* fix(cli): reset auth0 spa token expiry

---------

Co-authored-by: Cathryn Lavery <cathryn@bestself.co>

Files touched

Diff

commit 015478bfe179f2d7bfd26e850466c800e7f71540
Author: Nick Walker <nick@stormforge.io>
Date:   Tue May 19 17:12:40 2026 -0500

    feat(cli): detect Auth0 SPA in-memory auth + emit CDP extractor (#1645)
    
    * feat(cli): detect Auth0 SPA in-memory auth at sniff time and emit CDP extractor
    
    Some sites (Auth0 SPA SDK v2+ with cacheLocation: memory) keep the access
    token in JS heap and never write it to cookies or localStorage. The
    cookie-jar extractor in auth login --chrome has no path to those tokens —
    DevTools paste was the only way to land them in config until WU-3a (#1641)
    added auth set-token.
    
    This change closes the automation gap. Sniff-time detection looks for
    /oauth/token responses that carry access_token in the body without a
    JWT-shaped Set-Cookie on the same response, and annotates the spec with
    auth.subtype: auth0_spa_in_memory. The generator routes that subtype to
    auth_browser.go.tmpl, which emits an --auth0-spa flag on auth login --chrome.
    The CDP path attaches to a running Chrome at --debug-port, installs a
    Fetch.enable interceptor via chromedp, and captures the next outbound
    Authorization: Bearer JWT — validated via cliutil.LooksLikeJWT before
    SaveTokens.
    
    Side-effect-command floor applies: the CDP path short-circuits when
    cliutil.IsVerifyEnv() or cliutil.IsDogfoodEnv() is set, so the verifier
    matrix and dogfood --live runs never attempt a real Chrome attach.
    
    Refs #1598 (WU-3b).
    
    * fix(cli): reset auth0 spa token expiry
    
    ---------
    
    Co-authored-by: Cathryn Lavery <cathryn@bestself.co>
---
 docs/SPEC-EXTENSIONS.md                           |  42 +++++
 internal/browsersniff/analysis.go                 |  24 ++-
 internal/browsersniff/auth0_spa.go                | 171 +++++++++++++++++
 internal/browsersniff/auth0_spa_test.go           | 220 ++++++++++++++++++++++
 internal/browsersniff/specgen.go                  |  14 +-
 internal/generator/auth0_spa_test.go              | 118 ++++++++++++
 internal/generator/generator.go                   |  10 +-
 internal/generator/plan_generate.go               |   1 +
 internal/generator/templates/auth_browser.go.tmpl | 198 +++++++++++++++++++
 internal/generator/templates/go.mod.tmpl          |   4 +
 internal/openapi/parser.go                        |  12 ++
 internal/openapi/parser_test.go                   |  64 +++++++
 internal/spec/spec.go                             |  40 +++-
 internal/spec/spec_test.go                        |  39 ++++
 14 files changed, 947 insertions(+), 10 deletions(-)

diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 60c76770..965a88d2 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -29,6 +29,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-auth-key-url` | `components.securitySchemes.<name>` | `APISpec.Auth.KeyURL` | No |
 | `x-auth-title` | `components.securitySchemes.<name>` | `APISpec.Auth.Title` | No |
 | `x-auth-description` | `components.securitySchemes.<name>` | `APISpec.Auth.Description` | No |
+| `x-auth-subtype` | `components.securitySchemes.<name>` | `APISpec.Auth.Subtype` | 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 |
@@ -514,6 +515,47 @@ components:
       x-auth-description: Optional FlightAware AeroAPI credential for enriched flight data.
 ```
 
+### `x-auth-subtype`
+
+Refines `Auth.Type` for runtime flows that need a different credential-capture
+path than the base type implies. Today the only recognized value is
+`auth0_spa_in_memory`: a bearer-token spec whose access token is held by the
+Auth0 SPA SDK with `cacheLocation: memory`. Cookie/localStorage extractors have
+no path to such a token (it lives in JS heap only), so the generator emits a
+`--auth0-spa` flag on `auth login --chrome` that drives a Chrome DevTools
+Protocol outbound-Authorization interceptor instead.
+
+Parsed field: `APISpec.Auth.Subtype`
+
+Rules:
+
+- Optional.
+- Must be a string.
+- Recognized values: `auth0_spa_in_memory`. Other values are silently dropped
+  by the parser; the in-spec value never round-trips unless it matches a known
+  subtype.
+- Spec-level validation rejects `auth.subtype: auth0_spa_in_memory` paired with
+  any non-empty `auth.type` other than `bearer_token`. Auth0 SPA tokens are
+  always Authorization-bearer values; combining the subtype with `api_key` or
+  `cookie` would silently emit a CDP path against a credential that isn't a
+  JWT.
+- Sniff-time detection (`internal/browsersniff/auth0_spa.go`) sets the subtype
+  automatically when an `/oauth/token` response carries `access_token` in the
+  JSON body without a JWT-shaped Set-Cookie on the same response. Authors
+  rarely need to set it by hand.
+
+Example:
+
+```yaml
+components:
+  securitySchemes:
+    BearerAuth:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+      x-auth-subtype: auth0_spa_in_memory
+```
+
 ### `x-auth-cookie-domain`
 
 Domain used when extracting named cookies for composed auth.
diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 3cdab09d..e3bb3644 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -225,6 +225,16 @@ type ProtocolObservation struct {
 
 type AuthAnalysis struct {
 	Candidates []AuthCandidate `json:"candidates,omitempty"`
+
+	// Auth0SPAInMemory is true when an /oauth/token response carried an
+	// `access_token` in the JSON body without a JWT-shaped Set-Cookie on the
+	// same response — the signature of an Auth0 SPA SDK deployment using
+	// `cacheLocation: memory`. The token lives in JS heap and is reachable
+	// only via CDP runtime interception, so the spec carries
+	// `auth.subtype: auth0_spa_in_memory` and the generated `auth login`
+	// command exposes a `--auth0-spa` CDP path instead of cookie-jar
+	// extraction. See internal/browsersniff/auth0_spa.go.
+	Auth0SPAInMemory bool `json:"auth0_spa_in_memory,omitempty"`
 }
 
 // UnmarshalJSON accepts v2-shape `auth.candidate_types: ["api_key", "none"]`
@@ -233,13 +243,15 @@ type AuthAnalysis struct {
 // as `{type: <s>, confidence: 1.0}`. See issue #474.
 func (a *AuthAnalysis) UnmarshalJSON(data []byte) error {
 	var legacy struct {
-		Candidates     []AuthCandidate `json:"candidates,omitempty"`
-		CandidateTypes []string        `json:"candidate_types,omitempty"`
+		Candidates       []AuthCandidate `json:"candidates,omitempty"`
+		CandidateTypes   []string        `json:"candidate_types,omitempty"`
+		Auth0SPAInMemory bool            `json:"auth0_spa_in_memory,omitempty"`
 	}
 	if err := json.Unmarshal(data, &legacy); err != nil {
 		return err
 	}
 	a.Candidates = legacy.Candidates
+	a.Auth0SPAInMemory = legacy.Auth0SPAInMemory
 	if len(a.Candidates) == 0 && len(legacy.CandidateTypes) > 0 {
 		a.Candidates = make([]AuthCandidate, 0, len(legacy.CandidateTypes))
 		for _, t := range legacy.CandidateTypes {
@@ -808,7 +820,10 @@ func detectTrafficAuth(capture *EnrichedCapture, entries []EnrichedEntry) AuthAn
 		}
 		return out[i].Confidence > out[j].Confidence
 	})
-	return AuthAnalysis{Candidates: out}
+	return AuthAnalysis{
+		Candidates:       out,
+		Auth0SPAInMemory: detectAuth0SPAInMemory(entries),
+	}
 }
 
 func detectProtections(entries []EnrichedEntry) []ProtectionObservation {
@@ -1418,6 +1433,9 @@ func deriveGenerationHints(analysis *TrafficAnalysis) []string {
 			hints["requires_browser_auth"] = true
 		}
 	}
+	if analysis.Auth.Auth0SPAInMemory {
+		hints["auth0_spa_in_memory"] = true
+	}
 	for _, warning := range analysis.Warnings {
 		if warning.Type == "weak_schema_evidence" || warning.Type == "raw_protocol_envelope" {
 			hints["weak_schema_confidence"] = true
diff --git a/internal/browsersniff/auth0_spa.go b/internal/browsersniff/auth0_spa.go
new file mode 100644
index 00000000..3ee4001a
--- /dev/null
+++ b/internal/browsersniff/auth0_spa.go
@@ -0,0 +1,171 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+
+package browsersniff
+
+import (
+	"encoding/json"
+	"strings"
+)
+
+// detectAuth0SPAInMemory reports whether the captured traffic looks like an
+// Auth0 SPA SDK deployment using the default in-memory cache
+// (`cacheLocation: memory`). The signature: at least one `/oauth/token`
+// response carries an `access_token` value in the JSON body, and no
+// JWT-shaped Set-Cookie header is observed on the same response.
+//
+// Tokens that match this shape live in JS heap only — never in cookies,
+// never in localStorage — so the cookie-jar extractor in the generated
+// `auth login --chrome` flow has no path to them. The detection feeds a
+// spec annotation (auth.subtype: "auth0_spa_in_memory") that routes the
+// generator to emit a CDP-based runtime extractor instead.
+//
+// The check is intentionally conservative: a missing JWT-shaped Set-Cookie
+// alone is not enough (most APIs don't set credential cookies on
+// /oauth/token even when they aren't Auth0 SPA), so the signal requires the
+// access_token to actually appear in the body. The JWT cookie absence rules
+// out the rarer "we put the access token in a cookie too" pattern, which
+// the existing cookie-jar extractor already handles.
+func detectAuth0SPAInMemory(entries []EnrichedEntry) bool {
+	for _, entry := range entries {
+		if !isOAuthTokenPath(entry.URL) {
+			continue
+		}
+		if !responseBodyCarriesAccessToken(entry.ResponseBody) {
+			continue
+		}
+		if responseHasJWTShapedSetCookie(entry.ResponseHeaders) {
+			// Tokens are also being persisted to a cookie; the existing
+			// cookie-jar flow can reach them. Not an in-memory case.
+			continue
+		}
+		return true
+	}
+	return false
+}
+
+// isOAuthTokenPath matches the Auth0 token endpoint path. Auth0 SPA SDK
+// always POSTs to `/oauth/token` against the tenant domain; other OAuth
+// providers (Google, Microsoft, GitHub) use the same path shape, which is
+// fine — the cookie-vs-in-memory question is the same regardless of
+// vendor. We match on the path suffix to ignore tenant-shaped hostnames
+// like `dev-xyz.us.auth0.com`.
+func isOAuthTokenPath(rawURL string) bool {
+	// A bare URL or HAR-recorded URL; the path may or may not have a query
+	// string. Split on the first '?' before checking the suffix so
+	// `/oauth/token?...` still matches.
+	pathOnly := rawURL
+	if i := strings.IndexByte(pathOnly, '?'); i >= 0 {
+		pathOnly = pathOnly[:i]
+	}
+	pathOnly = strings.TrimRight(pathOnly, "/")
+	return strings.HasSuffix(pathOnly, "/oauth/token")
+}
+
+// responseBodyCarriesAccessToken decodes the body as JSON and looks for a
+// non-empty `access_token` field. Falls back to a substring check when JSON
+// decoding fails so partial/HAR-truncated captures still trip the detector
+// — the substring `"access_token"` followed by a colon is specific enough
+// that false positives on non-OAuth responses are vanishingly unlikely.
+func responseBodyCarriesAccessToken(body string) bool {
+	body = strings.TrimSpace(body)
+	if body == "" {
+		return false
+	}
+	var decoded map[string]any
+	if err := json.Unmarshal([]byte(body), &decoded); err == nil {
+		if v, ok := decoded["access_token"]; ok {
+			if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
+				return true
+			}
+		}
+		return false
+	}
+	// JSON decode failed — fall back to a textual marker. Require the key
+	// in JSON-shaped quotes followed by a value indicator (`":`) so prose
+	// mentions of "access_token" in HTML error pages don't match.
+	return strings.Contains(body, `"access_token":`)
+}
+
+// responseHasJWTShapedSetCookie scans Set-Cookie headers for a value whose
+// cookie value (the bit after the first `=`) looks like a JWT — three
+// base64url segments separated by dots, with a minimum length that rules
+// out short tracking cookies like Cloudflare's `__cf_bm`.
+//
+// EnrichedEntry stores headers as a `map[string]string`. Multi-valued
+// Set-Cookie headers (the common case for token issuance responses) are
+// folded by the HAR parser into a single comma-separated string, which we
+// re-split here. This is a heuristic — both `,` and `, ` are legal inside
+// a cookie value's `Expires=...` attribute, so we look at each comma-split
+// piece and accept the result whenever any piece's value half satisfies
+// the JWT shape check. The opposite failure mode (false positive) is
+// preferable to the false negative: a false positive only blocks the
+// auth0-spa-in-memory subtype from firing, leaving the cookie-jar path
+// intact.
+func responseHasJWTShapedSetCookie(headers map[string]string) bool {
+	for name, value := range headers {
+		if !strings.EqualFold(name, "Set-Cookie") {
+			continue
+		}
+		// Split on comma to handle the HAR-folded multi-header form.
+		for raw := range strings.SplitSeq(value, ",") {
+			raw = strings.TrimSpace(raw)
+			if raw == "" {
+				continue
+			}
+			// The cookie name=value pair is the part before the first `;`.
+			pair := raw
+			if i := strings.IndexByte(pair, ';'); i >= 0 {
+				pair = pair[:i]
+			}
+			_, cookieValue, ok := strings.Cut(pair, "=")
+			if !ok {
+				continue
+			}
+			cookieValue = strings.TrimSpace(cookieValue)
+			if looksLikeJWTShape(cookieValue) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// looksLikeJWTShape is the sniff-time mirror of cliutil.LooksLikeJWT (the
+// runtime helper emitted into every printed CLI). Lives here as a copy
+// because the generator package can't import the template-side runtime
+// helper at compile time. Length and segment-shape values match the
+// runtime gate so a token that fails this check would also fail in the
+// generated CLI — keeping the detection symmetric.
+//
+// See internal/generator/templates/cliutil_jwtshape.go.tmpl for the
+// canonical version and the empirical-floor rationale.
+func looksLikeJWTShape(s string) bool {
+	s = strings.TrimSpace(s)
+	s = strings.TrimPrefix(s, "Bearer ")
+	const (
+		minTotal  = 150
+		minHeader = 20
+	)
+	if len(s) < minTotal {
+		return false
+	}
+	parts := strings.Split(s, ".")
+	if len(parts) != 3 {
+		return false
+	}
+	if len(parts[0]) < minHeader {
+		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
+}
diff --git a/internal/browsersniff/auth0_spa_test.go b/internal/browsersniff/auth0_spa_test.go
new file mode 100644
index 00000000..4ae13589
--- /dev/null
+++ b/internal/browsersniff/auth0_spa_test.go
@@ -0,0 +1,220 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+
+package browsersniff
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// syntheticJWT builds a JWT-shaped string (3 base64url segments separated by
+// dots, header ≥ 20 chars, total ≥ 150 chars) so the JWT-shape gate fires.
+// Real signature math is irrelevant; we only test the shape check.
+func syntheticJWT() string {
+	header := strings.Repeat("a", 32)               // > minHeader
+	payload := strings.Repeat("b", 80)              // pad payload
+	signature := strings.Repeat("c", 50)            // pad signature
+	return header + "." + payload + "." + signature // total > minTotal
+}
+
+// TestDetectAuth0SPAInMemory_Positive — oauth/token response with
+// access_token in the body and no JWT-shaped Set-Cookie on the same
+// response. Detector must fire.
+func TestDetectAuth0SPAInMemory_Positive(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://example.auth0.com/oauth/token",
+			ResponseBody: `{"access_token":"abc.def.ghi","token_type":"Bearer","expires_in":3600}`,
+			ResponseHeaders: map[string]string{
+				"Set-Cookie": "__cf_bm=short; Path=/; HttpOnly",
+			},
+		},
+	}
+	assert.True(t, detectAuth0SPAInMemory(entries),
+		"oauth/token with access_token body and no JWT-shaped Set-Cookie should trip Auth0 SPA detector")
+}
+
+// TestDetectAuth0SPAInMemory_JWTCookiePresent — same shape but the
+// response also sets a JWT-shaped cookie. Detector must NOT fire because
+// the existing cookie-jar extractor can reach the token.
+func TestDetectAuth0SPAInMemory_JWTCookiePresent(t *testing.T) {
+	t.Parallel()
+
+	jwt := syntheticJWT()
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://example.auth0.com/oauth/token",
+			ResponseBody: `{"access_token":"abc.def.ghi","token_type":"Bearer","expires_in":3600}`,
+			ResponseHeaders: map[string]string{
+				"Set-Cookie": "session_token=" + jwt + "; Path=/; HttpOnly",
+			},
+		},
+	}
+	assert.False(t, detectAuth0SPAInMemory(entries),
+		"JWT-shaped Set-Cookie on the same response should suppress the in-memory subtype")
+}
+
+// TestDetectAuth0SPAInMemory_NoTokenCall — capture has no oauth/token
+// call at all. Detector must NOT fire (returns absent-case).
+func TestDetectAuth0SPAInMemory_NoTokenCall(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method: "GET",
+			URL:    "https://api.example.com/users",
+			ResponseHeaders: map[string]string{
+				"Content-Type": "application/json",
+			},
+			ResponseBody: `{"items": []}`,
+		},
+		{
+			Method: "GET",
+			URL:    "https://api.example.com/orders",
+			ResponseHeaders: map[string]string{
+				"Content-Type": "application/json",
+			},
+			ResponseBody: `{"items": [{"id":1}]}`,
+		},
+	}
+	assert.False(t, detectAuth0SPAInMemory(entries),
+		"capture without oauth/token calls should not trip the detector")
+}
+
+// TestDetectAuth0SPAInMemory_TokenCallNoAccessToken — has oauth/token but
+// body lacks access_token (e.g. error response). Detector must NOT fire.
+func TestDetectAuth0SPAInMemory_TokenCallNoAccessToken(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://example.auth0.com/oauth/token",
+			ResponseBody: `{"error":"invalid_grant","error_description":"refresh token expired"}`,
+		},
+	}
+	assert.False(t, detectAuth0SPAInMemory(entries),
+		"oauth/token without access_token in body should not trip the detector")
+}
+
+// TestDetectAuth0SPAInMemory_PathWithQueryString — oauth/token URL with
+// a query string still matches the suffix check.
+func TestDetectAuth0SPAInMemory_PathWithQueryString(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://auth.example.com/oauth/token?audience=api.example.com",
+			ResponseBody: `{"access_token":"abc.def.ghi","token_type":"Bearer"}`,
+		},
+	}
+	assert.True(t, detectAuth0SPAInMemory(entries),
+		"oauth/token with a query string should still match the path suffix")
+}
+
+// TestDetectAuth0SPAInMemory_PartialJSONFallback — body is technically
+// invalid JSON (truncated capture) but contains the access_token key.
+// Substring fallback must still trip the detector.
+func TestDetectAuth0SPAInMemory_PartialJSONFallback(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://example.auth0.com/oauth/token",
+			ResponseBody: `{"access_token":"abc.def.ghi","token_type":"Bear`, // truncated
+		},
+	}
+	assert.True(t, detectAuth0SPAInMemory(entries),
+		"truncated body with access_token key should still trip via the substring fallback")
+}
+
+// TestAuth0SPADetector_SetsAuthSubtype — full path: traffic with the
+// signature → AuthAnalysis.Auth0SPAInMemory true and TrafficAnalysis
+// carries the auth0_spa_in_memory generation hint.
+func TestAuth0SPADetector_SetsAuthSubtype(t *testing.T) {
+	t.Parallel()
+
+	capture := &EnrichedCapture{
+		TargetURL: "https://app.example.com",
+		Entries: []EnrichedEntry{
+			{
+				Method:       "POST",
+				URL:          "https://example.auth0.com/oauth/token",
+				ResponseBody: `{"access_token":"x.y.z","token_type":"Bearer"}`,
+			},
+			{
+				Method: "GET",
+				URL:    "https://api.example.com/v1/me",
+				RequestHeaders: map[string]string{
+					"Authorization": "Bearer abc.def.ghi",
+				},
+			},
+		},
+	}
+
+	analysis, err := AnalyzeTraffic(capture)
+	if err != nil {
+		t.Fatalf("AnalyzeTraffic: %v", err)
+	}
+	assert.True(t, analysis.Auth.Auth0SPAInMemory,
+		"AuthAnalysis should mark Auth0 SPA in-memory when the signature trips")
+	assert.Contains(t, analysis.GenerationHints, "auth0_spa_in_memory",
+		"GenerationHints should carry the auth0_spa_in_memory marker")
+}
+
+// TestSpecgenDetectAuth_AppliesAuth0SPASubtype — the specgen detectAuth
+// wrapper must annotate bearer_token auth with the auth0_spa_in_memory
+// subtype when the detector fires.
+func TestSpecgenDetectAuth_AppliesAuth0SPASubtype(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method:       "POST",
+			URL:          "https://example.auth0.com/oauth/token",
+			ResponseBody: `{"access_token":"abc.def.ghi","token_type":"Bearer"}`,
+		},
+		{
+			Method: "GET",
+			URL:    "https://api.example.com/v1/me",
+			RequestHeaders: map[string]string{
+				"Authorization": "Bearer abc.def.ghi",
+			},
+		},
+	}
+	auth := detectAuth(nil, entries, "example")
+	assert.Equal(t, "bearer_token", auth.Type)
+	assert.Equal(t, spec.AuthSubtypeAuth0SPAInMemory, auth.Subtype,
+		"detectAuth should annotate bearer_token auth with the auth0_spa_in_memory subtype")
+}
+
+// TestSpecgenDetectAuth_NoSubtypeWithoutSignature — bearer_token traffic
+// without the oauth/token + no-JWT-cookie signature must not get the
+// subtype annotation.
+func TestSpecgenDetectAuth_NoSubtypeWithoutSignature(t *testing.T) {
+	t.Parallel()
+
+	entries := []EnrichedEntry{
+		{
+			Method: "GET",
+			URL:    "https://api.example.com/v1/me",
+			RequestHeaders: map[string]string{
+				"Authorization": "Bearer abc.def.ghi",
+			},
+		},
+	}
+	auth := detectAuth(nil, entries, "example")
+	assert.Equal(t, "bearer_token", auth.Type)
+	assert.Empty(t, auth.Subtype,
+		"detectAuth must not annotate bearer_token auth without the in-memory signature")
+}
diff --git a/internal/browsersniff/specgen.go b/internal/browsersniff/specgen.go
index fcac05a9..edf08baa 100644
--- a/internal/browsersniff/specgen.go
+++ b/internal/browsersniff/specgen.go
@@ -939,9 +939,17 @@ func inferURLParams(entries []EnrichedEntry, normalizedPath string) []spec.Param
 
 func detectAuth(capture *EnrichedCapture, entries []EnrichedEntry, name string) spec.AuthConfig {
 	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+	auth0SPA := detectAuth0SPAInMemory(entries)
 	if capture != nil && capture.Auth != nil {
 		auth := detectCapturedAuth(capture.Auth, envPrefix)
 		if auth.Type != "" {
+			// Capture-derived bearer auth with an Auth0-SPA-in-memory signature
+			// gets the subtype annotation so the generator routes to the CDP
+			// extractor; other auth shapes (cookie, composed, api_key) are
+			// reached by their own extractor paths and don't need the hint.
+			if auth0SPA && auth.Type == "bearer_token" {
+				auth.Subtype = spec.AuthSubtypeAuth0SPAInMemory
+			}
 			return auth
 		}
 	}
@@ -951,11 +959,15 @@ func detectAuth(capture *EnrichedCapture, entries []EnrichedEntry, name string)
 			lowerHeader := strings.ToLower(headerName)
 			switch {
 			case strings.EqualFold(headerName, "Authorization") && strings.HasPrefix(strings.TrimSpace(value), "Bearer "):
-				return spec.AuthConfig{
+				auth := spec.AuthConfig{
 					Type:    "bearer_token",
 					Header:  "Authorization",
 					EnvVars: envVarsOrNil(envPrefix, "TOKEN"),
 				}
+				if auth0SPA {
+					auth.Subtype = spec.AuthSubtypeAuth0SPAInMemory
+				}
+				return auth
 			case strings.Contains(lowerHeader, "api-key") || strings.Contains(lowerHeader, "api_key"):
 				return spec.AuthConfig{
 					Type:    "api_key",
diff --git a/internal/generator/auth0_spa_test.go b/internal/generator/auth0_spa_test.go
new file mode 100644
index 00000000..0a2265f0
--- /dev/null
+++ b/internal/generator/auth0_spa_test.go
@@ -0,0 +1,118 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+
+package generator
+
+import (
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// TestGenerateAuth0SPAEmitsCDPLoginCmd verifies that the auth_browser
+// template emits the `--auth0-spa` CDP code path when Auth.Subtype is
+// auth0_spa_in_memory. Covers the WU-3b acceptance criterion:
+//
+//	spec carries auth.subtype: auth0_spa_in_memory AND
+//	auth login --chrome --auth0-spa invokes the CDP path.
+func TestGenerateAuth0SPAEmitsCDPLoginCmd(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("factor75")
+	apiSpec.BaseURL = "https://api.example.com"
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Subtype: spec.AuthSubtypeAuth0SPAInMemory,
+		Header:  "Authorization",
+		EnvVars: []string{"FACTOR75_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "factor75-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	authGo := readGeneratedFile(t, outputDir, "internal", "cli", "auth.go")
+
+	// CDP code path must be emitted.
+	assert.Contains(t, authGo, "captureAuth0SPABearer(",
+		"auth.go should define the CDP capture helper for the auth0_spa_in_memory subtype")
+	assert.Contains(t, authGo, "chromedp.NewRemoteAllocator",
+		"auth.go should attach to a running Chrome via the remote allocator")
+	assert.Contains(t, authGo, "fetch.Enable()",
+		"auth.go should enable the Fetch domain for outbound interception")
+	assert.Contains(t, authGo, "EventRequestPaused",
+		"auth.go should listen for paused requests to read the Authorization header")
+
+	// --auth0-spa flag must be wired.
+	assert.Contains(t, authGo, `"auth0-spa"`,
+		"auth.go should register the --auth0-spa flag on `auth login`")
+	assert.Contains(t, authGo, `"chrome"`,
+		"auth.go should still register the --chrome flag")
+
+	// Side-effect-command floor: verify-env + dogfood-env short-circuits.
+	assert.Contains(t, authGo, "cliutil.IsVerifyEnv()",
+		"CDP login must short-circuit under PRINTING_PRESS_VERIFY=1")
+	assert.Contains(t, authGo, "cliutil.IsDogfoodEnv()",
+		"CDP login must short-circuit under PRINTING_PRESS_DOGFOOD=1")
+
+	// JWT shape gate before saving.
+	assert.Contains(t, authGo, "cliutil.LooksLikeJWT(",
+		"captured Authorization must be JWT-shape-validated before SaveTokens")
+
+	// go.mod must include chromedp dependencies for this subtype.
+	goMod := readGeneratedFile(t, outputDir, "go.mod")
+	assert.Contains(t, goMod, "github.com/chromedp/chromedp",
+		"go.mod should require chromedp for the auth0_spa_in_memory subtype")
+	assert.Contains(t, goMod, "github.com/chromedp/cdproto",
+		"go.mod should require cdproto for the fetch.* event types")
+
+	// Build check: confirm the emitted CLI compiles and `auth login
+	// --chrome --auth0-spa --help` exits 0. Pulls chromedp via `go mod
+	// tidy`, then builds the binary and probes the help surface.
+	if testing.Short() {
+		t.Skip("skipping build check in -short mode (downloads chromedp)")
+	}
+	runGoCommand(t, outputDir, "mod", "tidy")
+	binPath := filepath.Join(outputDir, "factor75-pp-cli")
+	runGoCommand(t, outputDir, "build", "-o", binPath, "./cmd/factor75-pp-cli")
+	out, err := exec.Command(binPath, "auth", "login", "--chrome", "--auth0-spa", "--help").CombinedOutput()
+	require.NoError(t, err, "`auth login --chrome --auth0-spa --help` failed: %s", string(out))
+	assert.Contains(t, string(out), "--auth0-spa",
+		"`--help` should list the --auth0-spa flag")
+	assert.Contains(t, string(out), "CDP",
+		"`--help` should mention the CDP capture path so users understand what --auth0-spa does")
+}
+
+// TestGenerateBearerTokenWithoutSubtypeUsesSimpleTemplate ensures that a
+// plain bearer_token spec (no Auth0 SPA subtype) still routes to the
+// auth_simple template — the CDP path is opt-in by detection, not the
+// default for bearer_token auth.
+func TestGenerateBearerTokenWithoutSubtypeUsesSimpleTemplate(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("vanilla")
+	apiSpec.BaseURL = "https://api.example.com"
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"VANILLA_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "vanilla-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	authGo := readGeneratedFile(t, outputDir, "internal", "cli", "auth.go")
+	assert.NotContains(t, authGo, "captureAuth0SPABearer",
+		"plain bearer_token spec must NOT emit the CDP helper")
+	assert.NotContains(t, authGo, "chromedp.NewRemoteAllocator",
+		"plain bearer_token spec must NOT import or call chromedp")
+	assert.NotContains(t, authGo, "--auth0-spa",
+		"plain bearer_token spec must NOT register the --auth0-spa flag")
+
+	goMod := readGeneratedFile(t, outputDir, "go.mod")
+	assert.NotContains(t, goMod, "github.com/chromedp",
+		"go.mod for a plain bearer_token spec must not pull in chromedp")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 14115ffe..3786fdbb 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1941,11 +1941,11 @@ func (g *Generator) renderAuthFiles() error {
 		authTmpl = "auth_client_credentials.go.tmpl"
 	case g.Spec.Auth.AuthorizationURL != "":
 		authTmpl = "auth.go.tmpl"
-	case g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query"):
-		// Browser-aware auth template for browser-cookie auth or a
-		// persisted-query registry, even for auth.type:none. Query refresh
-		// flows need temporary browser capture support, not a resident
-		// browser transport.
+	case g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query") || g.Spec.Auth.Subtype == spec.AuthSubtypeAuth0SPAInMemory:
+		// Browser-aware auth template for browser-cookie auth, a
+		// persisted-query registry, or an Auth0-SPA-in-memory bearer token
+		// (CDP runtime extraction). Query refresh flows need temporary
+		// browser capture support, not a resident browser transport.
 		authTmpl = "auth_browser.go.tmpl"
 	}
 	authData := &authTemplateData{
diff --git a/internal/generator/plan_generate.go b/internal/generator/plan_generate.go
index 58f9d639..121ec08a 100644
--- a/internal/generator/plan_generate.go
+++ b/internal/generator/plan_generate.go
@@ -48,6 +48,7 @@ type planGoModData struct {
 	CLIName   string
 	VisionSet struct{ Store, MCP bool }
 	Config    struct{ Format string }
+	Auth      struct{ Subtype string }
 }
 
 func (planGoModData) UsesBrowserHTTPTransport() bool {
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index c882be3c..26930ae1 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -2,6 +2,7 @@
 {{- $hasQueryRefresh := .HasGraphQLPersistedQueries -}}
 {{- $hasBrowserCapture := or $hasBrowserRefresh $hasQueryRefresh -}}
 {{- $hasCookieBrowserAuth := or (eq .Auth.Type "cookie") (eq .Auth.Type "composed") -}}
+{{- $hasAuth0SPA := eq .Auth.Subtype "auth0_spa_in_memory" -}}
 {{- $canonicalEnvVar := .Auth.CanonicalEnvVar -}}
 // 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.
@@ -15,22 +16,31 @@ import (
 {{- if or $hasCookieBrowserAuth $hasQueryRefresh}}
 	"bytes"
 {{- end}}
+{{- if $hasAuth0SPA}}
+	"context"
+{{- end}}
 {{- if .Auth.RequiresBrowserSession}}
 	"crypto/sha256"
 	"encoding/hex"
 {{- end}}
+{{- if or $hasCookieBrowserAuth $hasBrowserCapture}}
 	"encoding/json"
+{{- end}}
 {{- if $hasBrowserCapture}}
 	"errors"
 {{- end}}
 	"fmt"
+{{- if or $hasCookieBrowserAuth $hasBrowserCapture $hasAuth0SPA}}
 	"io"
+{{- end}}
 {{- if $hasCookieBrowserAuth}}
 	"net/http"
 {{- end}}
 	"os"
+{{- if or $hasCookieBrowserAuth $hasBrowserCapture}}
 	"os/exec"
 	"path/filepath"
+{{- end}}
 {{- if or $hasCookieBrowserAuth $hasBrowserRefresh}}
 	"runtime"
 {{- end}}
@@ -47,6 +57,10 @@ import (
 	"{{modulePath}}/internal/cliutil"
 {{- end}}
 	"{{modulePath}}/internal/config"
+{{- if $hasAuth0SPA}}
+	"github.com/chromedp/cdproto/fetch"
+	"github.com/chromedp/chromedp"
+{{- end}}
 	"github.com/spf13/cobra"
 )
 
@@ -334,6 +348,190 @@ profile by name when the installed backend supports it.`,
 	cmd.Flags().StringVar(&profileFlag, "profile", "", "Chrome profile name (e.g. \"Work\", \"Personal\")")
 	return cmd
 }
+{{- else if $hasAuth0SPA}}
+// newAuthLoginCmd for Auth0-SPA-in-memory bearer tokens: drives a Chrome
+// DevTools Protocol session that intercepts the next outbound request and
+// captures its `Authorization: Bearer ...` header. The cookie-jar extractor
+// has no path to this token — the Auth0 SPA SDK keeps it in JS heap with
+// `cacheLocation: memory` and injects it into every XHR via an interceptor.
+//
+// The `--auth0-spa` flag opts into the CDP path explicitly because attaching
+// to a running Chrome is a heavier action than reading the cookie DB. Without
+// the flag, `auth login --chrome` prints help pointing at it.
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+	var browserFlag bool
+	var auth0SPA bool
+	var debugPort int
+	var targetOrigin string
+	var captureTimeout time.Duration
+
+	cmd := &cobra.Command{
+		Use:   "login",
+		Short: "Capture an in-memory bearer JWT from a running Chrome via CDP",
+		Long: `Capture an in-memory bearer JWT from a running Chrome via CDP.
+
+This API's access token lives in the Auth0 SPA SDK's in-memory cache. It is
+not in cookies and not in localStorage, so the cookie-jar extractor has no
+path. Use --auth0-spa to attach to a running Chrome via the DevTools
+Protocol, intercept the next outbound API request, and read the bearer JWT
+from its Authorization header.
+
+First start Chrome with remote debugging enabled, log in to the target site,
+then run:
+
+  ` + "`{{.Name}}-pp-cli auth login --chrome --auth0-spa`" + `
+
+You can also paste the token directly with ` + "`{{.Name}}-pp-cli auth set-token <jwt>`" + `
+when the CDP path is not available (other machine, headless server, etc.).`,
+		Example: `  # Start Chrome with remote debugging
+  google-chrome --remote-debugging-port=9222
+
+  # Log in to the site, then capture the JWT
+  {{.Name}}-pp-cli auth login --chrome --auth0-spa
+
+  # Use a non-default debug port or restrict to a specific origin
+  {{.Name}}-pp-cli auth login --chrome --auth0-spa --debug-port 9223 --origin https://app.example.com`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if !browserFlag {
+				fmt.Fprintln(cmd.OutOrStdout(), "Use --chrome (or --browser) with --auth0-spa to capture the in-memory bearer JWT:")
+				fmt.Fprintf(cmd.OutOrStdout(), "  {{.Name}}-pp-cli auth login --chrome --auth0-spa\n")
+				return nil
+			}
+			if !auth0SPA {
+				fmt.Fprintln(cmd.OutOrStdout(), "This API uses Auth0 SPA in-memory tokens. Add --auth0-spa to capture via CDP,")
+				fmt.Fprintln(cmd.OutOrStdout(), "or paste the bearer JWT directly with `auth set-token <jwt>`.")
+				return nil
+			}
+
+			w := cmd.OutOrStdout()
+
+			// Honor verify-env (mock subprocess) and dogfood-env (live API
+			// matrix) per the side-effect-command rule. CDP attach launches
+			// real Chrome activity — explode-prone in both environments.
+			if cliutil.IsVerifyEnv() {
+				fmt.Fprintln(w, "PRINTING_PRESS_VERIFY=1; skipping Chrome CDP attach.")
+				return nil
+			}
+			if cliutil.IsDogfoodEnv() {
+				fmt.Fprintln(w, "PRINTING_PRESS_DOGFOOD=1; skipping Chrome CDP attach.")
+				return nil
+			}
+
+			token, err := captureAuth0SPABearer(cmd.Context(), debugPort, targetOrigin, captureTimeout, w)
+			if err != nil {
+				fmt.Fprintln(w, red("Could not capture a bearer from Chrome via CDP."))
+				fmt.Fprintln(w, "")
+				fmt.Fprintln(w, "Make sure Chrome is running with remote debugging enabled:")
+				fmt.Fprintf(w, "\n  google-chrome --remote-debugging-port=%d\n\n", debugPort)
+				fmt.Fprintln(w, "Then log in to the site and trigger an authenticated request (any data load)")
+				fmt.Fprintln(w, "before re-running this command. If CDP attach keeps failing, fall back to:")
+				fmt.Fprintf(w, "\n  {{.Name}}-pp-cli auth set-token <jwt>\n")
+				return authErr(fmt.Errorf("capturing auth0-spa bearer via CDP: %w", err))
+			}
+
+			if !cliutil.LooksLikeJWT(token) {
+				return authErr(fmt.Errorf("captured Authorization value is not JWT-shaped (got %d chars); refusing to save", len(token)))
+			}
+
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			cfg.AuthHeaderVal = ""
+			if err := cfg.SaveTokens("", "", token, "", time.Time{}); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+
+			fmt.Fprintf(w, "%s Captured bearer JWT (%d chars) from Chrome via CDP.\n", green("OK"), len(token))
+			fmt.Fprintf(w, "Token saved to %s\n", cfg.Path)
+			return nil
+		},
+	}
+
+	cmd.Flags().BoolVar(&browserFlag, "chrome", false, "Attach to a running Chrome via CDP")
+	cmd.Flags().BoolVar(&browserFlag, "browser", false, "Alias for --chrome")
+	cmd.Flags().BoolVar(&auth0SPA, "auth0-spa", false, "Use CDP to capture the in-memory bearer JWT (required for Auth0 SPA in-memory tokens)")
+	cmd.Flags().IntVar(&debugPort, "debug-port", 9222, "Chrome remote-debugging port to attach to")
+	cmd.Flags().StringVar(&targetOrigin, "origin", "", "Restrict interception to requests targeting this origin (e.g. https://api.example.com); empty matches any host")
+	cmd.Flags().DurationVar(&captureTimeout, "timeout", 90*time.Second, "How long to wait for an authenticated outbound request")
+	return cmd
+}
+
+// captureAuth0SPABearer attaches to a running Chrome at ws://localhost:<port>
+// via the DevTools Protocol, installs a Fetch.enable interceptor, and returns
+// the first observed `Authorization: Bearer ...` header value (with the
+// `Bearer ` prefix stripped). When targetOrigin is non-empty, requests whose
+// URL host does not match are continued without interception and ignored.
+//
+// The function never tries to evaluate JS in the page to read SDK state —
+// the Auth0 SPA SDK actively hides the token from window-scope access, and
+// any extraction script would be brittle across SDK versions. Intercepting
+// the outbound header is the SDK's own injection point and is stable.
+func captureAuth0SPABearer(parent context.Context, debugPort int, targetOrigin string, timeout time.Duration, w io.Writer) (string, error) {
+	if timeout <= 0 {
+		timeout = 90 * time.Second
+	}
+	remoteURL := fmt.Sprintf("http://localhost:%d", debugPort)
+	allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(parent, remoteURL)
+	defer cancelAlloc()
+
+	ctx, cancel := chromedp.NewContext(allocCtx)
+	defer cancel()
+
+	ctx, cancelTimeout := context.WithTimeout(ctx, timeout)
+	defer cancelTimeout()
+
+	tokenCh := make(chan string, 1)
+	chromedp.ListenTarget(ctx, func(ev any) {
+		req, ok := ev.(*fetch.EventRequestPaused)
+		if !ok {
+			return
+		}
+		// Continue the request unconditionally so the browser keeps working
+		// while we sniff headers. The continuation runs in a background
+		// goroutine because chromedp.Run requires a writable context and
+		// the listener fires on the dispatch goroutine.
+		defer func() {
+			go func() {
+				_ = chromedp.Run(ctx, fetch.ContinueRequest(req.RequestID))
+			}()
+		}()
+		if targetOrigin != "" && !strings.HasPrefix(req.Request.URL, targetOrigin) {
+			return
+		}
+		for k, v := range req.Request.Headers {
+			if !strings.EqualFold(k, "Authorization") {
+				continue
+			}
+			value, ok := v.(string)
+			if !ok {
+				continue
+			}
+			value = strings.TrimSpace(value)
+			value = strings.TrimPrefix(value, "Bearer ")
+			if value == "" {
+				continue
+			}
+			select {
+			case tokenCh <- value:
+			default:
+			}
+			return
+		}
+	})
+
+	fmt.Fprintf(w, "Attaching to Chrome at %s (waiting up to %s for an authenticated request)...\n", remoteURL, timeout)
+	if err := chromedp.Run(ctx, fetch.Enable()); err != nil {
+		return "", fmt.Errorf("enabling Fetch domain: %w", err)
+	}
+
+	select {
+	case token := <-tokenCh:
+		return token, nil
+	case <-ctx.Done():
+		return "", fmt.Errorf("no Authorization-bearing request observed before timeout: %w", ctx.Err())
+	}
+}
 {{- else}}
 func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
diff --git a/internal/generator/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
index 675a549c..5a86a0b0 100644
--- a/internal/generator/templates/go.mod.tmpl
+++ b/internal/generator/templates/go.mod.tmpl
@@ -5,6 +5,10 @@ go 1.26.3
 require (
 {{- if .UsesBrowserHTTPTransport}}
 	github.com/enetx/surf v1.0.199
+{{- end}}
+{{- if eq .Auth.Subtype "auth0_spa_in_memory"}}
+	github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
+	github.com/chromedp/chromedp v0.15.1
 {{- end}}
 	github.com/spf13/cobra v1.9.1
 {{- if .HasHTMLExtraction}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 795496d6..0192be38 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -39,6 +39,7 @@ const (
 	extensionAuthInstructions      = "x-auth-instructions"
 	extensionAuthTitle             = "x-auth-title"
 	extensionAuthDescription       = "x-auth-description"
+	extensionAuthSubtype           = "x-auth-subtype"
 	extensionOAuthRefreshTokenMech = "x-oauth-refresh-token-mechanism"
 	extensionSpeakeasyExample      = "x-speakeasy-example"
 	extensionTierRouting           = "x-tier-routing"
@@ -926,6 +927,17 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 	if description := stringExtension(extensions, extensionAuthDescription); description != "" {
 		auth.Description = description
 	}
+	if subtype := stringExtension(extensions, extensionAuthSubtype); subtype != "" {
+		// Only known subtype values are accepted. Unknown values would round-trip
+		// silently and surface later as a confusing "subtype %q is not
+		// recognized" validation error far from the spec source. Filtering here
+		// keeps the error close to the typo, matching how unknown auth types are
+		// handled elsewhere in this parser.
+		switch subtype {
+		case spec.AuthSubtypeAuth0SPAInMemory:
+			auth.Subtype = subtype
+		}
+	}
 	if mech := stringExtension(extensions, extensionOAuthRefreshTokenMech); mech != "" {
 		auth.RefreshTokenMechanism = mech
 	}
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 64cbef43..4d128859 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -2832,6 +2832,70 @@ paths:
 	assert.Equal(t, "Optional FlightAware AeroAPI credential for enriched flight data.", parsed.Auth.Description)
 }
 
+// TestOpenAPIAuthSubtype covers the x-auth-subtype extension on a bearer
+// security scheme. The parser accepts the auth0_spa_in_memory value and
+// silently drops unrecognized values (typos surface as the field being
+// empty, not as a confusing later validation error).
+func TestOpenAPIAuthSubtype(t *testing.T) {
+	t.Parallel()
+
+	t.Run("auth0_spa_in_memory round-trips", func(t *testing.T) {
+		t.Parallel()
+		yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: SpaService
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    BearerAuth:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+      x-auth-subtype: auth0_spa_in_memory
+paths:
+  /me:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+		parsed, err := Parse(yamlSpec)
+		require.NoError(t, err)
+		assert.Equal(t, "bearer_token", parsed.Auth.Type)
+		assert.Equal(t, "auth0_spa_in_memory", parsed.Auth.Subtype)
+	})
+
+	t.Run("unknown subtype is dropped", func(t *testing.T) {
+		t.Parallel()
+		yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: SpaService
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    BearerAuth:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+      x-auth-subtype: some_typo_subtype
+paths:
+  /me:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+		parsed, err := Parse(yamlSpec)
+		require.NoError(t, err)
+		assert.Empty(t, parsed.Auth.Subtype,
+			"unknown subtype values should not round-trip")
+	})
+}
+
 func TestOpenAPIAuthKeyURLInference(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 6ba2e960..71d48e9d 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -561,7 +561,8 @@ func (c BearerRefreshConfig) Enabled() bool {
 }
 
 type AuthConfig struct {
-	Type             string       `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
+	Type             string       `yaml:"type" json:"type"`                           // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
+	Subtype          string       `yaml:"subtype,omitempty" json:"subtype,omitempty"` // optional refinement of Type. Currently used for "auth0_spa_in_memory": bearer_token whose JWT lives in JS heap (Auth0 SPA SDK v2+ with cacheLocation: memory) and is reachable only via CDP runtime interception, not via cookie/localStorage extraction. Mirrors x-auth-subtype on the OpenAPI security scheme.
 	Header           string       `yaml:"header" json:"header"`
 	Prefix           string       `yaml:"prefix,omitempty" json:"prefix,omitempty"` // Authorization scheme word (e.g., "Token", "PRIVATE-TOKEN"); empty defaults to "Bearer". Ignored when Format is set.
 	Format           string       `yaml:"format" json:"format"`
@@ -660,6 +661,16 @@ const (
 	RefreshTokenMechanismKindQuery = "query"
 )
 
+// AuthSubtypeAuth0SPAInMemory marks a bearer_token spec whose access token is
+// held in JS heap by the Auth0 SPA SDK (cacheLocation: memory) and is reachable
+// only via Chrome DevTools Protocol runtime interception. The cookie-jar
+// extractor in `auth login --chrome` has no path to such tokens, so the printed
+// CLI emits a `--auth0-spa` flag that drives a CDP-based outbound-Authorization
+// header capture instead. Detected at sniff time when an /oauth/token call
+// returns `access_token` in the JSON body without a JWT-shaped Set-Cookie on
+// the same response (see internal/browsersniff/auth0_spa.go).
+const AuthSubtypeAuth0SPAInMemory = "auth0_spa_in_memory"
+
 // 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".
@@ -970,6 +981,30 @@ func validateAuthPrefix(c AuthConfig) error {
 	return nil
 }
 
+// validateAuthSubtype rejects unrecognized auth.subtype values so authoring
+// typos fail fast rather than silently bypassing the runtime emission. Only
+// auth0_spa_in_memory is recognized today; the field is otherwise expected to
+// be empty.
+func validateAuthSubtype(c AuthConfig) error {
+	if c.Subtype == "" {
+		return nil
+	}
+	switch c.Subtype {
+	case AuthSubtypeAuth0SPAInMemory:
+		// Subtype refines bearer_token; reject the combination if the
+		// underlying type doesn't fit. Auth0 SPA tokens are always
+		// Authorization: Bearer values.
+		if c.Type != "" && c.Type != "bearer_token" {
+			return fmt.Errorf("auth.subtype %q requires auth.type %q (got %q)",
+				c.Subtype, "bearer_token", c.Type)
+		}
+		return nil
+	default:
+		return fmt.Errorf("auth.subtype %q is not recognized (valid: %q)",
+			c.Subtype, AuthSubtypeAuth0SPAInMemory)
+	}
+}
+
 // AuthConfig.Type is intentionally skipped: the field is ignored for
 // non-oauth2 types, matching how SessionTTLHours and similar fields behave.
 func validateOAuth2Grant(c AuthConfig) error {
@@ -2040,6 +2075,9 @@ func (s *APISpec) Validate() error {
 	if err := validateAuthPrefix(s.Auth); err != nil {
 		return err
 	}
+	if err := validateAuthSubtype(s.Auth); err != nil {
+		return err
+	}
 	if err := validateSessionHandshake(s.Auth); err != nil {
 		return err
 	}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 8ae009f4..9dad9e20 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1006,6 +1006,45 @@ func TestAuthPrefixValidate(t *testing.T) {
 	}
 }
 
+func TestAuthSubtypeValidate(t *testing.T) {
+	tests := []struct {
+		name    string
+		auth    AuthConfig
+		wantErr string
+	}{
+		{name: "empty subtype is valid", auth: AuthConfig{Type: "bearer_token"}},
+		{
+			name: "auth0_spa_in_memory with bearer_token is valid",
+			auth: AuthConfig{Type: "bearer_token", Subtype: AuthSubtypeAuth0SPAInMemory},
+		},
+		{
+			name: "auth0_spa_in_memory with empty Type is valid",
+			auth: AuthConfig{Subtype: AuthSubtypeAuth0SPAInMemory},
+		},
+		{
+			name:    "auth0_spa_in_memory with api_key is rejected",
+			auth:    AuthConfig{Type: "api_key", Subtype: AuthSubtypeAuth0SPAInMemory},
+			wantErr: `requires auth.type "bearer_token"`,
+		},
+		{
+			name:    "unknown subtype is rejected",
+			auth:    AuthConfig{Type: "bearer_token", Subtype: "auth0_spa_localstorage"},
+			wantErr: "is not recognized",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateAuthSubtype(tt.auth)
+			if tt.wantErr == "" {
+				require.NoError(t, err)
+				return
+			}
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
 func TestAPISpecValidate_RejectsBadAuthPrefix(t *testing.T) {
 	build := func(prefix string) APISpec {
 		return APISpec{

← 75e4d1b9 fix(cli): verify-mode HTTP-verb short-circuit + N1 envelope  ·  back to Cli Printing Press  ·  feat(cli): add Quo (formerly OpenPhone) catalog entry and Op 1a243810 →