aws-sso: read the session end from AWS's token cap; log in 30 s after it
Overnight 2026-10-12: in the session's last hour AWS issued tokens capped at the end (00:18:48 local, 8 h after sign-in); the old 'expiresAt advanced' rule read their millisecond jitter as 'refreshed'. A refusal at 00:13 led to a login that inherited the ending session, and the 30-min wait then delayed the useful login to 00:47. Now: new full token = refreshed; new token issued for < 55 min = capped, its expiry is the end (session_end_seen, status 'until HH:MM'), login at end+30 s; nothing written = refused, login at once. anchor logs token_min/inherited from the cap, not a clock. 2 min between unprompted logins after a success; 30 min, 1 h, 2 h, 4 h after unanswered ones.
This commit is contained in:
@@ -394,23 +394,28 @@ Status and logs show only the ntfy server and a fingerprint of the topic.
|
||||
**How it detects the end.** Per AWS ("Understanding authentication sessions"), the
|
||||
CLI's 1-hour token is refreshed "as long as the underlying user interactive session
|
||||
remains valid", meaning the browser's AWS sign-in. That sign-in is admin-capped and
|
||||
its length is not predictable (measured 8 h on 2026-10-10, at most 6 h on 2026-10-11).
|
||||
When it ends, the next refresh is refused. Inside the SDK's 5-minute pre-expiry
|
||||
window, a refused refresh is *silent*: the SDK keeps returning the still-valid token.
|
||||
So the keeper makes that refresh attempt itself as soon as the window opens: it uses
|
||||
pi's own `@aws-sdk/token-providers` and the same cache file. It then checks whether
|
||||
`expiresAt` in `~/.aws/sso/cache/<sha1(sso-session)>.json` advanced. If it did not,
|
||||
the session is over, and the keeper logs in right away while the old token still
|
||||
covers requests. Nothing is renewed on a clock: a login while the browser sign-in
|
||||
is still valid completes silently ("…can be used until your session expires") but
|
||||
inherits its end, so it gains nothing. Tokens are only ever logged as `sha256[:8]`
|
||||
measured 8 h here (2026-10-10 from the portal cookie; 2026-10-12 from the token cap
|
||||
below). In the session's last hour AWS still issues new 1-hour tokens but **caps them
|
||||
at the session's end**; after the end it refuses. Inside the SDK's 5-minute pre-expiry
|
||||
window both are *silent*: the SDK keeps returning a still-valid token. So the keeper
|
||||
makes that refresh attempt itself as soon as the window opens, with pi's own
|
||||
`@aws-sdk/token-providers` and the same cache file
|
||||
(`~/.aws/sso/cache/<sha1(sso-session)>.json`), and reads the result from the file:
|
||||
a new full token = alive; a new token issued for under 55 min = **capped**, and its
|
||||
expiry is the session's end; nothing written = **refused**. On a cap the keeper logs
|
||||
in 30 s after that end. On a refusal it logs in at once while the old token still
|
||||
covers requests. Refusals also happened with time left (cause unknown): a probe at
|
||||
00:13 local on 2026-10-12, 5 min before the end, and a failed request ~2 h before it;
|
||||
both times a login worked at once. Nothing is renewed on a clock, and never
|
||||
before a known end: a login while the browser sign-in is valid completes silently
|
||||
("…can be used until your session expires") but inherits its end, so it gains nothing. Tokens are only ever logged as `sha256[:8]`
|
||||
fingerprints (`~/.pi/agent/aws-sso/events.jsonl`); the token the SDK returns is
|
||||
dropped unread.
|
||||
|
||||
**Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every
|
||||
10 min once the token has run out). Unprompted logins back off (retries after 30 min,
|
||||
1 h, 2 h, then every 4 h), so one waiting for you does not pile up tabs or
|
||||
pushes overnight. `before_agent_start`
|
||||
10 min once the token has run out). At least 2 min pass between unprompted logins;
|
||||
after an unanswered one the keeper retries after 30 min, 1 h, 2 h, then every 4 h, so
|
||||
a login waiting for you does not pile up tabs or pushes overnight. `before_agent_start`
|
||||
checks first if the token is in its window and logs in (and waits) if the session
|
||||
is over. `message_end` is the fallback: if a request still fails on an expired
|
||||
token, the keeper renews and sends `continue` (once per 10 min), also when you
|
||||
|
||||
+28
-15
@@ -14,17 +14,25 @@ Reference (commands, variables, mechanism): the `aws-sso.ts` section of the
|
||||
|
||||
- The **1-hour access token** is renewed by the AWS SDK by itself. You never see that.
|
||||
- The **session** behind it is your browser's AWS sign-in. Its maximum length is set
|
||||
by your IdC admin, and it is not predictable from the client: we measured 8 h on
|
||||
one day and at most 6 h on the next. When it ends, AWS refuses further renewals.
|
||||
by your IdC admin (measured 8 h here). In its last hour AWS still issues new tokens
|
||||
but caps them at the session's end; after the end it refuses renewals.
|
||||
- A login while the browser sign-in is still valid completes silently but **inherits
|
||||
the same end**, so renewing early gains nothing. Only a login after the end starts
|
||||
a new session.
|
||||
- AWS sometimes refuses a renewal while the session still has time left (cause
|
||||
unknown): seen once by the keeper's check, 5 min before the end, and once as a failed
|
||||
request about 2 h before it. A login then worked at once.
|
||||
|
||||
So the keeper **detects** the end instead of guessing it. In the last 5 minutes of
|
||||
each 1-hour token it makes the renewal attempt itself, through the same SDK pi uses.
|
||||
If AWS refuses, it starts the login at once, while the old token still covers
|
||||
requests for a few minutes. If a request fails anyway (pi was closed, the laptop
|
||||
slept), it logs in and then sends `continue` so the interrupted run resumes.
|
||||
So the keeper **reads** the end instead of guessing it. In the last 5 minutes of each
|
||||
1-hour token it makes the renewal attempt itself, through the same SDK pi uses:
|
||||
|
||||
- **new full token:** the session goes on;
|
||||
- **new token cut short:** its expiry is the session's end, and the keeper logs in
|
||||
30 s after it (the cut-short token covers requests until then);
|
||||
- **refused:** it logs in at once, while the old token still covers requests.
|
||||
|
||||
If a request fails anyway (pi was closed, the laptop slept), it logs in and then sends
|
||||
`continue` so the interrupted run resumes.
|
||||
|
||||
The permanent fix is administrative. Your IdC admin can raise the session length
|
||||
(up to 90 days) in the IAM Identity Center console. With that, the keeper has almost
|
||||
@@ -98,8 +106,9 @@ PI_AWS_SSO_MODE=remote # only on headless/remote devices
|
||||
```text
|
||||
keeper: ON · local (default) · push: ntfy.sh (topic 3fb657b1) · auto-continue: on
|
||||
sso-session: MySession (profile chain Bedrock → Base)
|
||||
session: alive · started ≈ 22:19 · 1-h token until 23:19 (next check from 23:14)
|
||||
last check: 23:14 · refreshed
|
||||
session: alive since ≈ 00:47 · ends 08:47 (AWS caps tokens there) → login just after
|
||||
token: valid until 08:47 · next check from 08:42
|
||||
last check: 07:43 · capped: session ends 08:47
|
||||
last login: 2026-10-11 22:19:24 · ok (local)
|
||||
```
|
||||
|
||||
@@ -107,8 +116,10 @@ The topic is shown only as a fingerprint, never in clear.
|
||||
|
||||
## What happens when a session ends
|
||||
|
||||
1. **Detected:** in the 5 minutes before the hourly token runs out, AWS refuses the
|
||||
renewal. Log: `probe … result: refused`.
|
||||
1. **Detected:** in the session's last hour the hourly check gets a token cut short at
|
||||
the end (`probe … result: capped`, then `session_end_seen`), and the status line
|
||||
shows `AWS ✓ until 08:47`. The login starts 30 s after that time. If AWS refuses
|
||||
the renewal instead (`probe … result: refused`), the login starts at once.
|
||||
2. **Login starts:**
|
||||
- `local`: a tab opens on the host. If it has not finished after 60 s, you get
|
||||
the push "AWS SSO login waiting".
|
||||
@@ -119,7 +130,7 @@ The topic is shown only as a fingerprint, never in clear.
|
||||
noticed.
|
||||
4. **If nobody approves:** the old token runs out, the task's next request fails
|
||||
and the run pauses. The keeper tries again after 30 min, then 1 h, 2 h, and then
|
||||
every 4 h, so an unattended night produces only a few pushes. Each attempt brings
|
||||
every 4 h (between successful logins it waits only 2 min), so an unattended night produces only a few pushes. Each attempt brings
|
||||
a fresh code, and the keeper waits up to 10 minutes for each. When you approve,
|
||||
even hours later, the run resumes with `continue`.
|
||||
|
||||
@@ -136,8 +147,9 @@ The status line shows `AWS ✓ since 22:19`, `AWS ⏳ approve ABCD-EFGH`,
|
||||
|
||||
| Event | Meaning |
|
||||
|---|---|
|
||||
| `anchor` | A new refresh token was seen. `inherited: true` = silent renewal inside the same session; `false` = new session |
|
||||
| `probe` | Renewal attempt inside the 5-min window: `refreshed` (fine), `refused` (session over), `unavailable` (pi's AWS SDK could not be loaded; says nothing about the session) |
|
||||
| `anchor` | A new refresh token was seen (a login). `token_min` = its token's lifetime: 60 = a new session; less (`inherited: true`) = it joined the ending session |
|
||||
| `probe` | Renewal attempt inside the 5-min window: `refreshed` (new full token), `capped` (token cut at the session end, `ends`), `refused` (nothing issued: log in now), `unavailable` (pi's AWS SDK could not be loaded; says nothing about the session) |
|
||||
| `session_end_seen` | The session's end read from a capped token |
|
||||
| `login_trigger`, `login_start` | A login started, with the reason and mode |
|
||||
| `device_code_shown` | `remote`: the code shown to you |
|
||||
| `login_end` | `ok: true` = a new refresh token arrived; otherwise `detail` has the CLI's last line |
|
||||
@@ -174,7 +186,8 @@ The status line shows `AWS ✓ since 22:19`, `AWS ⏳ approve ABCD-EFGH`,
|
||||
|---|---|
|
||||
| `local` on a macOS host, browser still signed in: silent, ~3–7 s | verified |
|
||||
| `local` after the session ended: browser re-signed in through the IdP silently, run auto-continued | verified once (request-failed path) |
|
||||
| Detection by refresh attempt before any request fails | unit-tested; first live run pending |
|
||||
| Session end detected before any request failed; unattended silent login (00:13, 00:47) | verified overnight 2026-10-12, but the 00:47 login came 29 min after the end (fixed: now 30 s after) |
|
||||
| Login 30 s after a capped end | unit-tested; first live run pending |
|
||||
| ntfy push: delivery, title, priority, tap link, UTF-8 body | verified against ntfy.sh |
|
||||
| `remote` end to end (code → push → approve on phone) | not yet |
|
||||
| `local` on Linux/Windows hosts | not yet |
|
||||
|
||||
+85
-39
@@ -110,17 +110,37 @@ export function isSsoExpiredError(message: string): boolean {
|
||||
export interface State {
|
||||
enabled?: boolean;
|
||||
mode?: "local" | "remote" | "mac" | "device"; // mac/device: names before 2026-10-11
|
||||
sessionHours?: number;
|
||||
sessionHours?: number; // unused since 2026-10-12 (the end is read from AWS, not a clock)
|
||||
autoContinue?: boolean;
|
||||
/** since = start of the browser's interactive session (estimated). */
|
||||
/** since = when this refresh token appeared; kept across a login that inherited the session. */
|
||||
anchor?: { refreshFp: string; since: number };
|
||||
/** A request failed on an expired session: the next new token starts a fresh session. */
|
||||
/** AWS refused a refresh, or a request failed on an expired session. */
|
||||
sessionEnded?: boolean;
|
||||
/** The session's real end, read from AWS: new tokens are capped there (seen in its last hour). */
|
||||
sessionEndsAt?: number;
|
||||
lastLogin?: { at: number; ok: boolean; mode: string; detail?: string };
|
||||
}
|
||||
|
||||
export const DEFAULTS = { sessionHours: 8 };
|
||||
|
||||
/**
|
||||
* A full access token lives 60 min (measured). In the session's last hour AWS still
|
||||
* issues new tokens but caps them at the session's end, so a token issued for less
|
||||
* than this marks the end (measured 2026-10-12: five refreshes in 00:13–00:17 all
|
||||
* expired at 00:18:48 local, exactly 8 h after the browser sign-in).
|
||||
*/
|
||||
export const FULL_TOKEN_MS = 55 * 60e3;
|
||||
/** Log in this long after the end, so the login cannot inherit the ending session. */
|
||||
export const AFTER_END_MS = 30e3;
|
||||
|
||||
export type TokenView = { accessExpiresAt: number; mtime: number };
|
||||
|
||||
/** The session's end if this token was capped at it, else null (file mtime = issue time). */
|
||||
export function sessionEndFrom(t: TokenView | null): number | null {
|
||||
if (!t || !Number.isFinite(t.accessExpiresAt) || !Number.isFinite(t.mtime)) return null;
|
||||
return t.accessExpiresAt - t.mtime < FULL_TOKEN_MS ? t.accessExpiresAt : null;
|
||||
}
|
||||
|
||||
/** The AWS SDK's own refresh window (token-providers EXPIRE_WINDOW_MS). */
|
||||
export const REFRESH_WINDOW_MS = 5 * 60e3;
|
||||
|
||||
@@ -135,36 +155,41 @@ export function probeDue(accessExpiresAt: number, now: number, lastProbeAt: numb
|
||||
return now - lastProbeAt >= (left > 0 ? 60e3 : 10 * 60e3);
|
||||
}
|
||||
|
||||
export type ProbeResult = { result: "fresh" | "refreshed" | "refused" | "unavailable"; before: number; after: number; error?: string };
|
||||
export type ProbeResult = { result: "fresh" | "refreshed" | "capped" | "refused" | "unavailable"; before: number; after: number; endsAt?: number; error?: string };
|
||||
|
||||
/**
|
||||
* One refresh attempt through the SDK's own token provider, judged by the cache:
|
||||
* refreshed = expiresAt advanced; refused = it did not (session over, or offline).
|
||||
* "unavailable" = the SDK could not be loaded: says nothing about the session.
|
||||
* The token the provider returns is dropped unread.
|
||||
* One refresh attempt through the SDK's own token provider, judged by the cache file:
|
||||
* refreshed = a new full token was written; capped = a new token was written but AWS
|
||||
* capped it at the session's end (endsAt); refused = nothing was written (session
|
||||
* over, or offline). "unavailable" = the SDK could not be loaded: says nothing about
|
||||
* the session. The token the provider returns is dropped unread.
|
||||
* (A bare "expiresAt advanced" test is wrong: capped tokens differ by milliseconds.)
|
||||
*/
|
||||
export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readExpiresAt: () => number, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
|
||||
const before = readExpiresAt();
|
||||
export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readTok: () => TokenView | null, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
|
||||
const b = readTok();
|
||||
const before = b?.accessExpiresAt ?? 0;
|
||||
if (before - now() > REFRESH_WINDOW_MS) return { result: "fresh", before, after: before };
|
||||
let provider: (init: object) => () => Promise<unknown>;
|
||||
try { provider = loadFromSso(); } catch (e) { return { result: "unavailable", before, after: before, error: (e as Error).message }; }
|
||||
let error: string | undefined;
|
||||
try { await provider({ profile, ignoreCache: true })(); } catch (e) { error = (e as Error).message; }
|
||||
const after = readExpiresAt();
|
||||
return { result: after > before ? "refreshed" : "refused", before, after, error };
|
||||
const a = readTok();
|
||||
const after = a?.accessExpiresAt ?? 0;
|
||||
if (!a || !b || a.mtime === b.mtime) return { result: "refused", before, after, error };
|
||||
const endsAt = sessionEndFrom(a);
|
||||
return endsAt === null ? { result: "refreshed", before, after } : { result: "capped", before, after, endsAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor update when the refresh token changed (null = unchanged).
|
||||
* Before the old session's end a new token is a silent renewal: it keeps the end.
|
||||
* After the end (or after an observed expiry) it is a fresh sign-in: new start.
|
||||
* Anchor update when the refresh token changed (null = unchanged). inherited = the new
|
||||
* token is capped at a session end, i.e. the login joined the ending session (silent
|
||||
* renewal) instead of starting one. Without a cap there is no evidence either way, so
|
||||
* the new token's first sight counts as the start.
|
||||
*/
|
||||
export function reanchor(state: State, refreshFp: string, firstSeen: number, now: number): State["anchor"] | null {
|
||||
export function reanchor(state: State, refreshFp: string, firstSeen: number, inherited: boolean): State["anchor"] | null {
|
||||
const a = state.anchor;
|
||||
if (a && a.refreshFp === refreshFp) return null;
|
||||
if (!a) return { refreshFp, since: firstSeen };
|
||||
const ended = state.sessionEnded || now >= a.since + (state.sessionHours ?? DEFAULTS.sessionHours) * 3600e3;
|
||||
return { refreshFp, since: ended ? firstSeen : a.since };
|
||||
return { refreshFp, since: a && inherited ? a.since : firstSeen };
|
||||
}
|
||||
|
||||
export type Mode = "local" | "remote";
|
||||
@@ -184,11 +209,13 @@ export function resolveMode(state: State, env: Record<string, string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap since the last unprompted login before the next one: 30 min after a success or
|
||||
* the first unanswered login, then 1 h, 2 h, and at most 4 h.
|
||||
* Gap since the last unprompted login before the next one: 2 min after a success (only
|
||||
* a guard against loops: the login just after a known end must not wait), then after
|
||||
* unanswered logins 30 min, 1 h, 2 h, and at most 4 h.
|
||||
*/
|
||||
export function autoLoginGap(failures: number): number {
|
||||
return Math.min(30 * 60e3 * 2 ** Math.max(0, failures - 1), 4 * 3600e3);
|
||||
if (failures <= 0) return 2 * 60e3;
|
||||
return Math.min(30 * 60e3 * 2 ** (failures - 1), 4 * 3600e3);
|
||||
}
|
||||
|
||||
export interface Push { title: string; message: string; click?: string; priority?: "min" | "low" | "default" | "high" | "urgent"; tags?: string[] }
|
||||
@@ -212,6 +239,8 @@ export function describeTopic(topicUrl: string | undefined): string {
|
||||
try { const u = new URL(topicUrl); return `${u.host} (topic ${fingerprint(u.pathname)})`; } catch { return "INVALID URL"; }
|
||||
}
|
||||
|
||||
const hhmm = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
export function fmtLeft(ms: number): string {
|
||||
if (ms <= 0) return "0m";
|
||||
const m = Math.round(ms / 60e3);
|
||||
@@ -360,13 +389,20 @@ export default function (pi: ExtensionAPI) {
|
||||
const token = readToken(s.sessionName);
|
||||
const state = loadState();
|
||||
if (token) {
|
||||
const end = sessionEndFrom(token);
|
||||
let dirty = false;
|
||||
// First sight of a refresh token: its file mtime is when it was written (≈ login time).
|
||||
const next = reanchor(state, token.refreshFp, Math.min(token.mtime, Date.now()), Date.now());
|
||||
const next = reanchor(state, token.refreshFp, Math.min(token.mtime, Date.now()), end !== null);
|
||||
if (next) {
|
||||
const inherited = !!state.anchor && next.since === state.anchor.since;
|
||||
state.anchor = next; state.sessionEnded = false; saveState(state);
|
||||
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), inherited });
|
||||
state.anchor = next; state.sessionEnded = false; dirty = true;
|
||||
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), token_min: Math.round((token.accessExpiresAt - token.mtime) / 60e3), inherited: end !== null });
|
||||
}
|
||||
// A capped token shows the session's end; a full one proves >= 55 min left, so drop a stale end.
|
||||
if (end !== null && Math.abs((state.sessionEndsAt ?? 0) - end) > 60e3) {
|
||||
state.sessionEndsAt = end; dirty = true;
|
||||
logEvent("session_end_seen", { at: new Date(end).toISOString() });
|
||||
} else if (end === null && state.sessionEndsAt !== undefined) { delete state.sessionEndsAt; dirty = true; }
|
||||
if (dirty) saveState(state);
|
||||
}
|
||||
return { s, error: null, token, state };
|
||||
}
|
||||
@@ -378,9 +414,10 @@ export default function (pi: ExtensionAPI) {
|
||||
const o = observe();
|
||||
if (o.error) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; }
|
||||
if (!o.token) { ui.setStatus("aws-sso", "AWS ✗ not logged in"); return; }
|
||||
if (o.state.sessionEnded) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
|
||||
const since = o.state.anchor ? new Date(o.state.anchor.since).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null;
|
||||
ui.setStatus("aws-sso", since ? `AWS ✓ since ${since}` : "AWS ✓");
|
||||
const end = o.state.sessionEndsAt;
|
||||
if (o.state.sessionEnded || (end !== undefined && Date.now() >= end)) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
|
||||
if (end !== undefined) { ui.setStatus("aws-sso", `AWS ✓ until ${hhmm(end)}`); return; }
|
||||
ui.setStatus("aws-sso", o.state.anchor ? `AWS ✓ since ${hhmm(o.state.anchor.since)}` : "AWS ✓");
|
||||
}
|
||||
|
||||
/** Refresh attempt inside the SDK window; on refusal, log in while the old token still works. */
|
||||
@@ -390,9 +427,9 @@ export default function (pi: ExtensionAPI) {
|
||||
probing = true;
|
||||
lastProbeAt = Date.now();
|
||||
try {
|
||||
const r = await probeWith(loadFromSso, () => readToken(o.s!.sessionName)?.accessExpiresAt ?? 0, o.s.chain[o.s.chain.length - 1]);
|
||||
lastProbe = { at: lastProbeAt, result: r.result };
|
||||
if (r.result !== "fresh") logEvent("probe", { why, result: r.result, left_s: Math.round((r.before - lastProbeAt) / 1000), after: r.after ? new Date(r.after).toISOString() : null, error: r.result === "refreshed" ? undefined : r.error?.slice(0, 160) });
|
||||
const r = await probeWith(loadFromSso, () => readToken(o.s!.sessionName), o.s.chain[o.s.chain.length - 1]);
|
||||
lastProbe = { at: lastProbeAt, result: r.result === "capped" && r.endsAt ? `capped: session ends ${hhmm(r.endsAt)}` : r.result };
|
||||
if (r.result !== "fresh") logEvent("probe", { why, result: r.result, left_s: Math.round((r.before - lastProbeAt) / 1000), after: r.after ? new Date(r.after).toISOString() : null, ends: r.endsAt ? new Date(r.endsAt).toISOString() : undefined, error: r.result === "refused" ? r.error?.slice(0, 160) : undefined });
|
||||
if (r.result === "refused") { const st = loadState(); st.sessionEnded = true; saveState(st); }
|
||||
return r;
|
||||
} finally { probing = false; render(); }
|
||||
@@ -455,14 +492,21 @@ export default function (pi: ExtensionAPI) {
|
||||
const o = observe();
|
||||
render();
|
||||
if (!o.token) return;
|
||||
// The end is known (AWS capped the tokens there): log in just after it. Before it a
|
||||
// login would only inherit the ending session (measured); the capped token covers until then.
|
||||
const end = o.state.sessionEndsAt;
|
||||
if (end !== undefined && Date.now() >= end + AFTER_END_MS) { autoLogin(`session ended at ${hhmm(end)}`); return; }
|
||||
if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return;
|
||||
const r = await probe("refresh window");
|
||||
// Unprompted logins back off (30 min, doubling, max 4 h) so an unanswered one does not
|
||||
// pile up tabs or pushes overnight. A prompt, a failed request or /aws-sso now still log in at once.
|
||||
if (r?.result === "refused" && Date.now() - lastAutoLogin >= autoLoginGap(autoLoginFailures)) {
|
||||
lastAutoLogin = Date.now();
|
||||
void login("session ended").then((ok) => { if (!ok) autoLoginFailures++; });
|
||||
if (r?.result === "refused") autoLogin("AWS refused a refresh");
|
||||
}
|
||||
|
||||
/** Unprompted login. Back-off after unanswered ones (autoLoginGap) so they do not pile up
|
||||
* tabs or pushes overnight; a prompt, a failed request or /aws-sso now still log in at once. */
|
||||
function autoLogin(reason: string) {
|
||||
if (Date.now() - lastAutoLogin < autoLoginGap(autoLoginFailures)) return;
|
||||
lastAutoLogin = Date.now();
|
||||
void login(reason).then((ok) => { if (!ok) autoLoginFailures++; });
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_e, ctx) => {
|
||||
@@ -488,7 +532,8 @@ export default function (pi: ExtensionAPI) {
|
||||
// Token in (or past) the refresh window: find out now whether the session still exists.
|
||||
if (o.token.accessExpiresAt - Date.now() <= REFRESH_WINDOW_MS && !o.state.sessionEnded) await probe("before prompt");
|
||||
o = observe();
|
||||
if (o.state.sessionEnded) await login("session ended");
|
||||
const end = o.state.sessionEndsAt;
|
||||
if (o.state.sessionEnded || (end !== undefined && Date.now() >= end)) await login("session ended");
|
||||
});
|
||||
|
||||
// A request failed on expired credentials: renew, then (optionally) resume the run.
|
||||
@@ -532,7 +577,8 @@ export default function (pi: ExtensionAPI) {
|
||||
const lines = [
|
||||
`keeper: ${enabled() ? "ON" : "off"}${process.env.PI_AWS_SSO_REFRESH ? ` (env PI_AWS_SSO_REFRESH=${process.env.PI_AWS_SSO_REFRESH})` : ""} · ${(() => { const r = resolveMode(st, process.env); return `${r.mode} (${r.source})`; })()} · push: ${describeTopic(process.env.PI_AWS_SSO_NTFY)} · auto-continue: ${(st.autoContinue ?? true) ? "on" : "off"}`,
|
||||
o.error ? `config: ✗ ${o.error}` : `sso-session: ${o.s!.sessionName} (profile chain ${o.s!.chain.join(" → ")})`,
|
||||
o.token ? `session: ${o.state.sessionEnded ? "ENDED" : "alive"}${o.state.anchor ? ` · started ≈ ${t(o.state.anchor.since)}` : ""} · 1-h token until ${t(o.token.accessExpiresAt)} (next check from ${t(o.token.accessExpiresAt - REFRESH_WINDOW_MS)})` : "token: none",
|
||||
o.token ? `session: ${o.state.sessionEnded ? "ENDED" : "alive"}${o.state.anchor ? ` since ≈ ${t(o.state.anchor.since)}` : ""} · ${o.state.sessionEndsAt !== undefined ? `ends ${t(o.state.sessionEndsAt)} (AWS caps tokens there) → login just after` : "end not visible yet (AWS shows it in the last hour)"}` : "token: none",
|
||||
o.token ? `token: valid until ${t(o.token.accessExpiresAt)} · next check from ${t(o.token.accessExpiresAt - REFRESH_WINDOW_MS)}` : "",
|
||||
lastProbe ? `last check: ${t(lastProbe.at)} · ${lastProbe.result}` : "last check: — (this pi process)",
|
||||
st.lastLogin ? `last login: ${new Date(st.lastLogin.at).toLocaleString()} · ${st.lastLogin.ok ? "ok" : "FAILED: " + st.lastLogin.detail} (${normalizeMode(st.lastLogin.mode) ?? st.lastLogin.mode})` : "last login: —",
|
||||
].filter(Boolean);
|
||||
|
||||
+38
-18
@@ -5,7 +5,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput,
|
||||
isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS,
|
||||
isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS, sessionEndFrom, FULL_TOKEN_MS,
|
||||
normalizeMode, resolveMode, autoLoginGap, ntfyRequest, describeTopic,
|
||||
} from "../extensions/aws-sso.ts";
|
||||
|
||||
@@ -107,14 +107,26 @@ test("probeDue: only inside the SDK's 5-min window; 60 s apart while valid, 10 m
|
||||
assert.equal(probeDue(exp, exp + 12 * 60e3, exp + 1 * 60e3), true);
|
||||
});
|
||||
|
||||
// probeWith: a fake SDK provider and a fake cache, mirroring token-providers' real behaviour.
|
||||
const cacheSim = (exp) => ({ exp, read() { return this.exp; } });
|
||||
test("probeWith: refreshed when the cache advances; refused when the SDK silently keeps the old token", async () => {
|
||||
const now = () => T0 + H - 3 * 60e3;
|
||||
const ok = cacheSim(T0 + H);
|
||||
let r = await probeWith(() => () => async () => { ok.exp = T0 + 2 * H; return { token: "x" }; }, () => ok.read(), "Base", now);
|
||||
test("sessionEndFrom: a full 60-min token says nothing; a token issued for less marks the end", () => {
|
||||
assert.equal(FULL_TOKEN_MS, 55 * 60e3);
|
||||
assert.equal(sessionEndFrom({ accessExpiresAt: T0 + H, mtime: T0 }), null); // measured lifetime: 60 min
|
||||
assert.equal(sessionEndFrom({ accessExpiresAt: T0 + 4 * 60e3, mtime: T0 }), T0 + 4 * 60e3);
|
||||
assert.equal(sessionEndFrom(null), null);
|
||||
assert.equal(sessionEndFrom({ accessExpiresAt: NaN, mtime: T0 }), null);
|
||||
});
|
||||
|
||||
// probeWith: a fake SDK provider and a fake cache file (expiresAt + mtime), mirroring token-providers.
|
||||
const cacheSim = (exp, mtime) => ({ exp, mtime, read() { return { accessExpiresAt: this.exp, mtime: this.mtime }; }, write(exp, at) { this.exp = exp; this.mtime = at; } });
|
||||
test("probeWith: refreshed = new full token; capped = new token cut at the session end; refused = nothing written", async () => {
|
||||
const t = T0 + H - 3 * 60e3, now = () => t;
|
||||
const ok = cacheSim(T0 + H, T0);
|
||||
let r = await probeWith(() => () => async () => { ok.write(t + H, t); return { token: "x" }; }, () => ok.read(), "Base", now);
|
||||
assert.equal(r.result, "refreshed");
|
||||
const ended = cacheSim(T0 + H); // session over: refresh fails inside the window, SDK returns the old token, no throw
|
||||
const capped = cacheSim(T0 + H, T0); // last hour: AWS issues a token that expires where the old one does
|
||||
r = await probeWith(() => () => async () => { capped.write(T0 + H + 10, t); }, () => capped.read(), "Base", now);
|
||||
assert.equal(r.result, "capped");
|
||||
assert.equal(r.endsAt, T0 + H + 10);
|
||||
const ended = cacheSim(T0 + H, T0); // refused: SDK keeps the old token, writes nothing, no throw
|
||||
r = await probeWith(() => () => async () => ({ token: "old" }), () => ended.read(), "Base", now);
|
||||
assert.equal(r.result, "refused");
|
||||
r = await probeWith(() => () => async () => { throw new Error("Token is expired. To refresh this SSO session run 'aws sso login'"); }, () => ended.read(), "Base", () => T0 + 2 * H);
|
||||
@@ -122,9 +134,19 @@ test("probeWith: refreshed when the cache advances; refused when the SDK silentl
|
||||
assert.match(r.error, /expired/);
|
||||
});
|
||||
|
||||
test("probeWith regression 2026-10-12: expiresAt moving by milliseconds is capped, not refreshed", async () => {
|
||||
// Measured: 00:14:50 local, cache expiresAt 22:18:48.735Z -> 22:18:48.745Z, logged 'refreshed' by the old rule.
|
||||
const before = Date.parse("2026-10-11T22:18:48.735Z"), after = Date.parse("2026-10-11T22:18:48.745Z");
|
||||
const at = Date.parse("2026-10-11T22:14:50Z");
|
||||
const c = cacheSim(before, Date.parse("2026-10-11T22:13:53Z"));
|
||||
const r = await probeWith(() => () => async () => { c.write(after, at); }, () => c.read(), "Base", () => at);
|
||||
assert.equal(r.result, "capped");
|
||||
assert.equal(new Date(r.endsAt).toISOString(), "2026-10-11T22:18:48.745Z");
|
||||
});
|
||||
|
||||
test("probeWith: never calls the SDK outside the window; a missing SDK is 'unavailable', not 'session ended'", async () => {
|
||||
let called = 0;
|
||||
const c = cacheSim(T0 + H);
|
||||
const c = cacheSim(T0 + H, T0);
|
||||
let r = await probeWith(() => () => async () => { called++; }, () => c.read(), "Base", () => T0);
|
||||
assert.equal(r.result, "fresh");
|
||||
assert.equal(called, 0);
|
||||
@@ -135,14 +157,12 @@ test("probeWith: never calls the SDK outside the window; a missing SDK is 'unava
|
||||
assert.deepEqual(seen, { profile: "GFTLIoT-Base", ignoreCache: true }); // the SSO profile at the end of the chain
|
||||
});
|
||||
|
||||
test("reanchor: a new token BEFORE the end inherits it (silent renewal); AFTER the end or an expiry starts fresh", () => {
|
||||
test("reanchor: a capped new token inherited the session (keeps its start); otherwise the new token starts one", () => {
|
||||
const st = { anchor: { refreshFp: "aaaa1111", since: T0 } };
|
||||
assert.equal(reanchor(st, "aaaa1111", T0 + 3 * H, T0 + 3 * H), null); // hourly access refresh: same refresh token
|
||||
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 1 * H, T0 + 1 * H), { refreshFp: "bbbb2222", since: T0 }); // measured 2026-10-11: green box, same session
|
||||
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 8.05 * H, T0 + 8.05 * H), { refreshFp: "bbbb2222", since: T0 + 8.05 * H });
|
||||
assert.deepEqual(reanchor({ ...st, sessionEnded: true }, "bbbb2222", T0 + 2 * H, T0 + 2 * H), { refreshFp: "bbbb2222", since: T0 + 2 * H });
|
||||
assert.deepEqual(reanchor({ ...st, sessionHours: 1 }, "bbbb2222", T0 + 2 * H, T0 + 2 * H), { refreshFp: "bbbb2222", since: T0 + 2 * H });
|
||||
assert.deepEqual(reanchor({}, "aaaa1111", T0, T0), { refreshFp: "aaaa1111", since: T0 });
|
||||
assert.equal(reanchor(st, "aaaa1111", T0 + 3 * H, false), null); // hourly access refresh: same refresh token
|
||||
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 7.9 * H, true), { refreshFp: "bbbb2222", since: T0 }); // 2026-10-12 00:13: silent login, token capped
|
||||
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 8.5 * H, false), { refreshFp: "bbbb2222", since: T0 + 8.5 * H }); // 00:47: full token, new session
|
||||
assert.deepEqual(reanchor({}, "aaaa1111", T0, true), { refreshFp: "aaaa1111", since: T0 });
|
||||
});
|
||||
|
||||
test("mode: command beats .env beats default local; old names map; junk ignored", () => {
|
||||
@@ -155,8 +175,8 @@ test("mode: command beats .env beats default local; old names map; junk ignored"
|
||||
assert.equal(normalizeMode("headless"), null);
|
||||
});
|
||||
|
||||
test("autoLoginGap: retries 30 min, 1 h, 2 h, then every 4 h; never closer than 30 min", () => {
|
||||
assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [30, 30, 60, 120, 240, 240]);
|
||||
test("autoLoginGap: 2 min after a success (the post-end login must not wait); 30 min, 1 h, 2 h, 4 h after unanswered ones", () => {
|
||||
assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [2, 30, 60, 120, 240, 240]);
|
||||
});
|
||||
|
||||
test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => {
|
||||
|
||||
Reference in New Issue
Block a user