Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cookie Broker

Keeps self-hosted apps in lockstep with logged-in browser sessions, for sites you approve one at a time, so nobody ever pastes a cookie out of DevTools again.

Any app can use it. You connect one by typing its address; it publishes a profile saying which sites it needs a session for, and the browser asks you about each of those by name before anything is read.

Generalized from the single-purpose rocketmoney-mcp/extension, which has been doing exactly this against the hardest possible target (a session that rotates on every response) since July.

The split

The extension seeds. The receiver keeps.

Extension (browser) Receiver (your server)
Fires when the jar changes, plus a 15-min heartbeat continuously, on its own clock
Job push the current jar, once persist it, follow rotations, prevent idle expiry, report liveness
If it is offline nothing is lost; the receiver carries on the session eventually dies and pings you

That split is the whole design. The extension makes no attempt to refresh or renew anything, so it does not matter that it only works in the one browser it is installed in, or that the browser is closed most of the day. It is a seeder.

The receiver refreshes on its own flow: every real request it makes goes through the persisted jar and absorbs any Set-Cookie that comes back, and a keepalive probe touches the session on a timer so it never ages out between uses. Recovery is the nice part - when a session finally does die, the ntfy alert's only instruction is "open the site in your browser," and the extension reseeds automatically.

Why an extension and not a bookmarklet

Session cookies worth brokering are HttpOnly, which exists precisely to stop page scripts from reading them:

$ curl -sI https://www.qobuz.com/us-en/discover | grep -i set-cookie
set-cookie: qobuz-session=...; path=/; secure; httponly

document.cookie cannot see that. Only the extension chrome.cookies API, which sits below the HttpOnly boundary, can. There is no lighter-weight option.

Security posture

This is, structurally, an infostealer. Treat it like one:

  • Never list it in an extension store. Load unpacked, always. The source is public so it can be read before it is trusted, which is the opposite of a store listing: that would put an auto-updating binary holding live session tokens on other people's machines, and hand whoever holds the publisher account a supply chain into every one of them. Read this, then build from what you read.
  • Chrome's permission dialog is the security boundary. Nothing is granted by installing a profile. The extension works out which hosts that profile would need, shows you the list, and asks Chrome, which asks you by name. A profile that wants your bank has to say so in that dialog. Grants are visible and revocable at any time from chrome://extensions, and are asked for again rather than assumed. The manifest declares that hosts may be requested; it no longer decides which, because a fixed list cannot know about an app you install next year.
  • A profile is data, never code. This is the one place it deliberately parts company with a userscript manager. Installed code inside something that can read HttpOnly cookies for a list of sites is not a plugin, it is every account on that list. Profiles are JSON, checked against a schema, rebuilt field by field, and anything outside the schema is dropped.
  • A profile cannot lie about what it is asking for. cookieUrl has to be part of cookieDomain, so it cannot present itself as one site and collect another's cookies. Endpoints are paths, not addresses, so it cannot redirect a jar off the origin you consented to. A sign-in page has to belong to the site it signs you into. All three are enforced in profile.js and tested by breaking them.
  • The receiver refuses to run unauthenticated. make_blueprint raises without $COOKIE_BROKER_TOKEN rather than defaulting to open.
  • Tokens live in chrome.storage.local only, entered on the options page. Nothing secret belongs in this repo.
  • The banner holds nothing. A content script runs inside a page the store controls, so it gets no token and makes no cross-origin call. It asks the service worker to act and the worker answers with one sentence. Its reach is the matches list on the content_scripts entry, which is a separate grant from host_permissions and deliberately does not widen it.

Plaintext is refused rather than discouraged. A profile whose receiver is plain http to a public address is not installable, because what travels over that connection is a live session cookie, which is the whole account. It is allowed to loopback, to the RFC 1918 ranges, to 100.64/10 where a tailnet lives, and to .local, since there something else is already carrying the encryption or the traffic never leaves your network.

Layout

extension/          Chrome MV3, load unpacked
  profile.js        what a profile may say, and what it may ask for. Pure, tested
  registry.js       what is installed right now, and which grants it holds
  background.js     service worker: read jar, push on change + heartbeat
  appapi.js         Library Wishlist client, called from the worker only
  content/banner.js the store return banner, injected on store pages
  content/present.js one attribute, so the app can tell the extension is here
  options.html/js   connect an app, manage installed profiles and their tokens
  popup.html/js     per-site status, manual sync, diagnostics, settings gear
  ui.css            shared tokens. Chrome's own palette, light and dark
  icons/            generated, see tools/make-icons.py. Do not hand-edit
profiles/           example profiles, for pasting or for copying into an app
receiver/
  cookie_broker.py  SessionKeeper + Flask blueprint, stdlib only
