Skip to content
Open
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
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ of them says the user is needed (issue #1). Keep these true:
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 hand-over goes to the surface the settings ask for**, which includes
notify mode: with automatic approval on, the notification moves from the
start of every refresh to the hand-over, and nothing opens until the user
answers it (`triggerPendingAuth()`, the same trigger the hotkey uses). The
wait is cancelled when the run ends, or `hasPendingAuth()` would keep saying
yes and swallow the next hotkey press.
- The console signal is forgeable by the page, exactly like the overlay's, so
it may only ever decide whether to show a window.

Expand Down Expand Up @@ -246,14 +252,15 @@ without the app knowing it is under test, and all four are worth keeping:
walked away.

`HOME` and the electron-store move to a temp directory, so a run touches
nothing of yours. Nine scenarios, ~30s, one per outcome:
nothing of yours. Ten scenarios, ~30s, one per outcome:

| Scenario | What must be true |
| --- | --- |
| Portal session is live | Token collected, **no window ever shown**, no notification |
| Federated, IdP session is live | Same, and the cross-origin hop happened |
| Federated, IdP wants a password | Window shown; after the test signs in, the driver finishes the approval |
| Notify mode | Nothing opens until `triggerPendingAuth()`, even when the approval needs nobody |
| Notify mode, approval needs nobody | No notification and no window: it finishes in silence |
| Notify mode, the page needs the user | **Notified at that moment**, nothing shown until `triggerPendingAuth()` |
| Default-browser mode | `openExternal` gets the verification URL, no window shown |
| Automatic approval off | Window visible from the start, nothing driven |
| IdP page with an "Allow access" button | Never clicked — it is not our host |
Expand All @@ -264,7 +271,8 @@ It is a real test, not a smoke test, and each mutation fails exactly one
scenario: the host rule returning `false` fails both approval scenarios and
returning `true` fails the identity-provider one; dropping the refusal rule
fails the unrecognised-page one; `isUserPresent()` returning `true`
unconditionally fails the unattended one. Confirm with a mutation before
unconditionally fails the unattended one; skipping the notify branch of the
hand-over fails the notify ones. Confirm with a mutation before
trusting a change here.

The matching rules alone can also be exercised without a browser —
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ 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.

Which surface you get follows your settings: the Frost window, your default
browser, or — in *Notification + hotkey* mode — a notification saying the
sign-in needs you, with nothing opening until you press the hotkey or click it.
The interruption arrives only for the refresh that could not finish on its own.

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
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ <h2 id="auto-approve">Automatic approval</h2>
clicked, whatever else about it matches. Anything unrecognised is left
alone for you to answer.
</p>
<p>
Which surface that is follows your settings. In the default mode the
window simply appears; in <em>Notification + hotkey</em> mode nothing
appears unannounced — Frost posts a notification saying the sign-in needs
you, and opens the page when you press the hotkey or click it. Either
way the interruption arrives only for the refresh that could not finish
on its own.
</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
Expand Down
14 changes: 14 additions & 0 deletions docs/docs/settings-behavior.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ <h3>Notification + hotkey</h3>
are ready, and the login page opens then. Nothing appears on screen until
you ask for it.
</p>
<p>
With <a href="#auto-approve">Approve automatically</a> on — the default —
the notification moves to where it is worth having. Frost works through
the approval off screen first, and only tells you when the sign-in turns
out to need you: a password, a one-time code, a security key. A refresh
your identity provider session covers passes in silence, with no
notification and nothing to press.
</p>
<p>
The cost is that credentials stay expired until you act — commands fail
with <code>ExpiredToken</code> in the meantime — and if you never act, the
Expand Down Expand Up @@ -122,6 +130,12 @@ <h3 id="auto-approve">Approve automatically</h3>
you picked — as soon as it asks for something only you can give, or if
the flow stops making progress.
</p>
<p>
In <a href="#mode">notify mode</a> it also decides what you are told:
instead of a notification before every refresh, you get one only when a
sign-in needs you, and nothing opens until you press the hotkey or click
it.
</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
Expand Down
100 changes: 89 additions & 11 deletions src/aws-sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ let timeoutId: NodeJS.Timeout | undefined;
let nextRefreshAt: number | null = null;
let consecutiveFailures = 0;
let pendingAuthResolve: (() => void) | null = null;
let pendingAuthCancel: (() => void) | null = null;

/**
* The login that replaces one nobody finished, and the earliest it may start.
Expand All @@ -87,26 +88,42 @@ export function hasPendingAuth(): boolean {
}

export function triggerPendingAuth() {
if (pendingAuthResolve) {
pendingAuthResolve();
pendingAuthResolve = null;
}
pendingAuthResolve?.();
}

/**
* Drop a trigger the user never answered, when the run that was waiting on it
* has ended. Without this `hasPendingAuth()` keeps saying yes, and the next
* hotkey press resolves a dead wait instead of starting the refresh the user
* was asking for.
*/
function cancelPendingAuth() {
pendingAuthCancel?.();
}

function waitForUserTrigger(timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const tid = setTimeout(() => {
const done = () => {
clearTimeout(tid);
pendingAuthResolve = null;
pendingAuthCancel = null;
};
const tid = setTimeout(() => {
done();
reject(
new LoginAbortedError(
"Timed out waiting for user to trigger auth"
)
);
}, timeoutMs);
pendingAuthResolve = () => {
clearTimeout(tid);
done();
resolve();
};
pendingAuthCancel = () => {
done();
reject(new LoginAbortedError("The login ended before you answered"));
};
});
}

Expand Down Expand Up @@ -170,15 +187,20 @@ export function getLoginRetryStatus(): LoginRetryStatus {

/**
* Whether a refresh can get all the way through with nobody at the machine.
* Notification mode waits for the hotkey by design, and without automatic
* approval the login page is the user's to work through, so in both cases an
* unattended attempt could only ask AWS for a device code it cannot redeem.
* Automatic approval is what makes that possible; without it the login page is
* the user's to work through, and an unattended attempt could only ask AWS for
* a device code it cannot redeem.
*
* Notification mode is no longer an exception. It used to wait for the hotkey
* before anything opened, which nobody was there to press; it now speaks up
* only when the page turns out to need a person, so a refresh that needs
* nobody finishes as silently as any other.
*/
function canRefreshUnattended(): boolean {
const behavior =
(config.get("behaviorConfig") as BehaviorConfig | undefined) ||
DEFAULT_BEHAVIOR;
return behavior.autoApprove !== false && behavior.refreshMode !== "notify";
return behavior.autoApprove !== false;
}

/**
Expand Down Expand Up @@ -438,6 +460,7 @@ async function getNewToken(
DEFAULT_BEHAVIOR;
const useBrowser = behavior.loginMethod === "default_browser";
const silent = behavior.autoApprove !== false;
const notifyMode = behavior.refreshMode === "notify";

// Nothing below can get through without the user: either they asked to be
// notified and press the hotkey first, or automatic approval is off and the
Expand Down Expand Up @@ -476,7 +499,13 @@ async function getNewToken(
const tokenExpires = moment().add(expiresInSec, "seconds");
let pollIntervalMs = (startAuth.interval ?? 5) * 1000;

if (behavior.refreshMode === "notify") {
// Notify mode is a promise not to put a login page in front of the user
// unannounced. When Frost is about to open one either way, that promise is
// kept here, before anything opens. Under automatic approval there is
// nothing to announce yet — most refreshes ask the user for nothing at all
// — so the notification moves to the moment one turns out to need them, in
// `handOverToUser()` below.
if (notifyMode && !silent) {
log.info("[getNewToken] Notify mode: showing notification");
const note = new Notification({
title: "Frost — AWS Credentials Renewal",
Expand Down Expand Up @@ -579,6 +608,45 @@ async function getNewToken(
);
};

/** What is left of the device code's life. After it there is nothing to show. */
const remainingMs = () => Math.max(tokenExpires.diff(moment()), 0);

/** Raised once, however many things notice the page needs the user. */
let asked = false;

/**
* Notify mode, under automatic approval: say that this refresh needs a
* person, and wait for them to say when. Nothing opens until they do — and
* this notification is the first they hear of the refresh at all, because
* the ones that need nobody never speak.
*/
const askThenShow = async (reason: string) => {
log.info("[getNewToken] Notify mode: asking before showing the login");
const note = new Notification({
title: "Frost — Sign-in Needed",
body: `Your AWS sign-in needs you. Press ${formatHotkey(
behavior.refreshHotkey
)} or click here to continue.`,
});
note.on("click", () => triggerPendingAuth());
note.show();

// Asking for a refresh while this is waiting is the user saying yes:
// they get this live page rather than "a refresh is already running".
showParkedLogin = () => triggerPendingAuth();

try {
await waitForUserTrigger(remainingMs());
} catch (err) {
log.warn(
"[getNewToken] The sign-in notice went unanswered: %s",
describeError(err)
);
return;
}
showToUser(reason);
};

/**
* What the window's drivers call when the page stops being something Frost
* can get through on its own.
Expand All @@ -604,6 +672,15 @@ async function getNewToken(
return;
}

// Somebody is here, but notify mode asked to be told rather than
// interrupted. The page stays hidden and driven until they answer.
if (notifyMode && silent) {
if (asked) return;
asked = true;
void askThenShow(reason);
return;
}

showToUser(reason);
};

Expand Down Expand Up @@ -758,6 +835,7 @@ async function getNewToken(
}
throw new LoginAbortedError("Login timed out");
} finally {
cancelPendingAuth();
// Nothing is holding a login any more, whatever happened to this one.
showParkedLogin = null;
// destroy(), not close(): cleanup must not depend on the remote page
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ <h1>Behavior</h1>
<input type="radio" name="refreshMode" value="notify">
<div>
<div class="radio-label">Notification + hotkey</div>
<div class="radio-desc">A system notification appears. Press the hotkey or click the notification when you're ready to log in.</div>
<div class="radio-desc">A system notification appears and nothing opens until you press the hotkey or click it. With automatic approval on, you are only told when a sign-in actually needs you.</div>
</div>
</label>
<div class="section-row" style="display:flex;align-items:center;gap:14px;">
Expand Down
50 changes: 38 additions & 12 deletions tools/test-auto-approve.js
Original file line number Diff line number Diff line change
Expand Up @@ -551,32 +551,53 @@ async function signInShowsTheWindow(frost) {

/**
* Notify mode is a promise not to put a login page in front of the user
* unannounced, and it keeps that promise by announcing every refresh before
* anything opens — including this one, which the portal session would have
* carried through without asking anybody anything.
* unannounced — not a promise to announce refreshes. One the portal session
* carries through asks them for nothing, so it says nothing.
*/
async function notifyModeAsksFirst(frost) {
async function notifyModeStaysSilent(frost) {
newRun("a refresh in notify mode that needs nobody", {});

await withTimeout(frost.refresh(), "the refresh to finish");

check(frost.hasToken(), "no token was stored");
check(!run.everVisible, "the login window was shown");
check(
run.notifications.length === 0,
"the user was interrupted about a refresh that needed nothing from them"
);
}

/**
* And when one does need them, that is when notify mode speaks — and still
* opens nothing until they say so.
*/
async function notifyModeAsksFirst(frost) {
newRun("a refresh that needs the user, in notify mode", { idp: "signin" });

const refreshing = frost.refresh();
await waitFor(frost.hasPendingAuth, "Frost to ask before starting the login");
await waitFor(frost.hasPendingAuth, "Frost to ask before showing the login");

check(!run.everVisible, "the login window was shown without asking");
check(
run.notifications.length > 0,
"nothing was said before the refresh waited for the user"
run.notifications.some((options) => /sign-in/i.test(options.title)),
"no sign-in notification was raised"
);
check(
!run.trail.some((entry) => entry.startsWith("page:")),
"the login page was opened before the go-ahead"
run.trail.includes(`page:${IDP}/signin`),
"the notification came before the page that needed the user"
);

// The user presses the hotkey, or clicks the notification.
frost.triggerPendingAuth();

const window = await waitFor(
visibleWindow,
"the login window after the go-ahead"
);
await signInOnPage(window);
await withTimeout(refreshing, "the refresh to finish");

// From here it is an ordinary silent approval.
check(frost.hasToken(), "no token was stored");
check(!run.everVisible, "the login window was shown");
}

/**
Expand Down Expand Up @@ -735,7 +756,12 @@ const TESTS = [
{},
],
[
"notify mode: nothing opens before the user says go",
"notify mode: silent when the sign-in needs nobody",
notifyModeStaysSilent,
{ refreshMode: "notify" },
],
[
"notify mode: notified when it needs you, shown on the go-ahead",
notifyModeAsksFirst,
{ refreshMode: "notify" },
],
Expand Down