theseus/theseus-id-preload.js
Local Dev ed441b4e9d Passwords: save and fill logins inside iframes too
Sign-in widgets and embedded checkouts often put the login form in an
iframe, and the password hooks only ran in a tab's top frame, so those
logins were never offered for saving or filling.

- Web tabs now run preloads in iframes (nodeIntegrationInSubFrames; pages
  still get no Node). Only the password hooks act there: the home-page
  bridge, window.bcnr, add-on page scripts and window.theseusId return early
  outside the top frame, exactly as before.
- A login in a frame belongs to the frame's own site, taken from that
  frame's committed URL. The save prompt says "login.example (in a frame on
  shop.example)", the fill offer names both, and the fill goes into that
  exact frame only while it is still that tab's and still on that site.
- The "did the login go through" check runs against the frame.

Verified with a shop page embedding a cross-site login frame: save offer,
fill offer and fill all target the frame; the outer page gets nothing;
top-frame logins behave as before.
2026-10-05 20:34:17 +02:00

68 lines
3.8 KiB
JavaScript

// window.theseusId — Theseus ID for web pages (DESIGN-theseus-id.md §5.1).
//
// const r = await theseusId.signIn({ projectId: "hephaestus", nonce });
// // r = { v, projectId, origin, account: "tid:q…", message, signature, scheme: "bip137", move? }
// // send { message, signature, move } to your server; verify with TheseusID/lib/verify.mjs
//
// Registered session-wide. The page passes fields, never message text;
// Theseus writes the message, takes the origin from the frame it committed,
// and answers only the top frame of a web tab. Failures reject with an Error
// whose `code` is one of: no-vault, locked, denied, origin-not-listed,
// origin-list-unavailable, bad-request, busy, not-top-frame, error.
const { contextBridge, ipcRenderer } = require("electron");
// Top frame only (main refuses iframes anyway): web tabs run preloads in
// iframes too, for the password hooks.
if (window.top === window && /^(https?|bns):$/.test(location.protocol)) {
// Errors lose custom properties crossing the bridge, so the code rides in
// the message as "[code] text" and is copied back onto the Error here, in
// the page's world.
const call = async (channel, payload) => {
const r = await ipcRenderer.invoke(channel, payload);
if (r && r.ok) return r.result;
const code = (r && r.error && r.error.code) || "error";
throw new Error(`[${code}] ${(r && r.error && r.error.message) || "Theseus ID failed"}`);
};
const str = (v, max) => (v == null ? undefined : String(v).slice(0, max));
// A locked vault is only unlocked for a page the user just clicked or
// typed in. navigator.userActivation is not enough: a page Theseus opens
// with loadURL starts out "activated", so it could pop the vault prompt on
// load. These listeners run in the preload's world and count only trusted
// events, which a page cannot synthesise.
let lastInput = 0;
for (const type of ["pointerdown", "keydown"]) {
window.addEventListener(type, (e) => { if (e.isTrusted) lastInput = Date.now(); }, true);
}
const recentInput = () => Date.now() - lastInput < 5000;
const api = {
version: 1,
signIn: (opts = {}) => call("theseus-id:signIn", {
projectId: str(opts.projectId, 300) ?? "",
nonce: str(opts.nonce, 200) ?? "",
statement: str(opts.statement, 200),
requestId: str(opts.requestId, 64),
resources: Array.isArray(opts.resources) ? opts.resources.slice(0, 8).map((x) => String(x).slice(0, 2048)) : undefined,
expiresIn: Number.isFinite(Number(opts.expiresIn)) ? Number(opts.expiresIn) : undefined,
// Read here, in the preload's world, so a page cannot claim a click it did not get.
gesture: recentInput() && !!(navigator.userActivation && navigator.userActivation.isActive),
}),
// After the project's server accepted a move proof (§6.3).
moved: (opts = {}) => call("theseus-id:moved", { projectId: str(opts.projectId, 300) ?? "", account: str(opts.account, 100) ?? "" }),
};
try { contextBridge.exposeInMainWorld("theseusIdBridge", api); } catch {}
// A thin wrapper in the page's own world turns "[code] text" into err.code.
try {
const { webFrame } = require("electron");
webFrame.executeJavaScript(`(() => {
const b = window.theseusIdBridge; if (!b || window.theseusId) return;
const wrap = (fn) => (...a) => fn(...a).catch((e) => {
const m = /^\\[([a-z-]+)\\] ([\\s\\S]*)$/.exec(String(e && e.message || ""));
const err = new Error(m ? m[2] : String(e && e.message || e));
err.code = m ? m[1] : "error";
throw err;
});
Object.defineProperty(window, "theseusId", { value: Object.freeze({ version: b.version, signIn: wrap(b.signIn), moved: wrap(b.moved) }), enumerable: true });
try { delete window.theseusIdBridge; } catch {}
})()`);
} catch {}
}