From 20f92740baaacec713540afbf67aeebdbdf2bf63 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 18 Aug 2026 12:47:12 +0000
Subject: [PATCH] Approve the AWS sign-in without showing a window
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AWS SSO's device authorization is a multi-step approval — confirm the
request, sign in, grant access — and none of the approval steps ask for
anything the user has to supply. Frost now clicks them itself: the login
page loads in a window that stays off screen, and a refresh covered by a
live identity provider session finishes without a window, a browser tab
or a Dock icon appearing at all.
The window comes up the moment the page needs the user — a password or a
one-time code to type, a security key to touch, a passkey to pick — and
on anything off the expected path: a page Frost cannot read, a load that
fails, a flow that stops making progress. In default-browser mode that
same moment opens the browser instead, which is where that user's
passkeys and saved passwords live, and the hidden probe is destroyed once
the browser is actually up.
Clicking is deliberately narrow, because the buttons next to the ones we
want deny the request: only on the device-authorization hosts, only
controls matched by AWS's own ids or by an exact label, never one whose
label reads like a refusal, and only a few per document. Anything
unrecognised stalls, and stalling shows the window.
- src/approve-overlay.ts drives the page, alongside the existing overlay
and under the same compile; src/auto-approve.ts watches it and owns the
timers that guarantee the login is always eventually handed over.
- src/page-script.ts holds the loading and sub-frame injection both
injected scripts need — the overlay's dom-ready fallback for frames the
document-start hook cannot reach, and the approval driver.
- attachLoginIndicator() takes an onUserNeeded callback: a WebAuthn wait,
and especially the modal account picker, must not happen behind a
window nobody can see.
- Behavior → Approve automatically turns it off, on by default.
Closes #1
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_018XK1mi3rSPLzxu7XUhyC6a
---
AGENTS.md | 72 ++++++-
README.md | 25 ++-
docs/docs/login.html | 40 ++++
docs/docs/settings-behavior.html | 16 ++
docs/docs/settings.html | 6 +
src/approve-overlay.ts | 318 +++++++++++++++++++++++++++++++
src/auto-approve.ts | 159 ++++++++++++++++
src/aws-sso.ts | 118 ++++++++++--
src/config.ts | 7 +
src/dashboard.html | 18 +-
src/login-indicator.ts | 99 +++-------
src/page-script.ts | 79 ++++++++
src/window.ts | 5 +-
tsconfig.json | 7 +-
tsconfig.overlay.json | 9 +-
15 files changed, 867 insertions(+), 111 deletions(-)
create mode 100644 src/approve-overlay.ts
create mode 100644 src/auto-approve.ts
create mode 100644 src/page-script.ts
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"]
}