docs/PROTOCOL.md    the wire format. Everything an app needs to implement
tools/make-icons.py redraws the icon at every size Chrome asks for. Stdlib only
tests/              `node tests/<name>.test.mjs`, nothing to install

The store return banner

The second thing this extension does, unrelated to pushing jars, and asked for by the app rather than configured here. An app that declares the purchase-return capability in its profile gets a bar on the shops it names: mark the URL you send a buyer to with #<marker>=<id> and the bar shows that item, an I bought it button, and a way back to the list. Without the extension the marker is inert and the old flow is unchanged.

It also stamps the app's own pages with one attribute, which is how the app knows to send a Buy click to this tab rather than a new one. That is what makes the return trip worth taking: going back is then ordinary history navigation, so the browser hands back the list exactly as it was, scroll position and typed filter included.

Nothing about it is configured in the extension, and nothing about it needs a manifest edit. The profile says which shops, which marker and which paths; the content scripts are registered at runtime from that. What the profile may not do is say what is drawn or what is called, which is the line the whole design rests on and is spelled out in docs/PROTOCOL.md.

Design, constraints and the manual checks: docs/store-return-banner.md.

Install

  1. chrome://extensions -> Developer mode -> Load unpacked -> extension/.
  2. Set COOKIE_BROKER_TOKEN on your app and mount the blueprint (see receiver/README.md). Until that token is set the ingest endpoint does not exist, and neither does the profile describing it.
  3. Extension Options -> Connect an app -> type its address.
  4. Chrome asks whether this extension may contact that address. Allow it, and the profile is read and shown to you: the app's name, and every host it wants.
  5. Paste the token, click Install, and Chrome asks a second time, naming each site. That list is the whole cost of installing; read it.
  6. The popup should read synced with server: live.

Nothing is stored before both of those questions are answered, so a dismissed prompt leaves nothing behind.

The badge marks trouble and says nothing otherwise: out logged out somewhere, cfg missing a token, err a push failed. Working is the state this spends nearly all its life in, so it gets no badge at all; a mark that is always there is a mark nobody reads. Open the popup for the reason, and for the difference between healthy, idle and nothing-installed.

Teaching it about a new app

Nothing here changes. Publish a profile from the app:

GET https://your-app.example/.well-known/cookie-broker.json

Someone types your app's address in Options and the rest follows. The full format, and what an app has to do with the jar once it has one, is docs/PROTOCOL.md. profiles/library-wishlist.json is a working example.

Leave receiver.base out of it. The extension uses the address it fetched the profile from, so a document that names its own address is correct for exactly one deployment, which is usually yours and nobody else's.

If your app cannot publish a document, the same JSON can be pasted into the options page instead. Then receiver.base is required, because nothing else knows where the app is.

One thing worth copying if you are writing a profile for a site behind a load balancer: read the jar at the origin the session is actually pinned to. Rocket Money, for instance, keeps its AWSALB stickiness cookies on client-api.rocketmoney.com rather than the app origin, and pushing the session id without them lets requests scatter across backends and 401 after a couple of calls.

Status

Built and deployed 2026-08-02.

Proven end to end against a live Qobuz session: the extension read the HttpOnly qobuz-session, pushed an 11-cookie jar, the receiver probed it LIVE, enumerated the account's real purchases, and a later keepalive tick with no browser involvement still reported live.

Running against Library Wishlist, which vendors cookie_broker.py and serves /auth/ingest and /auth/status itself. Keep the token in a file the service reads at start (mode 600) rather than inline in a unit file or a launchd plist: an inline value is readable by anything that can read that file, and on some systems by anything that can list processes.

One thing to check on your own deployment: the wishlist app binds 0.0.0.0 by default, so these endpoints answer on every interface the host has, not only the one you had in mind. The bearer token is the only thing gating them, which is why make_blueprint refuses to construct without one.

Open questions

  • Does Qobuz rotate qobuz-session on authenticated requests? Four requests in, last_rotation was still unset, which suggests a stable id whose server-side expiry refreshes on access. If that holds, the keepalive touch is what matters and Set-Cookie absorption is just insurance.
  • Real idle TTL is unknown. The 6h keepalive is sized well under the ~5 days the previous session survived.
  • Chrome API details never verified against docs (the research agent hit a session limit): chrome.alarms minimum period, and whether extension service workers face Private Network Access limits. Both are coded around rather than relied on.

Licence

AGPL-3.0-or-later. See LICENSE. Running a modified copy as a network service means publishing your changes.

About

Mirrors a logged-in browser session to your own self-hosted app, for sites you approve one at a time. The extension seeds; the app keeps the session alive.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages