Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ jobs:
run: |
npm run lint

- name: 🔑 Check the login overlay
run: |
xvfb-run -a npm run check:overlay -- --no-sandbox

build:
needs: lint
uses: ./.github/workflows/build.yaml
Expand Down
55 changes: 48 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ npm only (`package-lock.json`; CI runs `npm ci`). Do not add a `yarn.lock`.

- `npm run build` — `tsc`, then copies the tray icons and `dashboard.html`.
- `npm run lint` — oxlint, configured by `.oxlintrc.json`.
- `npm run check:overlay` — drives the login window's credential overlay
through a real WebAuthn wait. Needs `npm run build` first, and a display:
`xvfb-run -a npm run check:overlay -- --no-sandbox`.
- `npm start` / `npm run package` / `npm run make` — Electron Forge.

Build and lint both run in CI. Neither proves the app launches; see
All three run in CI. Build and lint alone do not prove the app launches; see
"Verification limits".

## ESM
Expand All @@ -90,10 +93,30 @@ for a security key looks broken. `src/login-indicator.ts` fills that gap, wired
up before `loadURL` and only for the in-app window; the default-browser mode
gets the browser's own prompts.

- `src/login-overlay.ts` is compiled separately, read off disk, and injected on
every `dom-ready` — the main frame plus sub-frames reached through
`frame-created`, since injecting on the `WebContents` only reaches the top
frame. It re-injects safely; a `window` flag makes it a no-op.
- `src/login-overlay.ts` is compiled separately, read off disk, and injected
into every document the login window loads. It wraps
`navigator.credentials.{get,create}` and draws a toast while a request is
pending. It re-injects safely; a `window` flag makes it a no-op.
- **It has to be running before the page's own scripts are.** A page that asks
for the key as it boots — Google's security-key challenge does — has already
called `navigator.credentials.get()` by `dom-ready`, and a wrapper installed
after the call sees nothing: no toast, no title, no log line, just the silent
window the overlay exists to prevent. So `attachLoginIndicator()` registers
it through the `WebContents` debugger with
`Page.addScriptToEvaluateOnNewDocument`, which runs it at document start in
the page's own world. Three details there all fail silently:
- `Page.enable` first, or the registration resolves and does nothing.
- There has to be a renderer to talk to. On a window that has loaded
nothing the command never resolves — no error, a hung promise — which is
why `attachLoginIndicator()` loads `about:blank` first, is `async`, and
must be awaited before `loadURL`.
- It reaches the top document — across the cross-origin hop to the identity
provider, which changes renderer process — and frames sharing its
process, but not a cross-origin `<iframe>`, which has its own CDP target.
Injecting on `dom-ready`, the main frame plus sub-frames reached through
`frame-created` (injecting on the `WebContents` only reaches the top frame),
stays as the fallback for those and for a debugger that would not attach.
Being attached is also why the login window cannot open DevTools.
- Its compile differs deliberately: `module: ESNext` (NodeNext would append an
export statement, a syntax error in an injected classic script), `lib: DOM`
with no Node types, and no source map. It must stay import-free — an import
Expand All @@ -114,6 +137,17 @@ gets the browser's own prompts.
window was alive — reaching through the destroyed `WebContents` throws, which
Electron shows as a modal dialog on close.

`npm run check:overlay` is the regression test for all of that: it drives the
real `attachLoginIndicator()` on a real `BrowserWindow` against pages that ask
for a key before and after `dom-ready`, and asserts the wait reached the main
process. No key needed — only the start of the request matters, and that is
signalled the moment the page asks. CI runs it.

The overlay's drawing can also be checked on its own, since it is import-free
browser code: `new Function("window", source)` over the built
`dist/login-overlay.js` with a stub `window`, or a Playwright page — the only
way to try it against a strict CSP or hostile `!important` CSS.

## `~/.aws/config` ownership

`src/aws-config.ts` merges into the user's file and must never rewrite it
Expand Down Expand Up @@ -408,8 +442,15 @@ cannot be validated locally.

Headless, you can run build, lint, isolated Node checks of individual modules,
and — with network access to the Electron downloads — `package`/`make` **for
linux**. You **cannot** launch the app or validate macOS signing. Say so rather
than claiming the app works.
linux**.

You *can* also launch it, given those same downloads and `xvfb`:
`xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox .` boots the
whole app, and `npm run check:overlay` uses that to drive a real
`BrowserWindow`. That is how the overlay's document-start bug was found; build
and lint could not have. What it does **not** give you is a real desktop: no
tray interaction, no dock, no security key, no keychain, no macOS signing. Say
so rather than claiming the app works.

Windows packaging cannot be exercised either: the maker needs a Windows host,
and even plain packaging shells out to `rcedit`, which needs Wine off Windows.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"build": "tsc && npm run build:icons && npm run build:html && npm run build:overlay",
"watch": "tsc -w",
"lint": "oxlint src",
"check:overlay": "electron tools/check-login-overlay.js",
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
Expand Down
18 changes: 15 additions & 3 deletions src/aws-sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,22 @@ async function getNewToken(
event.preventDefault()
);

// Before loadURL, so the very first document gets the overlay that
// shows when the page is waiting for a security key or passkey. The
// Awaited, and before loadURL, so the very first document gets
// the overlay that shows when the page is waiting for a security
// 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.
attachLoginIndicator(window);
await attachLoginIndicator(window);

// Arming the overlay is asynchronous, and the user can close the
// window while it happens. Nothing below survives a destroyed
// window, and this is the same "I'm not logging in now" the close
// handler reports.
if (window.isDestroyed()) {
throw new LoginAbortedError("Login window closed", {
cancelledByUser: true,
});
}

window.on("close", () => {
log.warn("[getNewToken] Login window closed");
Expand Down
118 changes: 114 additions & 4 deletions src/login-indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,95 @@ interface ScriptTarget {

let overlaySource: string | null | undefined;

/**
* Run the overlay at document start, before the page's own scripts.
*
* `dom-ready` is far too late for a page that starts listening for the key as
* it boots — Google's security-key challenge calls
* `navigator.credentials.get()` from its boot script, so by the time
* `dom-ready` fires the request is already pending and wrapping
* `navigator.credentials` no longer sees it. The page then sits there silently,
* which is exactly the symptom the overlay exists to remove (issue #17).
*
* `Page.addScriptToEvaluateOnNewDocument` is the only hook early enough: it
* runs our source in the page's own world before any script the page ships,
* and it keeps doing so across the cross-origin hop from AWS to the identity
* provider — which moves the page to a different renderer process. It needs the
* debugger, so the login window cannot open DevTools while it is attached — no
* loss for a window that only ever shows a sign-in page.
*
* It covers the top-level document and any frame sharing its process. A
* cross-origin <iframe> gets its own process and its own CDP target, which this
* registration does not reach; those are left to the `dom-ready` injection
* below, which is in time for anything but a request the frame makes as it
* boots.
*
* Two things about this are easy to get wrong, and silently:
*
* - The `Page` domain has to be enabled first. Without it the registration
* still resolves, and still does nothing at all.
* - There has to be a renderer to talk to. On a window that has not loaded
* anything yet the command never resolves — not an error, just a promise
* that hangs — which is why the caller loads about:blank first.
*
* Best effort: when it cannot be armed the `dom-ready` injection below is still
* there, and still covers every page that asks for the key after it has loaded.
*/
async function injectAtDocumentStart(
contents: Electron.WebContents,
source: string
): Promise<void> {
try {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
await contents.debugger.sendCommand("Page.enable");
await contents.debugger.sendCommand(
"Page.addScriptToEvaluateOnNewDocument",
{
source,
// The window is sitting on about:blank right now, and the login
// page is the *next* document, so this only covers about:blank
// itself. Harmless, and it keeps the hook honest if that ever
// stops being true.
runImmediately: true,
}
);
log.debug("[loginIndicator] Overlay armed at document start");
} catch (err) {
log.warn(
"[loginIndicator] Could not arm the overlay at document start, " +
"falling back to dom-ready: %s",
err
);
}
}

/**
* Give the window a renderer for the debugger to reach, without showing the
* user anything it would not have shown anyway: a new BrowserWindow is blank
* until the login page loads either way.
*/
async function loadBlank(contents: Electron.WebContents): Promise<boolean> {
try {
await contents.loadURL("about:blank");
return !contents.isDestroyed();
} catch (err) {
log.warn("[loginIndicator] Could not load about:blank: %s", err);
return false;
}
}

function detachDebugger(contents: Electron.WebContents) {
try {
if (!contents.isDestroyed() && contents.debugger.isAttached()) {
contents.debugger.detach();
}
} catch (err) {
log.debug("[loginIndicator] Could not detach the debugger: %s", err);
}
}

function loadOverlaySource(): string | null {
if (overlaySource === undefined) {
try {
Expand Down Expand Up @@ -88,7 +177,7 @@ function describeAccount(
* 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.
*/
export function attachLoginIndicator(window: BrowserWindow) {
export async function attachLoginIndicator(window: BrowserWindow) {
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
Expand All @@ -97,6 +186,19 @@ export function attachLoginIndicator(window: BrowserWindow) {
const session = contents.session;
const source = loadOverlaySource();

// 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
// before the caller loads that page. Everything after this is wired up
// synchronously.
if (source && (await loadBlank(contents))) {
await injectAtDocumentStart(contents, source);
}

if (window.isDestroyed()) {
log.warn("[loginIndicator] Window went away while arming the overlay");
return;
}

if (source) {
const inject = (target: ScriptTarget, where: string) => {
target.executeJavaScript(source).catch((err) => {
Expand All @@ -108,12 +210,16 @@ export function attachLoginIndicator(window: BrowserWindow) {
});
};

// 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 <iframe> (an
// identity provider embedded by the AWS page), and executeJavaScript
// on the WebContents only reaches the top frame — so follow sub-frames
// as they appear. The overlay no-ops if it lands in a frame twice.
// 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;
Expand Down Expand Up @@ -242,6 +348,10 @@ export function attachLoginIndicator(window: BrowserWindow) {

session.on("select-webauthn-account", selectAccount);

// `close`, not `closed`: the debugger has to be let go while the
// WebContents it is attached to is still there to let go of.
window.on("close", () => detachDebugger(contents));

window.on("closed", () => {
stopWaiting();
session.off("select-webauthn-account", selectAccount);
Expand Down
Loading