Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
52 changes: 46 additions & 6 deletions pkg/hoverclient/browser_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Comment thread
intel352 marked this conversation as resolved.
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.
// ---------------------------------------------------------------------------
Expand Down
121 changes: 121 additions & 0 deletions pkg/hoverclient/browser_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"strings"
"testing"
"time"

"github.com/GoCodeAlone/rod/lib/proto"
)

// --------------------------------------------------------------------------
Expand Down Expand Up @@ -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 {
Comment thread
intel352 marked this conversation as resolved.
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(`<html><body>signin</body></html>`))
})
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)
Comment thread
intel352 marked this conversation as resolved.
_, _ = 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(`<html><body>signin</body></html>`))
})
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) {
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down