From ab785351925d1a6edd4669bf9b6afd9b39272b90 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 15 Sep 2026 22:30:29 +0200 Subject: [PATCH] fix(theseus): prompt for HTTP authentication instead of showing the bare 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sites behind Basic/Digest auth (silentmode.st/guardian/admin) rendered the server's 401 page because nothing listened for Electron's login event, which cancels every challenge by default. A modal sign-in prompt now asks for the credentials and answers the challenge; Cancel leaves the 401 page. Concurrent challenges for the same host and realm share one prompt while it is open, and a rejected answer re-prompts instead of replaying the same credentials until Chromium gives up with ERR_TOO_MANY_RETRIES. Also: THESEUS_NO_UPDATE_CHECK skips the release check, for throwaway dev instances — the one-click install chip they show targets the real install. --- auth-prompt-preload.js | 8 +++++ auth-prompt.html | 77 ++++++++++++++++++++++++++++++++++++++++++ main.js | 69 +++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 4 files changed, 156 insertions(+) create mode 100644 auth-prompt-preload.js create mode 100644 auth-prompt.html diff --git a/auth-prompt-preload.js b/auth-prompt-preload.js new file mode 100644 index 0000000..31ef4fd --- /dev/null +++ b/auth-prompt-preload.js @@ -0,0 +1,8 @@ +// Preload for the HTTP authentication prompt (auth-prompt.html). Main pushes +// the challenge via `auth-show`; the page answers once with `auth-answer` +// (credentials, or null for cancel). Nothing else is exposed. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("authPrompt", { + onShow: (cb) => ipcRenderer.on("auth-show", (_e, req) => cb(req)), + answer: (id, creds) => ipcRenderer.invoke("auth-answer", id, creds), +}); diff --git a/auth-prompt.html b/auth-prompt.html new file mode 100644 index 0000000..e9dd8c2 --- /dev/null +++ b/auth-prompt.html @@ -0,0 +1,77 @@ + + + + +Sign in + + + +

Sign in

+
site
+
+ +
+ + +
+ + +
+
+ + + diff --git a/main.js b/main.js index 75587cf..7dd6687 100644 --- a/main.js +++ b/main.js @@ -333,6 +333,11 @@ function versionIsNewer(candidate, current) { return false; // equal → not newer } async function checkForUpdate() { + // Dev harness guard: a throwaway instance (THESEUS_USER_DATA) that finds a + // newer release on the mirror shows the same one-click "Install & restart" + // chip as a real install, and that installer targets the REAL install dir. + // 2026-09-15 a test run reinstalled the user's Theseus that way. + if (process.env.THESEUS_NO_UPDATE_CHECK) return; try { const controller = new AbortController(); const to = setTimeout(() => controller.abort(), 5000); @@ -2506,6 +2511,70 @@ ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100)); // the address bar. Without this, t.url is only refreshed on programmatic loads — // navigateTab / the collision switcher — and everything else sticks on the parent. // Internal bns:// → https:// for display, matching navigateTab's convention that + +// ---- HTTP authentication (401 / 407 challenges) ---- +// Without a `login` listener Electron cancels every challenge, so a site +// behind Basic/Digest auth just rendered the server's bare 401 page +// (silentmode.st/guardian/admin, 2026-09-15). One modal prompt at a time; +// concurrent challenges for the same host+realm (a page plus its +// subresources) share the first answer. Successful credentials are cached +// by Chromium's network service for the session, so a page's later +// requests don't re-prompt. +const authPrompts = new Map(); // key -> Promise<{username,password}|null> +const authPending = new Map(); // reqId -> resolve +let authSeq = 0, authQueue = Promise.resolve(); +function promptHttpAuth(req) { + const run = () => new Promise((resolve) => { + if (!win || win.isDestroyed()) return resolve(null); + const id = ++authSeq; + const pw = new BrowserWindow({ + parent: win, modal: true, show: false, width: 440, height: 340, + resizable: false, minimizable: false, maximizable: false, fullscreenable: false, + title: req.isProxy ? "Proxy sign-in" : "Sign in", + backgroundColor: nativeTheme.shouldUseDarkColors ? "#1c222c" : "#ffffff", + autoHideMenuBar: true, + webPreferences: { preload: path.join(__dirname, "auth-prompt-preload.js") }, + }); + let settled = false; + const settle = (v) => { if (settled) return; settled = true; authPending.delete(id); resolve(v); if (!pw.isDestroyed()) pw.close(); }; + authPending.set(id, settle); + pw.on("closed", () => settle(null)); + pw.webContents.on("did-finish-load", () => { pw.webContents.send("auth-show", { id, ...req }); pw.show(); }); + pw.loadFile(path.join(__dirname, "auth-prompt.html")).catch(() => settle(null)); + }); + const p = authQueue.then(run, run); + authQueue = p.catch(() => {}); + return p; +} +ipcMain.handle("auth-answer", (e, id, creds) => { + const settle = authPending.get(Number(id)); + if (!settle) return; + // Only the prompt window itself may answer. + const isPrompt = [...BrowserWindow.getAllWindows()].some((w) => w.webContents === e.sender && w.getParentWindow() === win); + if (!isPrompt) return; + settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null); +}); +app.on("login", (event, _wc, details, authInfo, callback) => { + // Proxy auth configured by an add-on is answered by its own handler. + if (authInfo?.isProxy && proxyLoginHandler) return; + event.preventDefault(); + const host = authInfo?.host || ""; + const port = authInfo?.port; + const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://")) + + host + (port && port !== 80 && port !== 443 ? ":" + port : ""); + const key = `${authInfo?.isProxy ? "proxy" : "site"}|${host}:${port}|${authInfo?.realm || ""}`; + let p = authPrompts.get(key); + if (!p) { + p = promptHttpAuth({ origin, realm: authInfo?.realm || "", scheme: authInfo?.scheme || "", isProxy: !!authInfo?.isProxy, + insecure: !authInfo?.isProxy && String(details?.url || "").startsWith("http:") }); + authPrompts.set(key, p); + // Share only while the prompt is open. Once answered, a fresh challenge + // for the same realm means the server rejected those credentials, and + // the user must be asked again (Chromium caches accepted ones itself). + p.finally(() => authPrompts.delete(key)); + } + p.then((c) => { if (c) callback(c.username, c.password); else callback(); }, () => callback()); +}); // https:// is what the user sees regardless of how the bytes were fetched. function refreshTabUrl(tab) { if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = "" diff --git a/package.json b/package.json index 2198afc..8bcde9a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "sidebar-preload.js", "approval-preload.js", "approval.html", + "auth-prompt-preload.js", + "auth-prompt.html", "addon-inject-preload.js", "addon-tab-preload.js", "addons-host.js",