← 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
M docs/SPEC-EXTENSIONS.mdM internal/browsersniff/analysis.goA internal/browsersniff/auth0_spa.goA internal/browsersniff/auth0_spa_test.goM internal/browsersniff/specgen.goA internal/generator/auth0_spa_test.goM internal/generator/generator.goM internal/generator/plan_generate.goM internal/generator/templates/auth_browser.go.tmplM internal/generator/templates/go.mod.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.goM internal/spec/spec_test.go
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 →