From d429543ec78c81a09f643c40f02ed64a3062b55d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 5 Jun 2026 23:37:28 -0400 Subject: [PATCH] fix(hover): reuse warm browser profile session --- README.md | 13 ++- pkg/hoverclient/browser_backend.go | 52 ++++++++-- pkg/hoverclient/browser_backend_test.go | 121 ++++++++++++++++++++++++ plugin.json | 2 +- 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5e2e3c8..d5bc073 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,11 @@ require an in-page flow run in the same browser instance. `UpdateRecord`, `DeleteRecord`) run in the browser where the full Imperva context is live. -The session is considered stale after 1 hour; a fresh browser login fires -automatically on the next operation. +The in-process session is considered stale after 1 hour. Across process or CI +runs, the plugin first checks whether the persistent browser profile is still +authenticated by copying profile cookies into the Go client and probing +`/api/domains`; a fresh browser login fires only when that probe is not +authenticated. ## Chrome acquisition @@ -108,8 +111,10 @@ ${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile ``` Override via `browser_profile_dir` or `HOVER_BROWSER_PROFILE_DIR`. A warm -profile skips re-authentication against Imperva on subsequent runs and allows -the plugin to work even after a TOTP secret is no longer available. +profile is probed before credential login; when Hover still accepts the cached +session, the plugin skips the password/TOTP flow on subsequent runs. This +reduces repeated login traffic and allows the plugin to work even after a TOTP +secret is no longer available. ## Required secrets diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index 5666654..9309734 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -83,12 +83,15 @@ func (b *browserBackend) signinHost() string { // --------------------------------------------------------------------------- // Login drives Chrome to: -// 1. Navigate to /signin (Imperva JS runs, clearance cookies are minted). -// 2. Wait for Imperva clearance cookies. -// 3. Submit credentials via in-page fetch (same-origin XHR path). -// 4. Handle TOTP 2FA if required. -// 5. Copy all browser cookies into c.http.Jar for the hybrid HTTP reads. -// 6. Set c.loggedAt. +// 1. Launch Chrome with the configured persistent profile. +// 2. Copy existing profile cookies into c.http.Jar and probe /api/domains. +// 3. If the probe is authenticated, reuse the warm session without login. +// 4. Otherwise navigate to /signin (Imperva JS runs, clearance cookies are minted). +// 5. Wait for Imperva clearance cookies. +// 6. Submit credentials via in-page fetch (same-origin XHR path). +// 7. Handle TOTP 2FA if required. +// 8. Copy all browser cookies into c.http.Jar for the hybrid HTTP reads. +// 9. Set c.loggedAt. func (b *browserBackend) Login(ctx context.Context, c *Client) error { c.mu.Lock() alreadyFresh := !c.loggedAt.IsZero() && time.Since(c.loggedAt) < sessionStaleAfter @@ -131,6 +134,18 @@ func (b *browserBackend) Login(ctx context.Context, c *Client) error { b.browser = browser b.launcher = l + if err := b.handOffCookies(browser, c); err != nil { + return fmt.Errorf("hover browser login: warm cookie handoff: %w", err) + } + if ok, err := b.probeExistingSession(ctx, c); err != nil { + return fmt.Errorf("hover browser login: warm session probe: %w", err) + } else if ok { + c.mu.Lock() + c.loggedAt = time.Now() + c.mu.Unlock() + return nil + } + page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) if err != nil { return fmt.Errorf("hover browser login: new page: %w", err) @@ -281,6 +296,31 @@ func (b *browserBackend) syncJarCookiesToBrowser(ctx context.Context, c *Client) return nil } +func (b *browserBackend) probeExistingSession(ctx context.Context, c *Client) (bool, error) { + endpoint := b.signinHost() + "/api/domains" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return false, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.do(req) + if err != nil { + return false, nil + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false, nil + } + var body struct { + Succeeded bool `json:"succeeded"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return false, nil + } + return body.Succeeded, nil +} + // --------------------------------------------------------------------------- // READ operations — delegate to the HTTP backend after ensuring login. // --------------------------------------------------------------------------- diff --git a/pkg/hoverclient/browser_backend_test.go b/pkg/hoverclient/browser_backend_test.go index 54fe512..e672e2a 100644 --- a/pkg/hoverclient/browser_backend_test.go +++ b/pkg/hoverclient/browser_backend_test.go @@ -18,6 +18,8 @@ import ( "strings" "testing" "time" + + "github.com/GoCodeAlone/rod/lib/proto" ) // -------------------------------------------------------------------------- @@ -513,6 +515,125 @@ func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) { } } +func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) { + opts := newBrowserTestOpts(t) + + var signinHits, authHits, domainsHits int + mux := http.NewServeMux() + mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { + domainsHits++ + if _, err := r.Cookie("__uzma"); err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "domains": []map[string]any{}}) + }) + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + signinHits++ + http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"}) + _, _ = w.Write([]byte(`signin`)) + }) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + authHits++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + seedBrowserProfileCookie(t, opts, srv.URL, "__uzma", "warm-session") + + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := c.Login(ctx); err != nil { + t.Fatalf("Login with warm profile: %v", err) + } + if signinHits != 0 || authHits != 0 { + t.Fatalf("warm profile should skip credential login; signinHits=%d authHits=%d", signinHits, authHits) + } + if domainsHits == 0 { + t.Fatal("warm profile login did not probe /api/domains") + } + c.mu.Lock() + loggedAt := c.loggedAt + c.mu.Unlock() + if loggedAt.IsZero() { + t.Fatal("loggedAt not set after warm profile reuse") + } +} + +func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing.T) { + opts := newBrowserTestOpts(t) + + var signinHits, authHits, domainsHits int + mux := http.NewServeMux() + mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { + domainsHits++ + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`)) + }) + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + signinHits++ + http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"}) + _, _ = w.Write([]byte(`signin`)) + }) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + authHits++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := c.Login(ctx); err != nil { + t.Fatalf("Login after stale warm profile probe: %v", err) + } + if domainsHits == 0 { + t.Fatal("stale profile login did not probe /api/domains before fallback") + } + if signinHits == 0 || authHits == 0 { + t.Fatalf("stale profile should fall back to credential login; signinHits=%d authHits=%d", signinHits, authHits) + } +} + +func seedBrowserProfileCookie(t *testing.T, opts BrowserOptions, baseURL, name, value string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + browser, launcher, err := launchBrowserWithHandles(ctx, opts) + if err != nil { + t.Fatalf("launch browser to seed profile cookie: %v", err) + } + defer launcher.Kill() + defer func() { _ = browser.Close() }() + + if err := browser.Context(ctx).SetCookies([]*proto.NetworkCookieParam{{ + Name: name, + Value: value, + URL: baseURL, + Path: "/", + Expires: proto.TimeSinceEpoch(time.Now().Add(2 * time.Hour).Unix()), + }}); err != nil { + t.Fatalf("seed browser profile cookie: %v", err) + } +} + // TestBrowserBackend_OverrideHostRespected is a compile-time check that // overrideHost field exists on browserBackend (used by all local tests above). func TestBrowserBackend_OverrideHostRespected(_ *testing.T) { diff --git a/plugin.json b/plugin.json index a0db2f9..9f6593d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-hover", - "version": "0.5.5", + "version": "0.5.6", "description": "Hover DNS provider (browser-style login + TOTP, no official SDK)", "author": "GoCodeAlone", "license": "MIT",