fix(theseus): prompt for HTTP authentication instead of showing the bare 401

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.
This commit is contained in:
Local Dev 2026-09-15 22:30:29 +02:00
parent e596454446
commit ab78535192
4 changed files with 156 additions and 0 deletions

8
auth-prompt-preload.js Normal file
View file

@ -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),
});

77
auth-prompt.html Normal file
View file

@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign in</title>
<style>
:root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
@media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--surface); }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink);
padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 10px; }
.title { font-size: 15px; font-weight: 650; margin: 0; }
.origin { display: inline-flex; align-items: center; gap: 8px; max-width: 100%;
background: var(--surface2); border: 1px solid rgb(from var(--acid) r g b / .35); color: var(--acid);
border-radius: 8px; padding: 6px 10px;
font: 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
.origin .lbl { color: var(--dim); font: 11px system-ui, sans-serif; white-space: nowrap; }
.hint { color: var(--mut); font-size: 12px; }
.hint b { color: var(--ink); font-weight: 600; }
.warn { color: var(--danger); font-size: 12px; }
label { display: grid; gap: 4px; color: var(--mut); font-size: 12px; }
input { font: 13px system-ui, sans-serif; color: var(--ink); background: var(--surface2);
border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
input:focus { border-color: var(--acid); }
.btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; padding-top: 4px; }
button { font: 13px system-ui, sans-serif; border-radius: 8px; padding: 7px 14px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface2); color: var(--ink); }
button.primary { background: var(--acid); color: #0f1420; border-color: transparent; font-weight: 650; }
button:focus-visible { outline: 2px solid var(--acid); outline-offset: 1px; }
</style>
</head>
<body>
<h1 class="title" id="title">Sign in</h1>
<div class="origin"><span class="lbl" id="lbl">site</span><span id="origin"></span></div>
<div class="hint" id="hint"></div>
<div class="warn" id="warn" hidden>This connection is not encrypted — the password would be sent in plain text.</div>
<form id="f" autocomplete="off">
<label>Username <input id="user" type="text" autocomplete="username" spellcheck="false"></label>
<label style="margin-top:8px">Password <input id="pass" type="password" autocomplete="current-password"></label>
<div class="btns">
<button type="button" id="cancel">Cancel</button>
<button type="submit" class="primary" id="ok">Sign in</button>
</div>
</form>
<script>
const $ = (id) => document.getElementById(id);
let reqId = null, done = false;
function finish(creds) {
if (done || reqId == null) return;
done = true;
window.authPrompt.answer(reqId, creds);
}
window.authPrompt.onShow((req) => {
reqId = req.id; done = false;
$("title").textContent = req.isProxy ? "Proxy sign-in" : "Sign in";
$("lbl").textContent = req.isProxy ? "proxy" : "site";
$("origin").textContent = req.origin;
$("hint").innerHTML = req.realm
? "The " + (req.isProxy ? "proxy" : "site") + " says: <b></b>"
: "The " + (req.isProxy ? "proxy" : "site") + " requires a username and password.";
if (req.realm) $("hint").querySelector("b").textContent = req.realm;
$("warn").hidden = !req.insecure;
$("user").value = ""; $("pass").value = "";
$("user").focus();
});
$("f").addEventListener("submit", (e) => { e.preventDefault(); finish({ username: $("user").value, password: $("pass").value }); });
$("cancel").addEventListener("click", () => finish(null));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") finish(null); });
</script>
</body>
</html>

69
main.js
View file

@ -333,6 +333,11 @@ function versionIsNewer(candidate, current) {
return false; // equal → not newer return false; // equal → not newer
} }
async function checkForUpdate() { 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 { try {
const controller = new AbortController(); const controller = new AbortController();
const to = setTimeout(() => controller.abort(), 5000); 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 — // the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent. // navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that // 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. // https:// is what the user sees regardless of how the bytes were fetched.
function refreshTabUrl(tab) { function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = "" if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""

View file

@ -67,6 +67,8 @@
"sidebar-preload.js", "sidebar-preload.js",
"approval-preload.js", "approval-preload.js",
"approval.html", "approval.html",
"auth-prompt-preload.js",
"auth-prompt.html",
"addon-inject-preload.js", "addon-inject-preload.js",
"addon-tab-preload.js", "addon-tab-preload.js",
"addons-host.js", "addons-host.js",