DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Electron says 'unspecified', Playwright wants 'Lax', and the session cookies vanished

Notifio checks rental portals for new listings, and several of the portals it checks will not show you anything useful until you are signed in. So the app needs your session on those sites. What it very deliberately does not need is your password.

The way that works is a handoff, and the handoff had a bug in it that rejected almost every cookie it moved.

The login window is a real window, not a form we wrote

There is no "enter your portal password" field anywhere in the app. Signing in to a site opens an ordinary Electron BrowserWindow pointed at that site's own login page:

// Use a dedicated Electron session partition per hostname so cookies are
// isolated from the main app but persist across login window re-opens.
const partition = `persist:login-${hostname.replace(/\./g, '_')}`;

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

Three things fall out of that one choice.

The credentials are typed into the site's own page, in a window with node integration off and context isolation on, so nothing the app runs can read them. They are never in our process, never in our config file, and never sent to our server, which has no field to receive them even if they were.

Every hostname gets its own persistent partition, so a session on one portal is not visible to another, and closing the window does not sign you out.

And because it is a real browser window rather than an automated one, sign in with Google works. Google's sign in flow declines to run in automated browsers, which is a reasonable thing for it to do and a wall if your only browser is the automated one. The fix is not to get clever about it. The fix is to do the login in a window that genuinely is not automated, and move only the result.

Moving the result, and the mapping that broke it

Once you are signed in, the cookies from that Electron session have to reach the context that actually does the checking. Both are Chromium, which makes this sound like a copy. It is not quite, because the two APIs describe SameSite differently, and the difference is not cosmetic:

// 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'
Enter fullscreen mode Exit fullscreen mode

unspecified reads like "no value", and the tempting translation of "no value" is the most permissive one. It is the opposite. Modern Chromium treats a cookie with no SameSite attribute as Lax, so unspecified already means Lax, and translating it to None changes the cookie's meaning on the way through.

Then the second rule bites, because SameSite=None without Secure is rejected outright:

// 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);
Enter fullscreen mode Exit fullscreen mode

So a plain session cookie, the kind with no SameSite attribute at all, was being relabelled None, failing the Secure requirement, and vanishing on import. The symptom was the worst kind: the login window clearly worked, the cookie count in the log was healthy, and the check ran signed out anyway. Nothing errored. There was simply less session on the other side than there had been on this one.

The general lesson, which I have now learned twice: when you map one enum onto another, the value that means "absent" is the one to look up rather than guess. Absent almost always has a specified default, and the default is almost never the permissive end.

Refusing to set up something that cannot work

The other half of this is a gate. Auto reply needs a signed in session on the site, for both halves of the feature: the setup records you sending a real enquiry, and the reply later goes out as your own account. So the requests that turn it on are refused when there is no session, rather than accepted into a state that could never function:

async function loginGate(
  siteUrl: string,
  purpose: 'reply' | 'record' = 'reply'
): Promise<{ error: string; needsLogin: true } | null> {
  let loggedIn = false;
  try {
    loggedIn = await checkLoginStatus(siteUrl);
  } catch {
    // A check that failed is not permission to carry on.
  }
  if (loggedIn) return null;
  // ...409 with a message naming the site and why
}
Enter fullscreen mode Exit fullscreen mode

That catch is deliberately empty and deliberately does not flip the flag. An error while checking is not evidence of being signed in, and the version of this code that treated "could not check" as "carry on" is how you ship a feature that is enabled and permanently broken for the subset of users whose check happens to fail.

The message names the site and the reason, and it differs by what you were trying to do, because "log in first" without a reason is the kind of error that makes people think the app is broken:

purpose === 'record'
  ? `Log in to ${hostname} first. The setup records you replying on it, which needs you signed in.`
  : `Log in to ${hostname} first. Auto-reply replies as your own account, so it needs a saved login.`
Enter fullscreen mode Exit fullscreen mode

And the reverse, when the session goes away

Signing out of a site inside the app clears that partition, which means every search on that host now has an auto reply switch that can only ever produce skips. So the switch goes off with the session:

/**
 * Switch every search on a hostname back to email-only.
 *
 * Called when a site's session is wiped: leaving "Reply for me" on for a site
 * we are signed out of would show a switch that cannot do anything. Returns how
 * many searches changed so the caller can say so.
 */
function disableAutoReplyForHost(hostname: string): number { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The count comes back so the response can tell the user what else just changed. A setting that silently turned itself off is worse than one that says it did.

One more small thing happens on a successful login: the host's backoff is cleared. A site that had been refusing us very often turns out to have been refusing an anonymous visitor, so a fresh session is a good reason to stop waiting and try again now rather than in twenty minutes. The ladder that wait comes from is in Our retry ladder was keyed by the wrong thing.

Which portals need a login and what each one's own alerts do is listed at notifio.app/alerts. The setup walkthrough is in notifio.app/help, what is and is not stored is in notifio.app/privacy, and the app is at notifio.app/download.

Top comments (0)