diff --git a/AGENTS.md b/AGENTS.md
index 15e3996..1124b4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,11 +7,16 @@ or the release pipeline.
Frost is an Electron tray app (an AWS SSO credentials refresher) for macOS,
Windows and Linux. There is no bundler; almost every `src/*.ts` runs in the
-main process. Two exceptions:
-
-- `src/login-overlay.ts` is browser code injected into the login page. It has
- its own compile (`tsconfig.overlay.json`); the main `tsconfig.json` excludes
- it.
+main process. Three exceptions:
+
+- `src/login-overlay.ts` is browser code injected into the login page (the
+ WebAuthn toast). It has its own compile (`tsconfig.overlay.json`); the main
+ `tsconfig.json` excludes it.
+- `src/approve-overlay.ts` is the same kind of thing under the same compile —
+ the driver that clicks the AWS approval steps. Neither may import anything:
+ an import makes the output a module, which is not injectable as a classic
+ script, and is why each signal constant is duplicated in its main-process
+ counterpart rather than shared.
- `src/dashboard.html` is copied verbatim by `build:html` and is neither
type-checked nor linted.
@@ -40,6 +45,13 @@ missing one fails at runtime only.
failure *after* the token was renewed keeps the expiry schedule rather than
an error retry, which would reopen the login page for an unrelated failure.
No electron imports.
+- **`src/page-script.ts`** — `loadPageScript()` / `injectIntoEveryFrame()`,
+ used by both injected scripts. Injection follows sub-frames because
+ `executeJavaScript` on a `WebContents` reaches the top frame only, and a
+ sign-in page routinely puts the interesting part in a cross-origin `
+
Automatic approval
+
+ The approval itself is more than one step. The verification page first
+ asks you to confirm the request, then — once your identity provider has
+ signed you in — asks you to grant access, and only then is the token
+ Frost is waiting for issued. Neither step asks for anything you have to
+ supply: they are two buttons on two pages that say what they are about
+ to do.
+
+
+ So Frost clicks them. The login page is loaded in a window that stays
+ off screen, a script Frost injects clicks through the approval steps as
+ they appear, and if your identity provider session is still live the
+ whole refresh finishes without anything appearing at all — no window, no
+ browser tab, no Dock icon.
+
+
+ The window comes up the moment the page needs you: a password
+ or one-time code to type, a security key to touch, a passkey to pick.
+ It also comes up if anything is off the script — a page Frost cannot
+ read, a button AWS has renamed, a load that fails, or a flow that simply
+ stops making progress — so a sign-in never sits invisibly waiting for
+ someone. In default-browser mode the same moment opens your browser
+ instead, which is where your passkeys and saved passwords live.
+
+
+ Frost only ever clicks on the AWS access portal's own approval pages,
+ never on your identity provider's, and only controls it recognises — by
+ AWS's own id, or by an exact label such as Confirm and continue
+ or Allow access. A control that reads like a refusal is never
+ clicked, whatever else about it matches. Anything unrecognised is left
+ alone for you to answer.
+
+
+ Turn it off with Approve automatically on the Behavior
+ page and every sign-in shows its window from the start, as it did
+ before.
+ Behavior settings →
+
+
Where the login page opens
The Login Page setting on the Behavior page picks
diff --git a/docs/docs/settings-behavior.html b/docs/docs/settings-behavior.html
index bf955b8..cbfc8f0 100644
--- a/docs/docs/settings-behavior.html
+++ b/docs/docs/settings-behavior.html
@@ -110,6 +110,22 @@
+ On by default. Frost loads the AWS approval page off screen and clicks
+ through its steps itself, so a refresh that your identity provider
+ session still covers finishes without showing anything. The login page
+ appears — in the Frost window, or in your browser if that is the mode
+ you picked — as soon as it asks for something only you can give, or if
+ the flow stops making progress.
+
+
+ Turn it off to watch every sign-in happen. Nothing else about the flow
+ changes; the same pages open, in the same place, with the same steps for
+ you to click.
+ How automatic approval works →
+
+
Clear cookies and local storage
The in-app login window keeps its cookies and local storage between
diff --git a/docs/docs/settings.html b/docs/docs/settings.html
index e6fc792..a37db1b 100644
--- a/docs/docs/settings.html
+++ b/docs/docs/settings.html
@@ -71,6 +71,12 @@
Every setting
In-app window
Whether AWS sign-in opens in a Frost window or your default browser.
diff --git a/src/approve-overlay.ts b/src/approve-overlay.ts
new file mode 100644
index 0000000..9c4fd30
--- /dev/null
+++ b/src/approve-overlay.ts
@@ -0,0 +1,318 @@
+/**
+ * The approval driver Frost injects into its AWS SSO login window.
+ *
+ * The device authorization flow AWS SSO uses is a *multi-step* approval: the
+ * page opened from `verificationUriComplete` asks to confirm the request
+ * ("Confirm and continue"), then — once the user has an identity provider
+ * session — asks to grant access ("Allow access"), and only then is the device
+ * code redeemable. None of those steps carry information the user has to
+ * supply: with a live federated session they are two clicks on two pages that
+ * say what they are going to do next. This script performs those clicks so the
+ * common refresh needs nothing from the user at all, and reports back the
+ * moment the page asks for something only the user can give — a password, a
+ * one-time code — so `src/auto-approve.ts` can put the window on screen.
+ *
+ * Like `src/login-overlay.ts`, this runs **in the browser**, in the page's own
+ * world, on pages Frost does not control, and it is compiled on its own by
+ * `tsconfig.overlay.json` and injected as source text. Read the header of that
+ * file for what the compile has to look like and why. The rules that matter
+ * here:
+ *
+ * - It only ever clicks on an AWS SSO device-authorization host, and only a
+ * control it recognises by id or by exact label. Anything else — a page it
+ * cannot read, a button AWS renamed — is left alone, and the wait that
+ * follows is what shows the window. Guessing wrong is worse than stalling:
+ * the buttons next to the ones we want revoke access or cancel the request.
+ * - It never clicks a control whose label reads like a refusal, whatever its
+ * id says.
+ * - It reports over the console (see `SIGNAL`), the only channel a script in
+ * the page's world has. The page can forge those lines, so nothing here may
+ * ever be security-relevant: the worst a forged line can do is show the
+ * login window that was about to be shown anyway.
+ */
+(() => {
+ "use strict";
+
+ const FLAG = "__frostApproveOverlay";
+
+ /** The page's globals are not typed for our own bookkeeping. */
+ const flags = window as unknown as Record;
+ if (flags[FLAG]) return;
+ flags[FLAG] = true;
+
+ /** Must match APPROVE_SIGNAL in src/auto-approve.ts. */
+ const SIGNAL = "__frost-login-approve__:";
+
+ /** How often the page is re-examined. */
+ const SCAN_MS = 400;
+
+ /** Quiet period after a click, so a re-render is not clicked twice. */
+ const AFTER_CLICK_MS = 1500;
+
+ /**
+ * A page needing more clicks than this is not the flow we know. Stopping
+ * leaves the window to the stall timer in the main process, which is the
+ * safe outcome; carrying on would be a script clicking around a page it has
+ * evidently misread.
+ */
+ const MAX_CLICKS = 4;
+
+ /** Nothing in this flow takes minutes; stop scanning rather than spin. */
+ const MAX_LIFETIME_MS = 180000;
+
+ /**
+ * Hosts serving the device-authorization pages: the account's own portal
+ * (`d-1234567890.awsapps.com`, `acme.awsapps.com`) and the regional device
+ * endpoint. Clicking happens nowhere else — an identity provider's pages
+ * are the user's to answer.
+ */
+ function isApprovalHost(hostname: string): boolean {
+ const host = hostname.toLowerCase();
+ return (
+ host === "awsapps.com" ||
+ host.endsWith(".awsapps.com") ||
+ /^device\.sso\.[a-z0-9-]+\.amazonaws\.com$/.test(host) ||
+ /^oidc\.[a-z0-9-]+\.amazonaws\.com$/.test(host)
+ );
+ }
+
+ /** Ids AWS has used for the two approval buttons. */
+ const APPROVAL_IDS = ["cli_verification_btn", "cli_login_button"];
+
+ /** Exact labels — a partial match is how you end up clicking "Deny". */
+ const APPROVAL_LABELS = [
+ "confirm and continue",
+ "allow access",
+ "allow",
+ "approve",
+ "authorize",
+ "accept",
+ "confirm",
+ "yes, allow",
+ ];
+
+ const DENY_WORDS = [
+ "cancel",
+ "deny",
+ "reject",
+ "decline",
+ "no",
+ "close",
+ "back",
+ "logout",
+ "revoke",
+ ];
+
+ const DENY_PHRASES = ["sign out", "log out", "not now", "try another"];
+
+ /** The page the flow ends on. Its text is the only "we are done" we get. */
+ const APPROVED_TEXTS = [
+ "request approved",
+ "you can close this window",
+ "you may close this window",
+ "you can now close this window",
+ ];
+
+ const CONTROL_SELECTOR =
+ 'button, input[type="submit"], input[type="button"], [role="button"]';
+
+ /** Input types the user is expected to type into. */
+ const TYPED_INPUT_TYPES = ["", "text", "email", "tel", "number", "search", "url"];
+
+ function normalize(text: string | null | undefined): string {
+ return (text || "").replace(/\s+/g, " ").trim().toLowerCase();
+ }
+
+ /**
+ * What the control says it does, as the user would read it: its accessible
+ * label, else its text, else the value a submit button renders.
+ */
+ function labelOf(element: Element): string {
+ const aria = normalize(element.getAttribute("aria-label"));
+ if (aria) return aria;
+
+ const text = normalize(element.textContent);
+ if (text) return text;
+
+ const value = (element as HTMLInputElement).value;
+ return normalize(typeof value === "string" ? value : "");
+ }
+
+ function isDenial(label: string): boolean {
+ const words = label.split(/[^a-z0-9]+/).filter(Boolean);
+ return (
+ words.some((word) => DENY_WORDS.indexOf(word) >= 0) ||
+ DENY_PHRASES.some((phrase) => label.indexOf(phrase) >= 0)
+ );
+ }
+
+ /**
+ * `checkVisibility` is preferred over measuring a rectangle because this
+ * runs in a window that has not been shown yet: it answers from styles
+ * rather than from layout, so it does not depend on the window ever having
+ * been painted. The measurement below is the fallback for a page whose
+ * engine predates it.
+ */
+ function isVisible(element: Element): boolean {
+ if (typeof element.checkVisibility === "function") {
+ try {
+ return element.checkVisibility({
+ checkOpacity: true,
+ checkVisibilityCSS: true,
+ });
+ } catch {
+ // Fall through to the measured answer.
+ }
+ }
+
+ const rect = element.getBoundingClientRect();
+ if (rect.width < 1 && rect.height < 1) return false;
+
+ const style = window.getComputedStyle(element);
+ return style.visibility !== "hidden" && style.display !== "none";
+ }
+
+ function isEnabled(element: Element): boolean {
+ if ((element as HTMLButtonElement).disabled) return false;
+ return element.getAttribute("aria-disabled") !== "true";
+ }
+
+ const clicked = new WeakSet();
+ let clicks = 0;
+
+ function isApproval(element: Element): boolean {
+ if (clicked.has(element)) return false;
+ if (!isEnabled(element) || !isVisible(element)) return false;
+
+ const label = labelOf(element);
+ if (isDenial(label)) return false;
+
+ if (APPROVAL_IDS.indexOf(element.id) >= 0) return true;
+ return APPROVAL_LABELS.indexOf(label) >= 0;
+ }
+
+ /**
+ * The control to click, or null. Ids win over labels: an id is AWS's own
+ * handle on the button, while a label match could in principle be some
+ * other control that happens to read the same way.
+ */
+ function approvalControl(): HTMLElement | null {
+ const controls = Array.prototype.slice.call(
+ window.document.querySelectorAll(CONTROL_SELECTOR)
+ ) as HTMLElement[];
+
+ let byLabel: HTMLElement | null = null;
+ for (const control of controls) {
+ if (!isApproval(control)) continue;
+ if (APPROVAL_IDS.indexOf(control.id) >= 0) return control;
+ if (!byLabel) byLabel = control;
+ }
+ return byLabel;
+ }
+
+ /**
+ * Why the page cannot go on without the user, or null. An empty field the
+ * user has to fill in is the signal; the device page's own code field
+ * arrives already filled from `verificationUriComplete`, which is exactly
+ * the difference that matters.
+ */
+ function userPrompt(): string | null {
+ const fields = Array.prototype.slice.call(
+ window.document.querySelectorAll("input, textarea")
+ ) as HTMLInputElement[];
+
+ for (const field of fields) {
+ if (field.disabled || field.readOnly) continue;
+
+ const isTextArea = field.tagName === "TEXTAREA";
+ const type = normalize(field.getAttribute("type"));
+ if (!isTextArea) {
+ if (type === "hidden") continue;
+ if (type === "password") {
+ if (isVisible(field)) return "password";
+ continue;
+ }
+ if (TYPED_INPUT_TYPES.indexOf(type) < 0) continue;
+ }
+
+ if (normalize(field.value)) continue;
+ if (isVisible(field)) return "input";
+ }
+ return null;
+ }
+
+ function isApproved(): boolean {
+ const body = window.document.body;
+ const text = normalize(body ? body.innerText : "");
+ if (!text) return false;
+ return APPROVED_TEXTS.some((phrase) => text.indexOf(phrase) >= 0);
+ }
+
+ function signal(payload: Record) {
+ try {
+ window.console.info(SIGNAL + JSON.stringify(payload));
+ } catch {
+ // The page replaced `console`. Nothing is reported, and the main
+ // process falls back to showing the window when it stops hearing
+ // about progress - which is the same outcome as a page we cannot
+ // drive at all.
+ }
+ }
+
+ let timer = 0;
+ let pausedUntil = 0;
+ let reported = false;
+ const startedAt = Date.now();
+
+ function stop() {
+ window.clearInterval(timer);
+ }
+
+ function scan() {
+ const now = Date.now();
+ if (now < pausedUntil) return;
+ if (now - startedAt > MAX_LIFETIME_MS) {
+ stop();
+ return;
+ }
+ if (!window.document.body) return;
+
+ if (isApprovalHost(window.location.hostname)) {
+ if (isApproved()) {
+ signal({ state: "approved" });
+ stop();
+ return;
+ }
+
+ const control = approvalControl();
+ if (control && clicks < MAX_CLICKS) {
+ clicks += 1;
+ clicked.add(control);
+ pausedUntil = now + AFTER_CLICK_MS;
+
+ const label = labelOf(control) || control.id;
+ signal({ state: "clicked", label });
+ try {
+ control.click();
+ } catch {
+ // A control that will not take a click is a page we cannot
+ // drive; the wait that follows shows the window.
+ }
+ return;
+ }
+ }
+
+ // Kept running after the first report: once the user has signed in, the
+ // remaining approval steps are ours to click again.
+ if (!reported) {
+ const prompt = userPrompt();
+ if (prompt) {
+ reported = true;
+ signal({ state: "user", reason: prompt });
+ }
+ }
+ }
+
+ timer = window.setInterval(scan, SCAN_MS);
+ scan();
+})();
diff --git a/src/auto-approve.ts b/src/auto-approve.ts
new file mode 100644
index 0000000..9c6e0a7
--- /dev/null
+++ b/src/auto-approve.ts
@@ -0,0 +1,159 @@
+import { BrowserWindow } from "electron";
+import log from "electron-log/main";
+import { injectIntoEveryFrame, loadPageScript } from "./page-script.js";
+
+/**
+ * The script that does the clicking, compiled by `tsconfig.overlay.json`.
+ */
+const APPROVE_SCRIPT = "approve-overlay.js";
+
+/** Must match SIGNAL in src/approve-overlay.ts. */
+const APPROVE_SIGNAL = "__frost-login-approve__:";
+
+/**
+ * How long the flow may go without visible progress — a navigation, a page
+ * load, a click — before the window is handed to the user. Long enough to sit
+ * through a slow identity provider redirect, short enough that a page we have
+ * misread does not look like a hang.
+ */
+const STALL_MS = 12000;
+
+/**
+ * A ceiling on the whole silent attempt, for a page that keeps navigating (a
+ * redirect loop, a refreshing "please wait") and so keeps resetting the stall
+ * timer for as long as we let it.
+ */
+const TOTAL_MS = 60000;
+
+/** What the page reported, as a log line and a reason to show the window. */
+const REASONS: Record = {
+ password: "the page is asking for a password",
+ input: "the page is asking for something to be typed in",
+};
+
+interface ApproveSignal {
+ state?: string;
+ reason?: string;
+ label?: string;
+}
+
+/**
+ * Drive the AWS SSO device-authorization pages so a refresh with a live
+ * federated session needs nothing from the user (issue #1).
+ *
+ * `src/approve-overlay.ts` is injected into every document the login window
+ * loads; it clicks the "Confirm and continue" and "Allow access" steps and
+ * reports what it sees. This side turns those reports — plus the window's own
+ * navigation events — into a single decision: either the flow is still moving
+ * on its own, or the user has to take over, in which case `onUserNeeded` is
+ * called exactly once with a short phrase saying why.
+ *
+ * The caller decides what "take over" means (showing the hidden window, or
+ * opening the page in the default browser). Every failure lands there too: an
+ * unreadable script, a page that fails to load, a flow that stops making
+ * progress. Nothing here can leave the login hidden forever — the timers run
+ * whether or not the page ever says anything.
+ *
+ * Call it before loading the login URL, on a window nothing else has attached
+ * to; the listeners go away with the window.
+ */
+export function attachAutoApprove(
+ window: BrowserWindow,
+ onUserNeeded: (reason: string) => void
+) {
+ const contents = window.webContents;
+
+ let settled = false;
+ let stallTimer: NodeJS.Timeout | null = null;
+ let totalTimer: NodeJS.Timeout | null = null;
+
+ const clearTimers = () => {
+ if (stallTimer) clearTimeout(stallTimer);
+ if (totalTimer) clearTimeout(totalTimer);
+ stallTimer = null;
+ totalTimer = null;
+ };
+
+ /** The flow is over, one way or another: stop watching it. */
+ const settle = () => {
+ if (settled) return false;
+ settled = true;
+ clearTimers();
+ return true;
+ };
+
+ const handOver = (reason: string) => {
+ if (!settle()) return;
+ log.info("[autoApprove] Handing the login over: %s", reason);
+ onUserNeeded(reason);
+ };
+
+ const progress = () => {
+ if (settled) return;
+ if (stallTimer) clearTimeout(stallTimer);
+ stallTimer = setTimeout(
+ () => handOver("the login page stopped making progress"),
+ STALL_MS
+ );
+ };
+
+ const source = loadPageScript(APPROVE_SCRIPT);
+ if (!source) {
+ // Without the script there is nothing to drive the page with, and a
+ // hidden window would sit there until the device code expired.
+ handOver("the approval script could not be read");
+ return;
+ }
+
+ injectIntoEveryFrame(contents, source, "autoApprove");
+
+ contents.on("console-message", (details) => {
+ if (settled) return;
+ if (!details.message.startsWith(APPROVE_SIGNAL)) return;
+
+ let signal: ApproveSignal;
+ try {
+ signal = JSON.parse(details.message.slice(APPROVE_SIGNAL.length));
+ } catch (err) {
+ log.warn("[autoApprove] Unreadable signal: %s", err);
+ return;
+ }
+
+ if (signal.state === "clicked") {
+ log.info("[autoApprove] Approved a step: %s", signal.label);
+ progress();
+ } else if (signal.state === "approved") {
+ log.info("[autoApprove] The request was approved");
+ settle();
+ } else if (signal.state === "user") {
+ handOver(
+ REASONS[signal.reason || ""] || "the page is asking for input"
+ );
+ }
+ });
+
+ // A redirect chain through the identity provider can take a while and says
+ // nothing on the console; each hop is progress all the same.
+ contents.on("did-start-navigation", (details) => {
+ if (details.isMainFrame) progress();
+ });
+
+ contents.on(
+ "did-fail-load",
+ (_event, errorCode, errorDescription, _url, isMainFrame) => {
+ if (!isMainFrame) return;
+ // -3 is ABORTED, which is what a navigation cancelled by the next
+ // navigation reports - routine in a redirect chain.
+ if (errorCode === -3) return;
+ handOver(`the login page failed to load (${errorDescription})`);
+ }
+ );
+
+ window.on("closed", clearTimers);
+
+ totalTimer = setTimeout(
+ () => handOver("automatic approval ran out of time"),
+ TOTAL_MS
+ );
+ progress();
+}
diff --git a/src/aws-sso.ts b/src/aws-sso.ts
index 66bf062..5479de4 100644
--- a/src/aws-sso.ts
+++ b/src/aws-sso.ts
@@ -25,6 +25,7 @@ import { writeSsoConfig } from "./aws-config.js";
import { updateTrayIcon } from "./tray.js";
import { updateKubeConfig } from "./aws-eks.js";
import { attachLoginIndicator } from "./login-indicator.js";
+import { attachAutoApprove } from "./auto-approve.js";
import { formatHotkey } from "./hotkey.js";
import {
startRun,
@@ -303,42 +304,113 @@ async function getNewToken(
await waitForUserTrigger(expiresInSec * 1000);
}
+ const verificationUrl = startAuth.verificationUriComplete;
+ if (!verificationUrl) {
+ throw new Error("Missing verification URL from device authorization");
+ }
+
+ const useBrowser = behavior.loginMethod === "default_browser";
+ const silent = behavior.autoApprove !== false;
+
// In default-browser mode there is no window to watch, so windowOpen stays
// true and the poll loop runs until the device code expires.
let windowOpen = true;
let window: BrowserWindow | undefined;
+ let closingForBrowser = false;
+ let handedOver = false;
- const verificationUrl = startAuth.verificationUriComplete;
- if (!verificationUrl) {
- throw new Error("Missing verification URL from device authorization");
- }
+ const openInBrowser = async () => {
+ log.debug("[getNewToken] Opening login in default browser");
+ try {
+ await shell.openExternal(verificationUrl);
+ } catch (err) {
+ throw new Error(
+ `Failed opening login page in browser: ${describeError(err)}`,
+ { cause: err }
+ );
+ }
+ };
+
+ /** Never throws: failing to show a window must not fail the run. */
+ const showLoginWindow = (reason: string) => {
+ try {
+ if (!window || window.isDestroyed() || window.isVisible()) return;
+ log.info("[getNewToken] Showing the login window: %s", reason);
+ // Synchronously, before anything else: a WebAuthn account picker
+ // arrives as a modal that blocks this process, so a show queued
+ // behind it would come too late. The dock can catch up after.
+ window.show();
+ window.focus();
+ if (app.dock) app.dock.show();
+ } catch (err) {
+ log.error(
+ "[getNewToken] Could not show the login window: %s",
+ describeError(err)
+ );
+ }
+ };
+
+ /**
+ * The page needs the user. Give them whichever surface they asked for: the
+ * window that has been driving itself so far, or — for someone who picked
+ * the default browser, presumably because that is where their passkeys and
+ * saved passwords live — that browser, with the silent attempt dropped.
+ */
+ const handOverToUser = (reason: string) => {
+ if (!useBrowser) {
+ showLoginWindow(reason);
+ return;
+ }
+
+ // Once, however many things notice the page needs the user: every call
+ // after the first would be another browser tab.
+ if (handedOver) return;
+ handedOver = true;
+ log.info("[getNewToken] Handing the login to the browser: %s", reason);
+
+ openInBrowser().then(
+ () => {
+ // Only now that the browser is up. Closing the probe first and
+ // then failing to open anything would leave the run polling
+ // with nothing on screen to sign in with.
+ if (window && !window.isDestroyed()) {
+ closingForBrowser = true;
+ window.destroy();
+ }
+ },
+ (err: unknown) => {
+ log.error("[getNewToken] %s", describeError(err));
+ showLoginWindow("the browser could not be opened");
+ }
+ );
+ };
// Opening the login page happens inside the try: each attempt starts its
// own device authorization, so a window left behind by a throw would sit
// there pointing at a code nothing polls any more.
try {
- if (behavior.loginMethod === "default_browser") {
- log.debug("[getNewToken] Opening login in default browser");
- try {
- await shell.openExternal(verificationUrl);
- } catch (err) {
- throw new Error(
- `Failed opening login page in browser: ${describeError(
- err
- )}`,
- { cause: err }
- );
- }
+ // Under automatic approval even the default-browser user gets a window
+ // first: it stays off screen, and it is closed in favour of the browser
+ // the moment the page turns out to need them.
+ if (useBrowser && !silent) {
+ await openInBrowser();
} else {
- log.debug("[getNewToken] Opening login window");
- if (app.dock) await app.dock.show();
+ log.debug("[getNewToken] Opening login window (silent=%s)", silent);
+ if (!silent && app.dock) await app.dock.show();
window = new BrowserWindow({
width: 550,
height: 700,
center: true,
+ // Under automatic approval the window starts off screen and is
+ // shown only if the page turns out to need the user — and in
+ // default-browser mode it is never shown at all, it only probes
+ // whether this refresh needs anyone. Background throttling
+ // would slow the driver's scan loop to a crawl while hidden.
+ show: !silent,
webPreferences: {
nodeIntegration: false,
+ backgroundThrottling: false,
},
});
@@ -354,7 +426,7 @@ async function getNewToken(
// key or passkey — a sign-in page that starts listening as it boots
// asks for the key before any later hook could wrap the call. The
// default-browser path needs nothing: the browser has its own UI.
- await attachLoginIndicator(window);
+ await attachLoginIndicator(window, handOverToUser);
// Arming the overlay is asynchronous, and the user can close the
// window while it happens. Nothing below survives a destroyed
@@ -366,7 +438,15 @@ async function getNewToken(
});
}
+ // After the indicator, so the approval driver is not scanning a
+ // window that turned out to be gone, and before loadURL, so it is
+ // watching from the first document.
+ if (silent) attachAutoApprove(window, handOverToUser);
+
window.on("close", () => {
+ // Frost closing the probe in favour of the browser is not the
+ // user saying "not now".
+ if (closingForBrowser) return;
log.warn("[getNewToken] Login window closed");
windowOpen = false;
});
diff --git a/src/config.ts b/src/config.ts
index bdee091..7ad37c6 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -103,6 +103,7 @@ export const config = new Store({
refreshHotkey: { type: "string" },
historyRetentionDays: { type: "number" },
loginMethod: { type: "string" },
+ autoApprove: { type: "boolean" },
},
},
},
@@ -129,6 +130,11 @@ export interface BehaviorConfig {
* front, but the default browser can reach passkeys and password managers.
*/
loginMethod: "popup" | "default_browser";
+ /**
+ * Whether Frost clicks through the AWS approval pages itself, showing the
+ * login only when the page needs the user (see `src/auto-approve.ts`).
+ */
+ autoApprove: boolean;
}
export const DEFAULT_BEHAVIOR: BehaviorConfig = {
@@ -136,4 +142,5 @@ export const DEFAULT_BEHAVIOR: BehaviorConfig = {
refreshHotkey: "CmdOrCtrl+Shift+R",
historyRetentionDays: 7,
loginMethod: "popup",
+ autoApprove: true,
};
diff --git a/src/dashboard.html b/src/dashboard.html
index 904148a..9d26f2b 100644
--- a/src/dashboard.html
+++ b/src/dashboard.html
@@ -686,7 +686,8 @@
background: var(--sep);
}
.radio-row:hover { background: var(--hover); }
- .radio-row input[type=radio] { margin-top: 1px; width: 16px; height: 16px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer; }
+ .radio-row input[type=radio],
+ .radio-row input[type=checkbox] { margin-top: 1px; width: 16px; height: 16px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer; }
.radio-label { font-size: 13px; font-weight: 600; letter-spacing: -0.1px; }
.radio-desc { font-size: 12px; color: var(--text-2); margin-top: 3px; line-height: 1.5; }
@@ -988,6 +989,13 @@
Behavior
Opens in your browser, so passkeys and password managers work. Frost keeps waiting for you to approve.
// ── Behavior ────────────────────────────────────────────────────────────────
-let curBehavior = { refreshMode:'auto', refreshHotkey:'CmdOrCtrl+Shift+R', historyRetentionDays: 7, loginMethod:'popup' };
+let curBehavior = { refreshMode:'auto', refreshHotkey:'CmdOrCtrl+Shift+R', historyRetentionDays: 7, loginMethod:'popup', autoApprove:true };
let recording = false;
// Mirrors formatHotkey() in src/hotkey.ts, which the main process uses for
@@ -1585,6 +1593,8 @@
Credential Refresh
if (r) r.checked = true;
const l = radioWithValue('loginMethod', curBehavior.loginMethod);
if (l) l.checked = true;
+ const a = document.getElementById('auto-approve');
+ if (a) a.checked = curBehavior.autoApprove !== false;
document.getElementById('hotkey-pill').textContent = fmtHotkey(cfg.refreshHotkey);
}
@@ -1684,6 +1694,8 @@
Credential Refresh
if (r) curBehavior.refreshMode = r.value;
const l = document.querySelector('input[name=loginMethod]:checked');
if (l) curBehavior.loginMethod = l.value;
+ const a = document.getElementById('auto-approve');
+ if (a) curBehavior.autoApprove = !!a.checked;
await frost.saveBehavior(curBehavior);
dirty.behavior = false;
showNotice('behavior-notice');
diff --git a/src/login-indicator.ts b/src/login-indicator.ts
index 7eeebc2..ba3ef31 100644
--- a/src/login-indicator.ts
+++ b/src/login-indicator.ts
@@ -1,17 +1,13 @@
-import * as fs from "fs";
-import * as path from "path";
-import { fileURLToPath } from "url";
import { app, dialog, BrowserWindow } from "electron";
import log from "electron-log/main";
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+import { injectIntoEveryFrame, loadPageScript } from "./page-script.js";
/**
* The overlay is browser code with its own compile (`tsconfig.overlay.json`),
- * which emits a plain script next to the compiled main-process files. We read
- * it back as a string to inject.
+ * which emits a plain script next to the compiled main-process files;
+ * `src/page-script.ts` reads it back as a string to inject.
*/
-const OVERLAY_SCRIPT = path.join(__dirname, "login-overlay.js");
+const OVERLAY_SCRIPT = "login-overlay.js";
/** Must match SIGNAL in src/login-overlay.ts. */
const LOGIN_OVERLAY_SIGNAL = "__frost-login-overlay__:";
@@ -33,13 +29,6 @@ interface OverlaySignal {
kind?: string;
}
-/** Both `WebContents` and `WebFrameMain` can run a script for us. */
-interface ScriptTarget {
- executeJavaScript(code: string): Promise;
-}
-
-let overlaySource: string | null | undefined;
-
/**
* Run the overlay at document start, before the page's own scripts.
*
@@ -129,22 +118,6 @@ function detachDebugger(contents: Electron.WebContents) {
}
}
-function loadOverlaySource(): string | null {
- if (overlaySource === undefined) {
- try {
- overlaySource = fs.readFileSync(OVERLAY_SCRIPT, "utf8");
- } catch (err) {
- log.error(
- "[loginIndicator] Could not read %s: %s",
- OVERLAY_SCRIPT,
- err
- );
- overlaySource = null;
- }
- }
- return overlaySource;
-}
-
function describeAccount(
account: Electron.WebAuthnAccount,
index: number
@@ -176,15 +149,23 @@ function describeAccount(
* Call this before loading the login URL, and only for the login window: the
* account picker is a session-wide event, and it is unregistered when the
* window closes so it can never outlive the sign-in it belongs to.
+ *
+ * `onUserNeeded` is called the first time the page waits on a credential. Under
+ * automatic approval (`src/auto-approve.ts`) the window may not be on screen
+ * yet, and a key waiting for a touch behind a window nobody can see is the one
+ * wait that cannot resolve itself.
*/
-export async function attachLoginIndicator(window: BrowserWindow) {
+export async function attachLoginIndicator(
+ window: BrowserWindow,
+ onUserNeeded?: (reason: string) => void
+) {
const contents = window.webContents;
// Held on to now, while the window is alive: by the time `closed` fires,
// the WebContents is gone and even reading `contents.session` off it
// throws "Object has been destroyed". The Session itself outlives the
// window, so the listener below can still be removed from it.
const session = contents.session;
- const source = loadOverlaySource();
+ const source = loadPageScript(OVERLAY_SCRIPT);
// First, and awaited: the overlay is only useful if it is running before
// the login page's own scripts are, and both steps below need to finish
@@ -199,42 +180,13 @@ export async function attachLoginIndicator(window: BrowserWindow) {
return;
}
- if (source) {
- const inject = (target: ScriptTarget, where: string) => {
- target.executeJavaScript(source).catch((err) => {
- log.debug(
- "[loginIndicator] Could not inject the overlay into %s: %s",
- where,
- err
- );
- });
- };
-
- // Injecting again once the document is up covers the case where the
- // document-start hook could not be armed. The overlay no-ops when it
- // lands in a document twice, so the two cannot collide.
- contents.on("dom-ready", () => inject(contents, contents.getURL()));
-
- // A sign-in page may delegate WebAuthn to a cross-origin (an
- // identity provider embedded by the AWS page). Those run in their own
- // process, out of reach of both executeJavaScript on the WebContents
- // and the document-start hook, so follow sub-frames as they appear. The
- // overlay no-ops if it lands in a frame twice.
- contents.on("frame-created", (_event, details) => {
- const frame = details.frame;
- if (!frame || frame === contents.mainFrame) return;
- frame.on("dom-ready", () => {
- try {
- if (!frame.isDestroyed()) inject(frame, frame.url);
- } catch (err) {
- log.debug(
- "[loginIndicator] Sub-frame went away before injection: %s",
- err
- );
- }
- });
- });
- }
+ // Injecting again once each document is up covers the case where the
+ // document-start hook could not be armed, and the cross-origin an
+ // identity provider may put the sign-in in: those run in their own process,
+ // out of reach of both executeJavaScript on the WebContents and the
+ // document-start registration. The overlay no-ops when it lands in a
+ // document twice, so the two cannot collide.
+ if (source) injectIntoEveryFrame(contents, source, "loginIndicator");
let waiting = false;
let bounceId: number | null = null;
@@ -245,6 +197,10 @@ export async function attachLoginIndicator(window: BrowserWindow) {
waiting = true;
log.info("[loginIndicator] Login page is waiting for: %s", kind);
+ // Before anything else: there is no point titling or bouncing a window
+ // that automatic approval has kept off screen.
+ onUserNeeded?.("the page is waiting for a security key or passkey");
+
// The page keeps setting its own title, so block those updates for as
// long as ours is the more useful one.
contents.on("page-title-updated", holdTitle);
@@ -309,6 +265,11 @@ export async function attachLoginIndicator(window: BrowserWindow) {
) => {
let credentialId: string | null = null;
try {
+ // The picker below is modal and blocks this process, so ask for the
+ // window before it opens: a sheet attached to a window that has not
+ // been shown yet is a prompt nobody can answer.
+ onUserNeeded?.("your security key holds more than one credential");
+
const accounts = details.accounts || [];
log.info(
"[loginIndicator] Key offered %d credentials for %s",
diff --git a/src/page-script.ts b/src/page-script.ts
new file mode 100644
index 0000000..b814b3a
--- /dev/null
+++ b/src/page-script.ts
@@ -0,0 +1,79 @@
+import * as fs from "fs";
+import * as path from "path";
+import { fileURLToPath } from "url";
+import log from "electron-log/main";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Loading and injecting the scripts that run **in** the login page rather than
+ * in the main process (`src/login-overlay.ts`, `src/approve-overlay.ts`).
+ *
+ * Those have their own compile (`tsconfig.overlay.json`), which emits plain
+ * scripts next to the compiled main-process files; we read one back as a string
+ * and hand it to `executeJavaScript`. Both of the callers here need the same
+ * two things, and neither is obvious:
+ *
+ * - The source is read once and remembered, including the failure. A missing
+ * file is a broken build, not a transient error, and re-reading it on every
+ * page load would only repeat the same log line.
+ * - Injection has to follow sub-frames. `executeJavaScript` on a
+ * `WebContents` reaches the top frame only, and a sign-in page routinely
+ * puts the part we care about in a cross-origin ``.
+ */
+const sources = new Map();
+
+export function loadPageScript(fileName: string): string | null {
+ const cached = sources.get(fileName);
+ if (cached !== undefined) return cached;
+
+ let source: string | null = null;
+ try {
+ source = fs.readFileSync(path.join(__dirname, fileName), "utf8");
+ } catch (err) {
+ log.error("[pageScript] Could not read %s: %s", fileName, err);
+ }
+
+ sources.set(fileName, source);
+ return source;
+}
+
+/** Both `WebContents` and `WebFrameMain` can run a script for us. */
+interface ScriptTarget {
+ executeJavaScript(code: string): Promise;
+}
+
+/**
+ * Run `source` in every document the window loads, main frame and sub-frames
+ * alike, for as long as the window lives. The scripts guard themselves with a
+ * `window` flag, so landing in the same document twice is a no-op.
+ */
+export function injectIntoEveryFrame(
+ contents: Electron.WebContents,
+ source: string,
+ tag: string
+) {
+ const inject = (target: ScriptTarget, where: string) => {
+ target.executeJavaScript(source).catch((err) => {
+ log.debug("[%s] Could not inject into %s: %s", tag, where, err);
+ });
+ };
+
+ contents.on("dom-ready", () => inject(contents, contents.getURL()));
+
+ contents.on("frame-created", (_event, details) => {
+ const frame = details.frame;
+ if (!frame || frame === contents.mainFrame) return;
+ frame.on("dom-ready", () => {
+ try {
+ if (!frame.isDestroyed()) inject(frame, frame.url);
+ } catch (err) {
+ log.debug(
+ "[%s] Sub-frame went away before injection: %s",
+ tag,
+ err
+ );
+ }
+ });
+ });
+}
diff --git a/src/window.ts b/src/window.ts
index fb9ea39..c7fb8a2 100644
--- a/src/window.ts
+++ b/src/window.ts
@@ -174,10 +174,11 @@ export function setupIpc(callbacks: IpcCallbacks) {
handleFromDashboard("save-behavior", (behavior: BehaviorConfig) => {
log.info(
- "[save-behavior] mode=%s hotkey=%s loginMethod=%s",
+ "[save-behavior] mode=%s hotkey=%s loginMethod=%s autoApprove=%s",
behavior.refreshMode,
behavior.refreshHotkey,
- behavior.loginMethod
+ behavior.loginMethod,
+ behavior.autoApprove
);
config.set("behaviorConfig", behavior);
// Apply the (possibly shortened) retention period immediately rather
diff --git a/tsconfig.json b/tsconfig.json
index a72254d..cf6b02c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,9 +13,10 @@
"include": [
"src/**/*"
],
- // Built separately by tsconfig.overlay.json — it is browser code, and this
- // project's ESM output would not be injectable.
+ // Built separately by tsconfig.overlay.json — these are browser code, and
+ // this project's ESM output would not be injectable.
"exclude": [
- "src/login-overlay.ts"
+ "src/login-overlay.ts",
+ "src/approve-overlay.ts"
]
}
diff --git a/tsconfig.overlay.json b/tsconfig.overlay.json
index bfdb1bb..f1464d5 100644
--- a/tsconfig.overlay.json
+++ b/tsconfig.overlay.json
@@ -1,7 +1,8 @@
{
- // src/login-overlay.ts is the one file that runs in a browser rather than in
- // the main process, and it is injected into a page as source text — so it
- // needs its own compile. Differences from tsconfig.json that matter:
+ // src/login-overlay.ts and src/approve-overlay.ts are the files that run in a
+ // browser rather than in the main process, and they are injected into a page
+ // as source text — so they need their own compile. Each is emitted on its
+ // own; neither may import anything, or it would come out as a module. Differences from tsconfig.json that matter:
//
// module NodeNext would append `export {}` (this package is ESM),
// which is a syntax error in an injected classic script.
@@ -21,5 +22,5 @@
"rootDir": "src",
"outDir": "dist"
},
- "files": ["src/login-overlay.ts"]
+ "files": ["src/login-overlay.ts", "src/approve-overlay.ts"]
}