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:
pi@mbp-m1-2020
2026-10-12 07:57:15 +02:00
parent 0ce96fdb8b
commit 4e48456591
4 changed files with 170 additions and 86 deletions
+18 -13
View File
@@ -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 **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 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 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). measured 8 h here (2026-10-10 from the portal cookie; 2026-10-12 from the token cap
When it ends, the next refresh is refused. Inside the SDK's 5-minute pre-expiry below). In the session's last hour AWS still issues new 1-hour tokens but **caps them
window, a refused refresh is *silent*: the SDK keeps returning the still-valid token. at the session's end**; after the end it refuses. Inside the SDK's 5-minute pre-expiry
So the keeper makes that refresh attempt itself as soon as the window opens: it uses window both are *silent*: the SDK keeps returning a still-valid token. So the keeper
pi's own `@aws-sdk/token-providers` and the same cache file. It then checks whether makes that refresh attempt itself as soon as the window opens, with pi's own
`expiresAt` in `~/.aws/sso/cache/<sha1(sso-session)>.json` advanced. If it did not, `@aws-sdk/token-providers` and the same cache file
the session is over, and the keeper logs in right away while the old token still (`~/.aws/sso/cache/<sha1(sso-session)>.json`), and reads the result from the file:
covers requests. Nothing is renewed on a clock: a login while the browser sign-in a new full token = alive; a new token issued for under 55 min = **capped**, and its
is still valid completes silently ("…can be used until your session expires") but expiry is the session's end; nothing written = **refused**. On a cap the keeper logs
inherits its end, so it gains nothing. Tokens are only ever logged as `sha256[:8]` 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 fingerprints (`~/.pi/agent/aws-sso/events.jsonl`); the token the SDK returns is
dropped unread. dropped unread.
**Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every **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, 10 min once the token has run out). At least 2 min pass between unprompted logins;
1 h, 2 h, then every 4 h), so one waiting for you does not pile up tabs or after an unanswered one the keeper retries after 30 min, 1 h, 2 h, then every 4 h, so
pushes overnight. `before_agent_start` 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 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 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 token, the keeper renews and sends `continue` (once per 10 min), also when you
+28 -15
View File
@@ -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 **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 - 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 by your IdC admin (measured 8 h here). In its last hour AWS still issues new tokens
one day and at most 6 h on the next. When it ends, AWS refuses further renewals. 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 - 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 the same end**, so renewing early gains nothing. Only a login after the end starts
a new session. 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 So the keeper **reads** the end instead of guessing it. In the last 5 minutes of each
each 1-hour token it makes the renewal attempt itself, through the same SDK pi uses. 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 - **new full token:** the session goes on;
slept), it logs in and then sends `continue` so the interrupted run resumes. - **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 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 (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 ```text
keeper: ON · local (default) · push: ntfy.sh (topic 3fb657b1) · auto-continue: on keeper: ON · local (default) · push: ntfy.sh (topic 3fb657b1) · auto-continue: on
sso-session: MySession (profile chain Bedrock → Base) sso-session: MySession (profile chain Bedrock → Base)
session: alive · started ≈ 22:19 · 1-h token until 23:19 (next check from 23:14) session: alive since ≈ 00:47 · ends 08:47 (AWS caps tokens there) → login just after
last check: 23:14 · refreshed 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) 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 ## What happens when a session ends
1. **Detected:** in the 5 minutes before the hourly token runs out, AWS refuses the 1. **Detected:** in the session's last hour the hourly check gets a token cut short at
renewal. Log: `probe … result: refused`. 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:** 2. **Login starts:**
- `local`: a tab opens on the host. If it has not finished after 60 s, you get - `local`: a tab opens on the host. If it has not finished after 60 s, you get
the push "AWS SSO login waiting". the push "AWS SSO login waiting".
@@ -119,7 +130,7 @@ The topic is shown only as a fingerprint, never in clear.
noticed. noticed.
4. **If nobody approves:** the old token runs out, the task's next request fails 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 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, a fresh code, and the keeper waits up to 10 minutes for each. When you approve,
even hours later, the run resumes with `continue`. 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 | | Event | Meaning |
|---|---| |---|---|
| `anchor` | A new refresh token was seen. `inherited: true` = silent renewal inside the same session; `false` = new 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` (fine), `refused` (session over), `unavailable` (pi's AWS SDK could not be loaded; says nothing about the 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 | | `login_trigger`, `login_start` | A login started, with the reason and mode |
| `device_code_shown` | `remote`: the code shown to you | | `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 | | `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` 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) | | `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 | | 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 | | `remote` end to end (code → push → approve on phone) | not yet |
| `local` on Linux/Windows hosts | not yet | | `local` on Linux/Windows hosts | not yet |
+85 -39
View File
@@ -110,17 +110,37 @@ export function isSsoExpiredError(message: string): boolean {
export interface State { export interface State {
enabled?: boolean; enabled?: boolean;
mode?: "local" | "remote" | "mac" | "device"; // mac/device: names before 2026-10-11 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; 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 }; 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; 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 }; lastLogin?: { at: number; ok: boolean; mode: string; detail?: string };
} }
export const DEFAULTS = { sessionHours: 8 }; 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). */ /** The AWS SDK's own refresh window (token-providers EXPIRE_WINDOW_MS). */
export const REFRESH_WINDOW_MS = 5 * 60e3; 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); 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: * One refresh attempt through the SDK's own token provider, judged by the cache file:
* refreshed = expiresAt advanced; refused = it did not (session over, or offline). * refreshed = a new full token was written; capped = a new token was written but AWS
* "unavailable" = the SDK could not be loaded: says nothing about the session. * capped it at the session's end (endsAt); refused = nothing was written (session
* The token the provider returns is dropped unread. * 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> { export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readTok: () => TokenView | null, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
const before = readExpiresAt(); const b = readTok();
const before = b?.accessExpiresAt ?? 0;
if (before - now() > REFRESH_WINDOW_MS) return { result: "fresh", before, after: before }; if (before - now() > REFRESH_WINDOW_MS) return { result: "fresh", before, after: before };
let provider: (init: object) => () => Promise<unknown>; let provider: (init: object) => () => Promise<unknown>;
try { provider = loadFromSso(); } catch (e) { return { result: "unavailable", before, after: before, error: (e as Error).message }; } try { provider = loadFromSso(); } catch (e) { return { result: "unavailable", before, after: before, error: (e as Error).message }; }
let error: string | undefined; let error: string | undefined;
try { await provider({ profile, ignoreCache: true })(); } catch (e) { error = (e as Error).message; } try { await provider({ profile, ignoreCache: true })(); } catch (e) { error = (e as Error).message; }
const after = readExpiresAt(); const a = readTok();
return { result: after > before ? "refreshed" : "refused", before, after, error }; 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). * Anchor update when the refresh token changed (null = unchanged). inherited = the new
* Before the old session's end a new token is a silent renewal: it keeps the end. * token is capped at a session end, i.e. the login joined the ending session (silent
* After the end (or after an observed expiry) it is a fresh sign-in: new start. * 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; const a = state.anchor;
if (a && a.refreshFp === refreshFp) return null; if (a && a.refreshFp === refreshFp) return null;
if (!a) return { refreshFp, since: firstSeen }; return { refreshFp, since: a && inherited ? a.since : firstSeen };
const ended = state.sessionEnded || now >= a.since + (state.sessionHours ?? DEFAULTS.sessionHours) * 3600e3;
return { refreshFp, since: ended ? firstSeen : a.since };
} }
export type Mode = "local" | "remote"; 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 * Gap since the last unprompted login before the next one: 2 min after a success (only
* the first unanswered login, then 1 h, 2 h, and at most 4 h. * 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 { 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[] } 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"; } 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 { export function fmtLeft(ms: number): string {
if (ms <= 0) return "0m"; if (ms <= 0) return "0m";
const m = Math.round(ms / 60e3); const m = Math.round(ms / 60e3);
@@ -360,13 +389,20 @@ export default function (pi: ExtensionAPI) {
const token = readToken(s.sessionName); const token = readToken(s.sessionName);
const state = loadState(); const state = loadState();
if (token) { 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). // 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) { if (next) {
const inherited = !!state.anchor && next.since === state.anchor.since; state.anchor = next; state.sessionEnded = false; dirty = true;
state.anchor = next; state.sessionEnded = false; saveState(state); logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), token_min: Math.round((token.accessExpiresAt - token.mtime) / 60e3), inherited: end !== null });
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), inherited });
} }
// 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 }; return { s, error: null, token, state };
} }
@@ -378,9 +414,10 @@ export default function (pi: ExtensionAPI) {
const o = observe(); const o = observe();
if (o.error) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; } 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.token) { ui.setStatus("aws-sso", "AWS ✗ not logged in"); return; }
if (o.state.sessionEnded) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; } const end = o.state.sessionEndsAt;
const since = o.state.anchor ? new Date(o.state.anchor.since).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null; if (o.state.sessionEnded || (end !== undefined && Date.now() >= end)) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
ui.setStatus("aws-sso", since ? `AWS ✓ since ${since}` : "AWS ✓"); 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. */ /** 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; probing = true;
lastProbeAt = Date.now(); lastProbeAt = Date.now();
try { try {
const r = await probeWith(loadFromSso, () => readToken(o.s!.sessionName)?.accessExpiresAt ?? 0, o.s.chain[o.s.chain.length - 1]); const r = await probeWith(loadFromSso, () => readToken(o.s!.sessionName), o.s.chain[o.s.chain.length - 1]);
lastProbe = { at: lastProbeAt, result: r.result }; 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, error: r.result === "refreshed" ? undefined : r.error?.slice(0, 160) }); 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); } if (r.result === "refused") { const st = loadState(); st.sessionEnded = true; saveState(st); }
return r; return r;
} finally { probing = false; render(); } } finally { probing = false; render(); }
@@ -455,14 +492,21 @@ export default function (pi: ExtensionAPI) {
const o = observe(); const o = observe();
render(); render();
if (!o.token) return; 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; if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return;
const r = await probe("refresh window"); const r = await probe("refresh window");
// Unprompted logins back off (30 min, doubling, max 4 h) so an unanswered one does not if (r?.result === "refused") autoLogin("AWS refused a refresh");
// 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++; });
} }
/** 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) => { 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. // 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"); if (o.token.accessExpiresAt - Date.now() <= REFRESH_WINDOW_MS && !o.state.sessionEnded) await probe("before prompt");
o = observe(); 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. // A request failed on expired credentials: renew, then (optionally) resume the run.
@@ -532,7 +577,8 @@ export default function (pi: ExtensionAPI) {
const lines = [ 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"}`, `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.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)", 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: —", 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); ].filter(Boolean);
+38 -18
View File
@@ -5,7 +5,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput, 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, normalizeMode, resolveMode, autoLoginGap, ntfyRequest, describeTopic,
} from "../extensions/aws-sso.ts"; } 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); 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. test("sessionEndFrom: a full 60-min token says nothing; a token issued for less marks the end", () => {
const cacheSim = (exp) => ({ exp, read() { return this.exp; } }); assert.equal(FULL_TOKEN_MS, 55 * 60e3);
test("probeWith: refreshed when the cache advances; refused when the SDK silently keeps the old token", async () => { assert.equal(sessionEndFrom({ accessExpiresAt: T0 + H, mtime: T0 }), null); // measured lifetime: 60 min
const now = () => T0 + H - 3 * 60e3; assert.equal(sessionEndFrom({ accessExpiresAt: T0 + 4 * 60e3, mtime: T0 }), T0 + 4 * 60e3);
const ok = cacheSim(T0 + H); assert.equal(sessionEndFrom(null), null);
let r = await probeWith(() => () => async () => { ok.exp = T0 + 2 * H; return { token: "x" }; }, () => ok.read(), "Base", now); 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"); 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); r = await probeWith(() => () => async () => ({ token: "old" }), () => ended.read(), "Base", now);
assert.equal(r.result, "refused"); 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); 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/); 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 () => { test("probeWith: never calls the SDK outside the window; a missing SDK is 'unavailable', not 'session ended'", async () => {
let called = 0; let called = 0;
const c = cacheSim(T0 + H); const c = cacheSim(T0 + H, T0);
let r = await probeWith(() => () => async () => { called++; }, () => c.read(), "Base", () => T0); let r = await probeWith(() => () => async () => { called++; }, () => c.read(), "Base", () => T0);
assert.equal(r.result, "fresh"); assert.equal(r.result, "fresh");
assert.equal(called, 0); 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 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 } }; 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.equal(reanchor(st, "aaaa1111", T0 + 3 * H, false), 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 + 7.9 * H, true), { refreshFp: "bbbb2222", since: T0 }); // 2026-10-12 00:13: silent login, token capped
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 8.05 * H, T0 + 8.05 * H), { refreshFp: "bbbb2222", since: T0 + 8.05 * H }); 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({ ...st, sessionEnded: true }, "bbbb2222", T0 + 2 * H, T0 + 2 * H), { refreshFp: "bbbb2222", since: T0 + 2 * H }); assert.deepEqual(reanchor({}, "aaaa1111", T0, true), { refreshFp: "aaaa1111", since: T0 });
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 });
}); });
test("mode: command beats .env beats default local; old names map; junk ignored", () => { 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); assert.equal(normalizeMode("headless"), null);
}); });
test("autoLoginGap: retries 30 min, 1 h, 2 h, then every 4 h; never closer than 30 min", () => { 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), [30, 30, 60, 120, 240, 240]); 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", () => { test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => {