aws-sso: detect the real session end by a refresh attempt, renew before requests fail

Measured: a session lasted <= 6 h (2026-10-11), not the 8 h assumed, so a
clock cannot predict the end. token-providers 3.1127.0 refreshes only inside
a 5-min window and, on refusal, silently returns the still-valid token. The
keeper now makes that attempt itself (pi's own SDK, same cache) when the
window opens, and if expiresAt did not advance, logs in while the old token
still works. Clock-based renewal, signed-in and hours commands removed.
This commit is contained in:
pi@mbp-m1-2020
2026-10-11 22:33:40 +02:00
parent 32a397edb4
commit fdee3e42d6
3 changed files with 168 additions and 112 deletions
+25 -26
View File
@@ -359,44 +359,43 @@ Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/j
### `aws-sso.ts` ### `aws-sso.ts`
Keeps a Bedrock **AWS SSO** session alive, like Claude Code's `awsAuthRefresh`: Keeps a Bedrock **AWS SSO** session alive, like Claude Code's `awsAuthRefresh`:
shortly before the IAM Identity Center session ends (or when a request fails on when the IAM Identity Center session ends it runs `aws sso login` for you, before a
an expired token) it runs `aws sso login` for you. **You still approve the login.** request fails. **You still approve the login** (or your browser does it silently
Off until you enable it. while it is signed in). Off until you enable it.
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `/aws-sso on` / `off` | Enable/disable (persisted in `~/.pi/agent/aws-sso/state.json`) | | `/aws-sso on` / `off` | Enable/disable (persisted in `~/.pi/agent/aws-sso/state.json`) |
| `/aws-sso status` | Mode, profile chain, time left, last login | | `/aws-sso status` | Mode, profile chain, session state, last check, last login |
| `/aws-sso now` | Log in now | | `/aws-sso now` | Log in now |
| `/aws-sso mode mac` | Run `aws sso login` on the Docker host over SSH (`~/.ssh-local/config`, host alias `host` or `$PI_AWS_SSO_SSH_HOST`): your own browser opens. Default when the sidecar config exists. | | `/aws-sso mode mac` | Run `aws sso login` on the Docker host over SSH (`~/.ssh-local/config`, host alias `host` or `$PI_AWS_SSO_SSH_HOST`): your own browser opens. Default when the sidecar config exists. |
| `/aws-sso mode device` | Run `aws sso login --use-device-code --no-browser` here and show link + code in pi; approve on any device. For SSH/tmux sessions. | | `/aws-sso mode device` | Run `aws sso login --use-device-code --no-browser` here and show link + code in pi; approve on any device. For SSH/tmux sessions. |
| `/aws-sso continue on\|off` | After a request failed on an expired session and the renewal succeeded, send `continue` (default on) | | `/aws-sso continue on\|off` | After a request failed on an expired session and the renewal succeeded, send `continue` (default on) |
| `/aws-sso signed-in HH:MM` | Correct the estimated start of the browser's sign-in |
| `/aws-sso hours <n>` | Session length if your IdC admin changed it (default 8 h, measured) |
`PI_AWS_SSO_REFRESH=1` / `=0` force it on / off regardless of the saved state. `PI_AWS_SSO_REFRESH=1` / `=0` force it on / off regardless of the saved state.
**When it renews — and why not earlier.** Per AWS ("Understanding authentication **How it detects the end.** Per AWS ("Understanding authentication sessions"), the
sessions"), the CLI's 1-hour token is refreshed by the SDK "as long as the underlying CLI's 1-hour token is refreshed "as long as the underlying user interactive session
user interactive session remains valid" — that is the browser's AWS sign-in, remains valid", meaning the browser's AWS sign-in. That sign-in is admin-capped and
admin-capped (8 h measured 2026-10-10). A login while it is valid completes its length is not predictable (measured 8 h on 2026-10-10, at most 6 h on 2026-10-11).
silently ("…can be used until your session expires") but **inherits its end**, so When it ends, the next refresh is refused. Inside the SDK's 5-minute pre-expiry
renewing early gains nothing. The keeper therefore renews **2 minutes after** the window, a refused refresh is *silent*: the SDK keeps returning the still-valid token.
estimated end: the browser must sign in again through the IdP (silent while So the keeper makes that refresh attempt itself as soon as the window opens: it uses
"stay signed in" holds) and a fresh session starts, while the CLI's last 1-hour pi's own `@aws-sdk/token-providers` and the same cache file. It then checks whether
token still covers the gap. If a request fails on an expired token anyway, it `expiresAt` in `~/.aws/sso/cache/<sha1(sso-session)>.json` advanced. If it did not,
renews and sends `continue` (once per 10 min; `/aws-sso continue off` to disable). 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]`
fingerprints (`~/.pi/agent/aws-sso/events.jsonl`); the token the SDK returns is
dropped unread.
The session start is not stored anywhere. The keeper watches the refresh token in **Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every
`~/.aws/sso/cache/<sha1(sso-session)>.json`: a new one **after** the end (or after 10 min once the token has run out). Unprompted logins happen at most once per
an observed expiry) starts a new session; a new one **before** the end is a silent 30 min, so a login waiting for you does not pile up tabs. `before_agent_start`
renewal and keeps the old end. `/aws-sso signed-in HH:MM` corrects the estimate. checks first if the token is in its window and logs in (and waits) if the session
Tokens are only ever logged as `sha256[:8]` fingerprints (`~/.pi/agent/aws-sso/events.jsonl`). 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).
**Triggers:** a 60 s timer (2 min after the estimated end; one attempt per session,
so an ignored login does not loop), `before_agent_start` (session already expired →
log in and wait before sending the prompt), and `message_end` (a request failed
with an expired-token error → log in, then send `continue`).
Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert. Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert.
A lock file stops several pi processes from starting several logins. A lock file stops several pi processes from starting several logins.
+107 -71
View File
@@ -13,24 +13,25 @@
* Inert until enabled: `/aws-sso on` (persists in ~/.pi/agent/aws-sso/state.json), * Inert until enabled: `/aws-sso on` (persists in ~/.pi/agent/aws-sso/state.json),
* or env PI_AWS_SSO_REFRESH=1. PI_AWS_SSO_REFRESH=0 is a hard off switch. * or env PI_AWS_SSO_REFRESH=1. PI_AWS_SSO_REFRESH=0 is a hard off switch.
* *
* Timing (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an * Detection (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an
* *application session* whose 1-hour token the SDK refreshes by itself "as long as * *application session* whose 1-hour token is refreshed "as long as the underlying
* the underlying user interactive session remains valid". The interactive session * user interactive session remains valid" (the browser's AWS sign-in, admin-capped;
* is the browser's AWS sign-in, admin-capped (8 h measured). A login while it is * measured both 8 h and <= 6 h, so its length is NOT predictable). When it ends,
* still valid completes silently but INHERITS its end — renewing early gains * the next refresh is refused. Inside the SDK's 5-minute window a refused refresh is
* nothing. A login just AFTER it ends makes the browser sign in again (through the * silent: the SDK keeps returning the still-valid token, so requests only fail once
* IdP, silent while "stay signed in" holds) and starts a fresh interactive session. * that token runs out. The keeper makes the refresh attempt itself as soon as the
* So the keeper renews a couple of minutes after the estimated end, while the CLI's * window opens (same SDK, same cache file) and checks whether the cache advanced.
* last 1-hour token still works, and if a request fails anyway it renews and can * If not, the session has ended: it logs in right away, while the old token still
* send "continue" so an unattended run resumes. * covers requests. A login while the browser sign-in is still valid is silent and
* The session start is not stored anywhere: it is inferred from refresh-token * inherits its end, which is why nothing is renewed on a clock. If a request fails
* changes (a change after the end = new session; before the end = inherited). * anyway, it renews and can send "continue" so an unattended run resumes.
* Tokens never leave this process except as sha256[:8] fingerprints. * Tokens never leave this process except as sha256[:8] fingerprints.
*/ */
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -104,7 +105,6 @@ export interface State {
enabled?: boolean; enabled?: boolean;
mode?: "mac" | "device"; mode?: "mac" | "device";
sessionHours?: number; sessionHours?: number;
renewAfterMinutes?: number;
autoContinue?: boolean; autoContinue?: boolean;
/** since = start of the browser's interactive session (estimated). */ /** since = start of the browser's interactive session (estimated). */
anchor?: { refreshFp: string; since: number }; anchor?: { refreshFp: string; since: number };
@@ -113,24 +113,39 @@ export interface State {
lastLogin?: { at: number; ok: boolean; mode: string; detail?: string }; lastLogin?: { at: number; ok: boolean; mode: string; detail?: string };
} }
export const DEFAULTS = { sessionHours: 8, renewAfterMinutes: 2 }; export const DEFAULTS = { sessionHours: 8 };
/** The AWS SDK's own refresh window (token-providers EXPIRE_WINDOW_MS). */
export const REFRESH_WINDOW_MS = 5 * 60e3;
/** /**
* Where the session stands (endsAt = estimated end of the browser's sign-in). * Should the keeper make a refresh attempt now? Only inside the SDK's window (outside
* none – no cached token * it the SDK does not refresh, so an attempt proves nothing). Every 60 s while the
* expired – past the end AND the CLI's 1-hour token has run out too * token is still valid; every 10 min once it has run out (laptop slept, login pending).
* due – past the end + renewAfterMinutes: renew now (a fresh sign-in)
* ok – before that
*/ */
export function assess(state: State, token: { refreshFp: string; accessExpiresAt: number } | null, now: number) { export function probeDue(accessExpiresAt: number, now: number, lastProbeAt: number): boolean {
const hours = state.sessionHours ?? DEFAULTS.sessionHours; const left = accessExpiresAt - now;
const after = (state.renewAfterMinutes ?? DEFAULTS.renewAfterMinutes) * 60e3; if (left > REFRESH_WINDOW_MS) return false;
if (!token) return { status: "none" as const, endsAt: null as number | null }; return now - lastProbeAt >= (left > 0 ? 60e3 : 10 * 60e3);
const since = state.anchor && state.anchor.refreshFp === token.refreshFp ? state.anchor.since : null; }
const endsAt = since === null ? null : since + hours * 3600e3;
if (endsAt !== null && now >= endsAt && now >= token.accessExpiresAt) return { status: "expired" as const, endsAt }; export type ProbeResult = { result: "fresh" | "refreshed" | "refused" | "unavailable"; before: number; after: number; error?: string };
if (endsAt !== null && now >= endsAt + after) return { status: "due" as const, endsAt };
return { status: "ok" as const, endsAt }; /**
* 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.
*/
export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readExpiresAt: () => number, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
const before = readExpiresAt();
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 };
} }
/** /**
@@ -191,6 +206,19 @@ function readToken(sessionName: string): { refreshFp: string; accessExpiresAt: n
} catch { return null; } } catch { return null; }
} }
/** The SDK pi itself uses (resolved from pi's install, not from this file's location). */
let fromSsoCached: ((init: object) => () => Promise<unknown>) | null = null;
function loadFromSso() {
if (fromSsoCached) return fromSsoCached;
const bases = [process.argv[1], "/usr/lib/node_modules/@earendil-works/pi-coding-agent/package.json"].filter(Boolean) as string[];
let last: unknown;
for (const b of bases) {
try { fromSsoCached = createRequire(realpathSync(b))("@aws-sdk/token-providers").fromSso; if (fromSsoCached) return fromSsoCached; }
catch (e) { last = e; }
}
throw last instanceof Error ? last : new Error("@aws-sdk/token-providers not found");
}
/** Cross-process lock so several pi processes never start several logins. */ /** Cross-process lock so several pi processes never start several logins. */
function tryLock(): boolean { function tryLock(): boolean {
mkdirSync(DIR, { recursive: true, mode: 0o700 }); mkdirSync(DIR, { recursive: true, mode: 0o700 });
@@ -257,14 +285,17 @@ export default function (pi: ExtensionAPI) {
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let inFlight: Promise<{ ok: boolean; detail: string }> | null = null; let inFlight: Promise<{ ok: boolean; detail: string }> | null = null;
let ui: Ui | null = null; let ui: Ui | null = null;
let warnedEnd: number | null = null; let probing = false;
let lastProbeAt = 0;
let lastAutoLogin = 0;
let lastProbe: { at: number; result: string } | null = null;
const enabled = () => process.env.PI_AWS_SSO_REFRESH === "0" ? false : process.env.PI_AWS_SSO_REFRESH === "1" ? true : !!loadState().enabled; const enabled = () => process.env.PI_AWS_SSO_REFRESH === "0" ? false : process.env.PI_AWS_SSO_REFRESH === "1" ? true : !!loadState().enabled;
/** Observe the token, keep the anchor current, return the assessment. */ /** Observe the token, keep the anchor current, return the assessment. */
function observe() { function observe() {
const s = ssoSession(); const s = ssoSession();
if ("error" in s) return { s: null, error: s.error, a: null as ReturnType<typeof assess> | null, token: null }; if ("error" in s) return { s: null, error: s.error, token: null, state: loadState() };
const token = readToken(s.sessionName); const token = readToken(s.sessionName);
const state = loadState(); const state = loadState();
if (token) { if (token) {
@@ -276,7 +307,7 @@ export default function (pi: ExtensionAPI) {
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), inherited }); logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), inherited });
} }
} }
return { s, error: null, a: assess(state, token, Date.now()), token }; return { s, error: null, token, state };
} }
function render() { function render() {
@@ -284,12 +315,26 @@ export default function (pi: ExtensionAPI) {
if (!enabled()) { ui.setStatus("aws-sso", undefined); return; } if (!enabled()) { ui.setStatus("aws-sso", undefined); return; }
if (inFlight) return; // the login path owns the status while it runs if (inFlight) return; // the login path owns the status while it runs
const o = observe(); const o = observe();
if (o.error || !o.a) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; } if (o.error) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; }
const left = o.a.endsAt === null ? null : o.a.endsAt - Date.now(); if (!o.token) { ui.setStatus("aws-sso", "AWS ✗ not logged in"); return; }
const txt = o.a.status === "none" ? "AWS ✗ not logged in" if (o.state.sessionEnded) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
: o.a.status === "expired" ? "AWS ✗ session expired" const since = o.state.anchor ? new Date(o.state.anchor.since).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null;
: o.a.status === "due" ? "AWS ⚠ renewing" : left === null ? "AWS ✓" : `AWS ✓ ${fmtLeft(left)}`; ui.setStatus("aws-sso", since ? `AWS ✓ since ${since}` : "AWS ✓");
ui.setStatus("aws-sso", txt); }
/** Refresh attempt inside the SDK window; on refusal, log in while the old token still works. */
async function probe(why: string): Promise<ProbeResult | null> {
const o = observe();
if (!o.s || !o.token || probing) return null;
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) });
if (r.result === "refused") { const st = loadState(); st.sessionEnded = true; saveState(st); }
return r;
} finally { probing = false; render(); }
} }
async function login(reason: string): Promise<boolean> { async function login(reason: string): Promise<boolean> {
@@ -313,15 +358,17 @@ export default function (pi: ExtensionAPI) {
} finally { inFlight = null; unlock(); render(); } } finally { inFlight = null; unlock(); render(); }
} }
function tick() { async function tick() {
if (!enabled()) { render(); return; } if (!enabled() || inFlight || probing) { render(); return; }
const o = observe(); const o = observe();
render(); render();
if (!o.a || inFlight) return; if (!o.token) return;
// Just after the end: one attempt per session end, so an ignored login does not loop. if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return;
if ((o.a.status === "due" || o.a.status === "expired") && o.a.endsAt !== null && warnedEnd !== o.a.endsAt) { const r = await probe("refresh window");
warnedEnd = o.a.endsAt; // At most one unprompted login per 30 min, so a login waiting for you does not pile up tabs.
void login("session ended"); if (r?.result === "refused" && Date.now() - lastAutoLogin >= 30 * 60e3) {
lastAutoLogin = Date.now();
void login("session ended (refresh refused)");
} }
} }
@@ -329,8 +376,8 @@ export default function (pi: ExtensionAPI) {
ui = ctx.hasUI ? ctx.ui : null; ui = ctx.hasUI ? ctx.ui : null;
if (!ui) return; // print/json children: never start logins or timers if (!ui) return; // print/json children: never start logins or timers
render(); render();
timer = setInterval(tick, 60e3); timer = setInterval(() => void tick(), 20e3);
if (enabled()) tick(); if (enabled()) void tick();
}); });
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
@@ -342,9 +389,13 @@ export default function (pi: ExtensionAPI) {
// Before a prompt goes out: if the session is already gone, log in first and wait. // Before a prompt goes out: if the session is already gone, log in first and wait.
pi.on("before_agent_start", async () => { pi.on("before_agent_start", async () => {
if (!ui || !enabled()) return; if (!ui || !enabled()) return;
const o = observe(); if (inFlight) { await inFlight; return; }
if (o.a?.status === "expired" || o.a?.status === "none") await login("session expired"); let o = observe();
else if (inFlight) await inFlight; if (!o.token) { await login("not logged in"); return; }
// 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");
}); });
// A request failed on expired credentials: renew, then (optionally) resume the run. // A request failed on expired credentials: renew, then (optionally) resume the run.
@@ -367,13 +418,13 @@ export default function (pi: ExtensionAPI) {
}); });
pi.registerCommand("aws-sso", { pi.registerCommand("aws-sso", {
description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off | signed-in HH:MM | hours <n>", description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off",
handler: async (args, ctx) => { handler: async (args, ctx) => {
ui = ctx.ui; ui = ctx.ui;
const [cmd, val] = String(args ?? "").trim().split(/\s+/); const [cmd, val] = String(args ?? "").trim().split(/\s+/);
const st = loadState(); const st = loadState();
switch (cmd || "status") { switch (cmd || "status") {
case "on": st.enabled = true; saveState(st); if (!timer) timer = setInterval(tick, 60e3); tick(); ctx.ui.notify(`AWS SSO keeper ON (mode ${st.mode ?? defaultMode()}).`, "info"); break; case "on": st.enabled = true; saveState(st); if (!timer) timer = setInterval(() => void tick(), 20e3); void tick(); ctx.ui.notify(`AWS SSO keeper ON (mode ${st.mode ?? defaultMode()}).`, "info"); break;
case "off": st.enabled = false; saveState(st); render(); ctx.ui.notify("AWS SSO keeper OFF.", "info"); break; case "off": st.enabled = false; saveState(st); render(); ctx.ui.notify("AWS SSO keeper OFF.", "info"); break;
case "now": await login("requested"); break; case "now": await login("requested"); break;
case "mode": case "mode":
@@ -382,29 +433,14 @@ export default function (pi: ExtensionAPI) {
case "continue": case "continue":
if (val !== "on" && val !== "off") { ctx.ui.notify("Usage: /aws-sso continue on|off", "error"); break; } if (val !== "on" && val !== "off") { ctx.ui.notify("Usage: /aws-sso continue on|off", "error"); break; }
st.autoContinue = val === "on"; saveState(st); ctx.ui.notify(`AWS SSO auto-continue after a failed request: ${val}`, "info"); break; st.autoContinue = val === "on"; saveState(st); ctx.ui.notify(`AWS SSO auto-continue after a failed request: ${val}`, "info"); break;
case "signed-in": {
// Correct the estimate: when did the browser last sign in through the IdP?
const hm = String(val ?? "").match(/^(\d{1,2}):(\d{2})$/);
const o = observe();
if (!hm || !o.token) { ctx.ui.notify("Usage: /aws-sso signed-in HH:MM (local time, today; needs a cached token)", "error"); break; }
const d = new Date(); d.setHours(Number(hm[1]), Number(hm[2]), 0, 0);
if (d.getTime() > Date.now()) d.setDate(d.getDate() - 1);
const s2 = loadState(); s2.anchor = { refreshFp: o.token.refreshFp, since: d.getTime() }; s2.sessionEnded = false; saveState(s2);
logEvent("anchor_set", { since: d.toISOString() });
render(); ctx.ui.notify(`AWS SSO: session start set to ${d.toLocaleString()}.`, "info"); break;
}
case "hours": {
const n = Number(val);
if (!(n > 0 && n <= 24 * 90)) { ctx.ui.notify("Usage: /aws-sso hours <session length in hours>", "error"); break; }
st.sessionHours = n; saveState(st); render(); ctx.ui.notify(`AWS SSO session length set to ${n} h.`, "info"); break;
}
case "status": default: { case "status": default: {
const o = observe(); const o = observe();
const t = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
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})` : ""} · mode: ${st.mode ?? defaultMode()} · session length: ${st.sessionHours ?? DEFAULTS.sessionHours} h · 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})` : ""} · mode: ${st.mode ?? defaultMode()} · 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.a ? `session: ${o.a.status}${o.a.endsAt ? ` · browser sign-in ${new Date(o.a.endsAt - (st.sessionHours ?? DEFAULTS.sessionHours) * 3600e3).toLocaleTimeString()} → ends ≈ ${new Date(o.a.endsAt).toLocaleTimeString()} (${fmtLeft(o.a.endsAt - Date.now())} left) · renew ≈ ${new Date(o.a.endsAt + (st.renewAfterMinutes ?? DEFAULTS.renewAfterMinutes) * 60e3).toLocaleTimeString()}` : " · start unknown"}` : "", 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 ? `token: refresh ${o.token.refreshFp} · access valid until ${new Date(o.token.accessExpiresAt).toLocaleTimeString()}` : "token: none", 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} (${st.lastLogin.mode})` : "last login: —", st.lastLogin ? `last login: ${new Date(st.lastLogin.at).toLocaleString()} · ${st.lastLogin.ok ? "ok" : "FAILED: " + st.lastLogin.detail} (${st.lastLogin.mode})` : "last login: —",
].filter(Boolean); ].filter(Boolean);
ctx.ui.notify(lines.join("\n"), "info"); ctx.ui.notify(lines.join("\n"), "info");
+36 -15
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, assess, reanchor, fmtLeft, DEFAULTS, isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS,
} from "../extensions/aws-sso.ts"; } from "../extensions/aws-sso.ts";
const START = "https://d-1234567890.awsapps.com/start"; const START = "https://d-1234567890.awsapps.com/start";
@@ -95,22 +95,43 @@ const H = 3600e3;
const T0 = Date.UTC(2026, 9, 11, 14, 19, 0); const T0 = Date.UTC(2026, 9, 11, 14, 19, 0);
const tok = (fp, accessExp) => ({ refreshFp: fp, accessExpiresAt: accessExp }); const tok = (fp, accessExp) => ({ refreshFp: fp, accessExpiresAt: accessExp });
test("assess: ok until the end, due just AFTER it (renew → fresh sign-in), expired once the 1-h token is gone too", () => { test("probeDue: only inside the SDK's 5-min window; 60 s apart while valid, 10 min apart once expired", () => {
const st = { anchor: { refreshFp: "aaaa1111", since: T0 } }; const exp = T0 + H;
assert.equal(assess(st, tok("aaaa1111", T0 + H), T0 + 1 * H).status, "ok"); assert.equal(REFRESH_WINDOW_MS, 5 * 60e3); // token-providers 3.1127.0 EXPIRE_WINDOW_MS
assert.equal(assess(st, tok("aaaa1111", T0 + 8.5 * H), T0 + 7.9 * H).status, "ok"); // early renewal gains nothing: do not renew before the end assert.equal(probeDue(exp, exp - 6 * 60e3, 0), false); // outside: the SDK would not even try
assert.equal(assess(st, tok("aaaa1111", T0 + 8.5 * H), T0 + 8 * H + 60e3).status, "ok"); // inside the grace minutes assert.equal(probeDue(exp, exp - 5 * 60e3, 0), true);
assert.equal(assess(st, tok("aaaa1111", T0 + 8.5 * H), T0 + 8 * H + DEFAULTS.renewAfterMinutes * 60e3).status, "due"); // 1-h token still valid: renew without an outage assert.equal(probeDue(exp, exp - 4 * 60e3, exp - 4.5 * 60e3), false); // 30 s after the last attempt
assert.equal(assess(st, tok("aaaa1111", T0 + 8 * H), T0 + 8.2 * H).status, "expired"); assert.equal(probeDue(exp, exp - 3 * 60e3, exp - 4.5 * 60e3), true);
assert.equal(assess(st, null, T0).status, "none"); assert.equal(probeDue(exp, exp + 5 * 60e3, exp + 1 * 60e3), false); // expired: back off
assert.equal(assess({}, tok("aaaa1111", T0 + H), T0 + 30 * H).status, "ok"); // no anchor: never invent an end assert.equal(probeDue(exp, exp + 12 * 60e3, exp + 1 * 60e3), true);
assert.equal(assess(st, tok("bbbb2222", T0 + H), T0 + 30 * H).endsAt, null); // anchor belongs to another token
}); });
test("assess honours a configured session length", () => { // probeWith: a fake SDK provider and a fake cache, mirroring token-providers' real behaviour.
const st = { sessionHours: 72, anchor: { refreshFp: "aaaa1111", since: T0 } }; const cacheSim = (exp) => ({ exp, read() { return this.exp; } });
assert.equal(assess(st, tok("aaaa1111", T0 + 9 * H), T0 + 8.2 * H).status, "ok"); test("probeWith: refreshed when the cache advances; refused when the SDK silently keeps the old token", async () => {
assert.equal(assess(st, tok("aaaa1111", T0 + 72.5 * H), T0 + 72.1 * H).status, "due"); 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);
assert.equal(r.result, "refreshed");
const ended = cacheSim(T0 + H); // session over: refresh fails inside the window, SDK returns the old token, 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);
assert.equal(r.result, "refused");
assert.match(r.error, /expired/);
});
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);
let r = await probeWith(() => () => async () => { called++; }, () => c.read(), "Base", () => T0);
assert.equal(r.result, "fresh");
assert.equal(called, 0);
r = await probeWith(() => { throw new Error("Cannot find module '@aws-sdk/token-providers'"); }, () => c.read(), "Base", () => T0 + H - 60e3);
assert.equal(r.result, "unavailable");
let seen;
await probeWith(() => (init) => { seen = init; return async () => {}; }, () => c.read(), "GFTLIoT-Base", () => T0 + H - 60e3);
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 new token BEFORE the end inherits it (silent renewal); AFTER the end or an expiry starts fresh", () => {