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
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
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 **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
View File
@@ -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
View File
@@ -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", () => {