Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Electron cookies say "unspecified", Playwright only knows "Lax", and the mapping logged everyone out

Notifio is a desktop app that watches rental search pages and tells you the moment a new listing shows up. It runs on your own machine against your own browser profile, because that is the only arrangement where nobody e

Notifio is a desktop app that watches rental search pages and tells you the moment a new listing shows up. It runs on your own machine against your own browser profile, because that is the only arrangement where nobody else gets the listing first. You can see the product side of that argument on notifio.app and the per site version of it on pages like notifio.app/alerts/kamernet.

Several of the sites people watch only show listings to a signed in user. So the app has to hold a session for a site it does not own, and that turns out to involve two different browsers that disagree about what a cookie is.

Two browsers, one session

We do not automate logging in. The user logs in themselves, in a real Electron BrowserWindow with no automation flags on it at all, which I wrote about in The login window has no automation in it, which is the only reason Sign in with Google works.

The polling, though, happens in Playwright. So after the user says "I'm logged in", the session has to move from the Electron window to the Playwright context.

Each host gets its own Electron session partition, so cookies survive the login window being closed and reopened without mixing with anything else:

const partition = `persist:login-${hostname.replace(/\./g, '_')}`;

loginWindow = new BrowserWindow({
  width: 1100,
  height: 800,
  title: `Log in to ${hostname}`,
  webPreferences: { nodeIntegration: false, contextIsolation: true, partition },
});

When the user confirms, we read every cookie out of that partition, write it to a JSON file next to the browser profile, and the scraper imports it the next time it opens a context for that host:

const ses = session.fromPartition(partition);
const cookies = await ses.cookies.get({});
// scraper.ts, on context creation
if (fs.existsSync(cookieFile)) {
  const cookies = JSON.parse(fs.readFileSync(cookieFile, 'utf8'));
  if (Array.isArray(cookies) && cookies.length > 0) {
    await context.addCookies(cookies);
  }
}

That is the whole handoff. The bug was in the one line in the middle that translates each cookie.

Four values into three

Electron's cookie type reports sameSite as one of:

'unspecified' | 'no_restriction' | 'lax' | 'strict'

Playwright's addCookies accepts one of:

'Strict' | 'Lax' | 'None'

Four values into three. Three of them map obviously: strict to Strict, lax to Lax, no_restriction to None. Which leaves unspecified, and if you are translating an enum at speed, unspecified looks exactly like the one that belongs with None. No restriction was specified, so: no restriction.

That is wrong, and it is wrong in the direction that loses sessions.

unspecified does not mean the cookie is unrestricted. It means the Set-Cookie header carried no SameSite attribute, and Chromium's default for a cookie with no SameSite attribute is Lax. So mapping it to None does not preserve the cookie, it rewrites it into a cookie the site never set.

Why it failed silently

Two rules combine here. The first is that cookie default. The second is that Chromium rejects a SameSite=None cookie that is not also Secure.

Most session cookies on an ordinary site are set without an explicit SameSite, and plenty of them are set without Secure too. Translate unspecified to None and you hand Playwright a batch of cookies that are invalid by construction. They do not arrive in the context. Nothing throws, because nothing is wrong with the call: you asked for cookies that Chromium will not keep, and it did not keep them.

From the user's side the symptom is maddening and gives you no clue where to look. They log in, the site clearly shows them signed in in the login window, they press the confirm button, and the next check reports that the site wants them to log in. The count in the log says we imported 23 cookies. They just were not there afterwards.

The mapping we ship

// Electron sameSite is 'unspecified' | 'no_restriction' | 'lax' | 'strict'.
// Map to Playwright's 'Strict' | 'Lax' | 'None'. 'unspecified' must default
// to 'Lax' (Chromium's default). Mapping it to 'None' was wrong and caused
// many session cookies to be rejected on import.
const sameSite: 'Strict' | 'Lax' | 'None' =
  c.sameSite === 'strict'           ? 'Strict'
  : c.sameSite === 'no_restriction' ? 'None'
  : 'Lax'; // covers 'lax' and 'unspecified'

// Chromium rejects SameSite=None cookies that aren't also Secure, so force
// Secure on for any cookie we import as None.
const secure = sameSite === 'None' ? true : (c.secure ?? false);

The second half is a smaller decision but the same kind. If a cookie genuinely was set with SameSite=None, it must have been served over HTTPS to have been accepted in the first place, so forcing secure: true on import restores a property it already had rather than granting it a new one. The alternative is to pass the cookie through unchanged and watch Chromium drop it.

The shape of the lesson generalises past this pair of libraries. When you translate an enum between two systems and one side has a value the other does not, the interesting question is never which name looks closest. It is what the receiving system does when the field is absent, because the value that means "nothing was specified" has to map to the receiver's default, not to the receiver's most permissive option.

The question we deliberately answer cheaply

There is one more decision in that function worth defending. After exporting the cookies we have to tell the user something about whether the login worked, and what we do is this:

const loggedIn = cookies.some(c =>
  /session|auth|token|user|account|logged|jwt/i.test(c.name)
);

That is a guess about a cookie name, and it is obviously not proof of a session. We could do better by loading a page and looking for signed in content, but that costs a page load in the one moment the user is sitting there waiting, and it would still only be a guess about somebody else's markup.

So this answer is deliberately provisional. The authoritative check is the next real check: the monitor already has to recognise that a search landed on a login page, because sessions expire on their own schedule and it has to handle that anyway. If the cookie guess was optimistic, the search comes back as auth_failure within a cycle, the app says so against that specific search, and an email goes out. The cheap answer only has to be right often enough not to be annoying, because something that cannot be fooled is already standing behind it.

Links

If you want to see where this lands in the product, notifio.app/download is the app itself, notifio.app/help covers the login step and what to do when a site signs you out, and notifio.app/alerts lists the sites with a page each.

Related posts from the same codebase: Chromium lets one process open a profile, and it made our app say "logged out", and Polling someone else's website every 30 seconds without getting banned.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.