Skip to content

Commit f728de2

Browse files
authored
fix(hover): reuse warm browser profile session
Copies persisted Chrome profile cookies into the Go client before credential login, probes /api/domains to detect a valid warm Hover session, and falls back to the existing password/TOTP flow when the profile is not authenticated. Bumps plugin.json to 0.5.6.
1 parent 776ac4c commit f728de2

4 files changed

Lines changed: 177 additions & 11 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ require an in-page flow run in the same browser instance.
2929
`UpdateRecord`, `DeleteRecord`) run in the browser where the full Imperva
3030
context is live.
3131

32-
The session is considered stale after 1 hour; a fresh browser login fires
33-
automatically on the next operation.
32+
The in-process session is considered stale after 1 hour. Across process or CI
33+
runs, the plugin first checks whether the persistent browser profile is still
34+
authenticated by copying profile cookies into the Go client and probing
35+
`/api/domains`; a fresh browser login fires only when that probe is not
36+
authenticated.
3437

3538
## Chrome acquisition
3639

@@ -108,8 +111,10 @@ ${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile
108111
```
109112

110113
Override via `browser_profile_dir` or `HOVER_BROWSER_PROFILE_DIR`. A warm
111-
profile skips re-authentication against Imperva on subsequent runs and allows
112-
the plugin to work even after a TOTP secret is no longer available.
114+
profile is probed before credential login; when Hover still accepts the cached
115+
session, the plugin skips the password/TOTP flow on subsequent runs. This
116+
reduces repeated login traffic and allows the plugin to work even after a TOTP
117+
secret is no longer available.
113118

114119
## Required secrets
115120

pkg/hoverclient/browser_backend.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ func (b *browserBackend) signinHost() string {
8383
// ---------------------------------------------------------------------------
8484

8585
// Login drives Chrome to:
86-
// 1. Navigate to /signin (Imperva JS runs, clearance cookies are minted).
87-
// 2. Wait for Imperva clearance cookies.
88-
// 3. Submit credentials via in-page fetch (same-origin XHR path).
89-
// 4. Handle TOTP 2FA if required.
90-
// 5. Copy all browser cookies into c.http.Jar for the hybrid HTTP reads.
91-
// 6. Set c.loggedAt.
86+
// 1. Launch Chrome with the configured persistent profile.
87+
// 2. Copy existing profile cookies into c.http.Jar and probe /api/domains.
88+
// 3. If the probe is authenticated, reuse the warm session without login.
89+
// 4. Otherwise navigate to /signin (Imperva JS runs, clearance cookies are minted).
90+
// 5. Wait for Imperva clearance cookies.
91+
// 6. Submit credentials via in-page fetch (same-origin XHR path).
92+
// 7. Handle TOTP 2FA if required.
93+
// 8. Copy all browser cookies into c.http.Jar for the hybrid HTTP reads.
94+
// 9. Set c.loggedAt.
9295
func (b *browserBackend) Login(ctx context.Context, c *Client) error {
9396
c.mu.Lock()
9497
alreadyFresh := !c.loggedAt.IsZero() && time.Since(c.loggedAt) < sessionStaleAfter
@@ -131,6 +134,18 @@ func (b *browserBackend) Login(ctx context.Context, c *Client) error {
131134
b.browser = browser
132135
b.launcher = l
133136

137+
if err := b.handOffCookies(browser, c); err != nil {
138+
return fmt.Errorf("hover browser login: warm cookie handoff: %w", err)
139+
}
140+
if ok, err := b.probeExistingSession(ctx, c); err != nil {
141+
return fmt.Errorf("hover browser login: warm session probe: %w", err)
142+
} else if ok {
143+
c.mu.Lock()
144+
c.loggedAt = time.Now()
145+
c.mu.Unlock()
146+
return nil
147+
}
148+
134149
page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
135150
if err != nil {
136151
return fmt.Errorf("hover browser login: new page: %w", err)
@@ -281,6 +296,31 @@ func (b *browserBackend) syncJarCookiesToBrowser(ctx context.Context, c *Client)
281296
return nil
282297
}
283298

299+
func (b *browserBackend) probeExistingSession(ctx context.Context, c *Client) (bool, error) {
300+
endpoint := b.signinHost() + "/api/domains"
301+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
302+
if err != nil {
303+
return false, err
304+
}
305+
req.Header.Set("Accept", "application/json")
306+
req.Header.Set("User-Agent", c.UserAgent)
307+
resp, err := c.do(req)
308+
if err != nil {
309+
return false, nil
310+
}
311+
defer resp.Body.Close()
312+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
313+
return false, nil
314+
}
315+
var body struct {
316+
Succeeded bool `json:"succeeded"`
317+
}
318+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
319+
return false, nil
320+
}
321+
return body.Succeeded, nil
322+
}
323+
284324
// ---------------------------------------------------------------------------
285325
// READ operations — delegate to the HTTP backend after ensuring login.
286326
// ---------------------------------------------------------------------------

pkg/hoverclient/browser_backend_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"strings"
1919
"testing"
2020
"time"
21+
22+
"github.com/GoCodeAlone/rod/lib/proto"
2123
)
2224

2325
// --------------------------------------------------------------------------
@@ -513,6 +515,125 @@ func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) {
513515
}
514516
}
515517

518+
func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) {
519+
opts := newBrowserTestOpts(t)
520+
521+
var signinHits, authHits, domainsHits int
522+
mux := http.NewServeMux()
523+
mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) {
524+
domainsHits++
525+
if _, err := r.Cookie("__uzma"); err != nil {
526+
w.WriteHeader(http.StatusUnauthorized)
527+
_, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`))
528+
return
529+
}
530+
w.Header().Set("Content-Type", "application/json")
531+
_ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "domains": []map[string]any{}})
532+
})
533+
mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) {
534+
signinHits++
535+
http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"})
536+
_, _ = w.Write([]byte(`<html><body>signin</body></html>`))
537+
})
538+
mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) {
539+
authHits++
540+
w.Header().Set("Content-Type", "application/json")
541+
_ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"})
542+
})
543+
544+
srv := httptest.NewServer(mux)
545+
t.Cleanup(srv.Close)
546+
seedBrowserProfileCookie(t, opts, srv.URL, "__uzma", "warm-session")
547+
548+
creds := Credentials{Username: "alice", Password: "pw"}
549+
c := newBrowserClient(t, opts, srv.URL, creds)
550+
t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() })
551+
552+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
553+
defer cancel()
554+
555+
if err := c.Login(ctx); err != nil {
556+
t.Fatalf("Login with warm profile: %v", err)
557+
}
558+
if signinHits != 0 || authHits != 0 {
559+
t.Fatalf("warm profile should skip credential login; signinHits=%d authHits=%d", signinHits, authHits)
560+
}
561+
if domainsHits == 0 {
562+
t.Fatal("warm profile login did not probe /api/domains")
563+
}
564+
c.mu.Lock()
565+
loggedAt := c.loggedAt
566+
c.mu.Unlock()
567+
if loggedAt.IsZero() {
568+
t.Fatal("loggedAt not set after warm profile reuse")
569+
}
570+
}
571+
572+
func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing.T) {
573+
opts := newBrowserTestOpts(t)
574+
575+
var signinHits, authHits, domainsHits int
576+
mux := http.NewServeMux()
577+
mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) {
578+
domainsHits++
579+
w.WriteHeader(http.StatusUnauthorized)
580+
_, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`))
581+
})
582+
mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) {
583+
signinHits++
584+
http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"})
585+
_, _ = w.Write([]byte(`<html><body>signin</body></html>`))
586+
})
587+
mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) {
588+
authHits++
589+
w.Header().Set("Content-Type", "application/json")
590+
_ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"})
591+
})
592+
593+
srv := httptest.NewServer(mux)
594+
t.Cleanup(srv.Close)
595+
596+
creds := Credentials{Username: "alice", Password: "pw"}
597+
c := newBrowserClient(t, opts, srv.URL, creds)
598+
t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() })
599+
600+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
601+
defer cancel()
602+
603+
if err := c.Login(ctx); err != nil {
604+
t.Fatalf("Login after stale warm profile probe: %v", err)
605+
}
606+
if domainsHits == 0 {
607+
t.Fatal("stale profile login did not probe /api/domains before fallback")
608+
}
609+
if signinHits == 0 || authHits == 0 {
610+
t.Fatalf("stale profile should fall back to credential login; signinHits=%d authHits=%d", signinHits, authHits)
611+
}
612+
}
613+
614+
func seedBrowserProfileCookie(t *testing.T, opts BrowserOptions, baseURL, name, value string) {
615+
t.Helper()
616+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
617+
defer cancel()
618+
619+
browser, launcher, err := launchBrowserWithHandles(ctx, opts)
620+
if err != nil {
621+
t.Fatalf("launch browser to seed profile cookie: %v", err)
622+
}
623+
defer launcher.Kill()
624+
defer func() { _ = browser.Close() }()
625+
626+
if err := browser.Context(ctx).SetCookies([]*proto.NetworkCookieParam{{
627+
Name: name,
628+
Value: value,
629+
URL: baseURL,
630+
Path: "/",
631+
Expires: proto.TimeSinceEpoch(time.Now().Add(2 * time.Hour).Unix()),
632+
}}); err != nil {
633+
t.Fatalf("seed browser profile cookie: %v", err)
634+
}
635+
}
636+
516637
// TestBrowserBackend_OverrideHostRespected is a compile-time check that
517638
// overrideHost field exists on browserBackend (used by all local tests above).
518639
func TestBrowserBackend_OverrideHostRespected(_ *testing.T) {

plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "workflow-plugin-hover",
3-
"version": "0.5.5",
3+
"version": "0.5.6",
44
"description": "Hover DNS provider (browser-style login + TOTP, no official SDK)",
55
"author": "GoCodeAlone",
66
"license": "MIT",

0 commit comments

Comments
 (0)