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:
parent
e596454446
commit
ab78535192
4 changed files with 156 additions and 0 deletions
8
auth-prompt-preload.js
Normal file
8
auth-prompt-preload.js
Normal 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
77
auth-prompt.html
Normal 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
69
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 = ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue