Instantly share code, notes, and snippets.
Last active
July 23, 2026 08:10
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save hackmajoris/69ed4c0109206e2e89f70e0c7f80c620 to your computer and use it in GitHub Desktop.
go-cookies-proxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // cookieproxy mirrors your live browser session onto an API client. | |
| // | |
| // It reads cookies for a given domain out of the local browser cookie store | |
| // and (a) injects them into requests it reverse-proxies to the real host, and | |
| // (b) serves them as JSON so an HTTP client can pick them up directly. | |
| // | |
| // Intended for testing SAML/SSO-protected JSON endpoints from IntelliJ's HTTP | |
| // client, Postman, curl, etc. -- tools that cannot perform the interactive | |
| // SAML redirect flow themselves. | |
| // | |
| // Build: | |
| // | |
| // go build -o cookieproxy | |
| // GOOS=windows GOARCH=amd64 go build -o cookieproxy.exe # cross-compile from WSL | |
| // | |
| // Run: | |
| // | |
| // ./cookieproxy -target https://myapp.example.com | |
| // | |
| // Then, from your HTTP client: | |
| // | |
| // GET http://localhost:8080/api/data | |
| // | |
| // NOTE: the binary must run on the machine and under the user account whose | |
| // browser holds the session -- the cookie store is encrypted with an OS-level | |
| // key tied to that account. Under WSL that means building for Windows and | |
| // running the .exe there, not running it inside WSL. | |
| package main | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "flag" | |
| "log" | |
| "net/http" | |
| "net/http/httputil" | |
| "net/url" | |
| "sort" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/browserutils/kooky" | |
| _ "github.com/browserutils/kooky/browser/all" // registers the browser backends | |
| ) | |
| var ( | |
| target = flag.String("target", "https://myapp.example.com", "upstream base URL to proxy to") | |
| domain = flag.String("domain", "", "cookie domain suffix (defaults to the target's host)") | |
| listen = flag.String("listen", "127.0.0.1:8080", "address for the reverse proxy") | |
| cookieListen = flag.String("cookie-listen", "127.0.0.1:7777", "address for the /cookie JSON endpoint (empty to disable)") | |
| ttl = flag.Duration("ttl", 5*time.Minute, "how long to cache the cookie header before re-reading the store") | |
| verbose = flag.Bool("v", false, "log every proxied request") | |
| ) | |
| // cache holds the assembled Cookie header so we are not decrypting the | |
| // browser's store on every single request -- that is slow and, on macOS, | |
| // can trigger repeated keychain prompts. | |
| type cache struct { | |
| mu sync.Mutex | |
| value string | |
| expiry time.Time | |
| } | |
| var jar cache | |
| // header returns a "name=value; name=value" string suitable for a Cookie | |
| // header, refreshing from the browser store when the cached copy is stale. | |
| func (c *cache) header(cookieDomain string) string { | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| if c.value != "" && time.Now().Before(c.expiry) { | |
| return c.value | |
| } | |
| ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | |
| defer cancel() | |
| cookies, err := kooky.ReadCookies(ctx, | |
| kooky.Valid, // drop expired entries | |
| kooky.DomainHasSuffix(cookieDomain), // only this app's cookies | |
| ) | |
| if err != nil { | |
| // Partial failures are normal: kooky probes paths for browsers that | |
| // are not installed ("Chrome SxS" = Canary, plus Chromium, Brave, | |
| // Edge Dev, ...). Only give up if we actually got nothing back. | |
| // | |
| // A stale cookie is still more useful than none: the request may | |
| // succeed anyway, and the failure mode is a clear 302 rather than a | |
| // confusing empty header. | |
| if len(cookies) == 0 { | |
| log.Printf("cookie read failed (%v); reusing previous value", err) | |
| return c.value | |
| } | |
| log.Printf("cookie read partial, ignoring: %v", err) | |
| } | |
| // Later stores win on name collision; dedupe so we do not send the same | |
| // cookie twice with different values. | |
| seen := make(map[string]string, len(cookies)) | |
| for _, ck := range cookies { | |
| if ck.Name == "" { | |
| continue | |
| } | |
| seen[ck.Name] = ck.Value | |
| } | |
| names := make([]string, 0, len(seen)) | |
| for name := range seen { | |
| names = append(names, name) | |
| } | |
| sort.Strings(names) // stable output makes logs diffable | |
| parts := make([]string, 0, len(names)) | |
| for _, name := range names { | |
| parts = append(parts, name+"="+seen[name]) | |
| } | |
| c.value = strings.Join(parts, "; ") | |
| c.expiry = time.Now().Add(*ttl) | |
| if len(seen) == 0 { | |
| log.Printf("WARNING: found 0 cookies for %q -- is the browser logged in, "+ | |
| "and is this binary running as the right OS user?", cookieDomain) | |
| } else { | |
| log.Printf("refreshed %d cookies for %s: %s", len(seen), cookieDomain, strings.Join(names, ", ")) | |
| } | |
| return c.value | |
| } | |
| func main() { | |
| flag.Parse() | |
| upstream, err := url.Parse(*target) | |
| if err != nil { | |
| log.Fatalf("bad -target %q: %v", *target, err) | |
| } | |
| if upstream.Scheme == "" || upstream.Host == "" { | |
| log.Fatalf("bad -target %q: need a full URL, e.g. https://myapp.example.com", *target) | |
| } | |
| cookieDomain := *domain | |
| if cookieDomain == "" { | |
| cookieDomain = upstream.Hostname() | |
| } | |
| // Warm the cache once at startup so problems surface immediately rather | |
| // than on the first request. | |
| jar.header(cookieDomain) | |
| proxy := httputil.NewSingleHostReverseProxy(upstream) | |
| inner := proxy.Director | |
| proxy.Director = func(r *http.Request) { | |
| inner(r) | |
| // NewSingleHostReverseProxy preserves the inbound Host header, which | |
| // would be "localhost:8080". Gateways in front of SSO-protected apps | |
| // routinely route on Host, so rewrite it. | |
| r.Host = upstream.Host | |
| r.Header.Set("Cookie", jar.header(cookieDomain)) | |
| // Identify ourselves rather than leaking Go's default UA; some | |
| // gateways behave differently for non-browser agents. | |
| if r.Header.Get("User-Agent") == "" { | |
| r.Header.Set("User-Agent", "cookieproxy") | |
| } | |
| if *verbose { | |
| log.Printf("-> %s %s%s", r.Method, upstream.Host, r.URL.RequestURI()) | |
| } | |
| } | |
| proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |
| log.Printf("proxy error for %s: %v", r.URL.Path, err) | |
| http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway) | |
| } | |
| // Optional side channel: hand the raw header to clients that would rather | |
| // keep pointing at the real host and set Cookie themselves. | |
| if *cookieListen != "" { | |
| mux := http.NewServeMux() | |
| mux.HandleFunc("/cookie", func(w http.ResponseWriter, r *http.Request) { | |
| w.Header().Set("Content-Type", "application/json") | |
| _ = json.NewEncoder(w).Encode(map[string]string{ | |
| "cookie": jar.header(cookieDomain), | |
| "domain": cookieDomain, | |
| }) | |
| }) | |
| go func() { | |
| log.Printf("cookie endpoint http://%s/cookie", *cookieListen) | |
| if err := http.ListenAndServe(*cookieListen, mux); err != nil { | |
| log.Printf("cookie endpoint stopped: %v", err) | |
| } | |
| }() | |
| } | |
| log.Printf("reverse proxy http://%s -> %s", *listen, upstream) | |
| log.Fatal(http.ListenAndServe(*listen, proxy)) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment