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
72 changes: 63 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 `<iframe>`.
- **`src/auto-approve.ts`** — the main-process half of automatic approval (see
its own section). Its timers are what guarantee a hidden login window always
ends up in front of the user.
- **`src/browsing-data.ts`** — `clearBrowsingData()`. The login window takes no
partition, so it clears `session.defaultSession`: `clearData()` plus
`clearAuthCache()`, which that does not cover. Settings are left alone.
Expand Down Expand Up @@ -113,10 +125,10 @@ gets the browser's own prompts.
- 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.
Injecting on `dom-ready`, main frame plus sub-frames
(`src/page-script.ts`), 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 @@ -137,6 +149,11 @@ gets the browser's own prompts.
window was alive — reaching through the destroyed `WebContents` throws, which
Electron shows as a modal dialog on close.

`attachLoginIndicator()` takes an optional `onUserNeeded` callback and calls it
when a credential request starts and before the account picker opens. Under
automatic approval the window may not be on screen yet, and a modal sheet on a
window nobody can see is a prompt nobody can answer.

`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
Expand All @@ -148,6 +165,43 @@ 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.

## Automatic approval

AWS SSO's device authorization is a multi-step approval — confirm the request,
sign in, grant access — and none of the approval steps carry anything the user
has to supply. `src/approve-overlay.ts` clicks them, `src/auto-approve.ts`
watches, and `getNewToken()` keeps the window hidden (`show: false`) until one
of them says the user is needed (issue #1). Keep these true:

- **The login always ends up somewhere.** `auto-approve.ts` runs a stall timer
(reset by navigations, loads and clicks) and an absolute one, and hands over
on a failed load or an unreadable script. Every exit from the silent attempt
reaches `onUserNeeded`, which becomes `window.show()` — or, in
default-browser mode, `shell.openExternal`, destroying the hidden probe only
once the browser is up. A new way for the flow to end without arriving there
is a refresh that hangs invisibly until the device code expires.
- **`backgroundThrottling: false` on the window.** Chromium throttles timers in
a window that is not visible, and the driver's scan loop is a timer.
- **Clicking is deliberately narrow.** Only on the device-authorization hosts
(`*.awsapps.com`, `device.sso.<region>.amazonaws.com`), only controls matched
by AWS's own ids or an *exact* label, never one whose label reads like a
refusal, and a few per document at most. Anything else stalls, and stalling
shows the window — the safe failure. The buttons next to the ones we want
deny the request.
- **"The user is needed" is an empty visible field.** The device page's own
code field arrives prefilled from `verificationUriComplete`; an empty one is
a password, a username or a one-time code. Scanning continues after the user
takes over, so the approval steps after their sign-in are still clicked.
- The console signal is forgeable by the page, exactly like the overlay's, so
it may only ever decide whether to show a window.

`approve-overlay.ts` is import-free browser code, so it is checked the same way
the overlay's drawing is: `new Function("window", source)` over the built
`dist/approve-overlay.js` with a stub `window` whose `document.querySelectorAll`
answers the two selectors it uses, asserting which stub controls were clicked
and what it logged. That covers every page shape — confirm, allow, sign-in,
approved, unrecognised — without a browser.

## `~/.aws/config` ownership

`src/aws-config.ts` merges into the user's file and must never rewrite it
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ everything in one place:
get started.
- **Behavior** — whether Frost refreshes credentials automatically or just
notifies you, whether the AWS login page opens in a Frost window or in your
default browser (where passkeys and password managers work), plus the global
refresh hotkey (`⌘⇧R` on macOS, `Ctrl+Shift+R` elsewhere, rebindable). A
default browser (where passkeys and password managers work), whether Frost
approves the sign-in for you (see below), plus the global refresh hotkey (`⌘⇧R` on macOS, `Ctrl+Shift+R` elsewhere, rebindable). A
**Test** button fires a sample notification, which is also how you grant
Frost notification permission on macOS the first time, and **Clear
Cookies** wipes the cookies and local storage the in-app login window keeps
Expand All @@ -92,6 +92,27 @@ Frost has no backend. Your token, profiles, and run history are stored in a
local configuration file and nothing is transmitted anywhere — no analytics, no
telemetry, no crash reporting.

## Automatic Approval

AWS SSO's approval is a multi-step affair: confirm the request, sign in, grant
access. None of those steps needs anything you have to type — they are buttons
on pages that say what they are about to do — so Frost clicks them itself.

The login page is loaded in a window that stays off screen, and when your
identity provider session is still live the refresh finishes without anything
appearing at all: no window, no browser tab, no Dock icon. The window comes up
only when the page needs *you* — a password, a one-time code, a security key to
touch — or when anything is off the expected path: a page Frost cannot read, a
button AWS has renamed, a load that fails, a flow that stops progressing. In
default-browser mode that same moment opens your browser instead.

Frost clicks only on the AWS access portal's own approval pages, never on your
identity provider's, and only controls it recognises by AWS's id or by an exact
label ("Confirm and continue", "Allow access"). Anything that reads like a
refusal is never clicked. Turn the whole thing off with **Approve
automatically** under Behavior, and every sign-in shows its window from the
start.

## Security Keys and Passkeys

When you sign in through Frost's own login window (the default — see
Expand Down
40 changes: 40 additions & 0 deletions docs/docs/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,46 @@ <h2 id="flow">What happens during sign-in</h2>
</p>
</div>

<h2 id="auto-approve">Automatic approval</h2>
<p>
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.
</p>
<p>
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.
</p>
<p>
The window comes up the moment the page needs <em>you</em>: 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.
</p>
<p>
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 <em>Confirm and continue</em>
or <em>Allow access</em>. A control that reads like a refusal is never
clicked, whatever else about it matches. Anything unrecognised is left
alone for you to answer.
</p>
<p>
Turn it off with <strong>Approve automatically</strong> on the Behavior
page and every sign-in shows its window from the start, as it did
before.
<a href="settings-behavior.html#auto-approve">Behavior settings →</a>
</p>

<h2 id="where">Where the login page opens</h2>
<p>
The <strong>Login Page</strong> setting on the Behavior page picks
Expand Down
16 changes: 16 additions & 0 deletions docs/docs/settings-behavior.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ <h2 id="login-page">Login Page</h2>
<a href="login.html">More about signing in →</a>
</p>

<h3 id="auto-approve">Approve automatically</h3>
<p>
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.
</p>
<p>
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.
<a href="login.html#auto-approve">How automatic approval works →</a>
</p>

<h3 id="clear-cookies">Clear cookies and local storage</h3>
<p>
The in-app login window keeps its cookies and local storage between
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ <h2 id="table">Every setting</h2>
<td>In-app window</td>
<td>Whether AWS sign-in opens in a Frost window or your default browser.</td>
</tr>
<tr>
<td>Approve automatically</td>
<td><a href="settings-behavior.html#auto-approve">Behavior</a></td>
<td>On</td>
<td>Whether Frost clicks through the AWS approval steps off screen, showing the login page only when you are needed.</td>
</tr>
<tr>
<td>Refresh Hotkey</td>
<td><a href="settings-behavior.html#hotkey">Behavior</a></td>
Expand Down
Loading