diff --git a/README.md b/README.md index 5d8f9e2..22ae179 100644 --- a/README.md +++ b/README.md @@ -359,44 +359,43 @@ Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/j ### `aws-sso.ts` 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 -an expired token) it runs `aws sso login` for you. **You still approve the login.** -Off until you enable it. +when the IAM Identity Center session ends it runs `aws sso login` for you, before a +request fails. **You still approve the login** (or your browser does it silently +while it is signed in). Off until you enable it. | Command | Effect | |---|---| | `/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 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 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 ` | 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. -**When it renews — and why not earlier.** Per AWS ("Understanding authentication -sessions"), the CLI's 1-hour token is refreshed by the SDK "as long as the underlying -user interactive session remains valid" — that is the browser's AWS sign-in, -admin-capped (8 h measured 2026-10-10). A login while it is valid completes -silently ("…can be used until your session expires") but **inherits its end**, so -renewing early gains nothing. The keeper therefore renews **2 minutes after** the -estimated end: the browser must sign in again through the IdP (silent while -"stay signed in" holds) and a fresh session starts, while the CLI's last 1-hour -token still covers the gap. If a request fails on an expired token anyway, it -renews and sends `continue` (once per 10 min; `/aws-sso continue off` to disable). +**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/.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]` +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 -`~/.aws/sso/cache/.json`: a new one **after** the end (or after -an observed expiry) starts a new session; a new one **before** the end is a silent -renewal and keeps the old end. `/aws-sso signed-in HH:MM` corrects the estimate. -Tokens are only ever logged as `sha256[:8]` fingerprints (`~/.pi/agent/aws-sso/events.jsonl`). - -**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`). +**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 happen at most once per +30 min, so a login waiting for you does not pile up tabs. `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). Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert. A lock file stops several pi processes from starting several logins. diff --git a/extensions/aws-sso.ts b/extensions/aws-sso.ts index 8e8a54f..afaad6f 100644 --- a/extensions/aws-sso.ts +++ b/extensions/aws-sso.ts @@ -13,24 +13,25 @@ * 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. * - * Timing (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an - * *application session* whose 1-hour token the SDK refreshes by itself "as long as - * the underlying user interactive session remains valid". The interactive session - * is the browser's AWS sign-in, admin-capped (8 h measured). A login while it is - * still valid completes silently but INHERITS its end — renewing early gains - * nothing. A login just AFTER it ends makes the browser sign in again (through the - * IdP, silent while "stay signed in" holds) and starts a fresh interactive session. - * So the keeper renews a couple of minutes after the estimated end, while the CLI's - * last 1-hour token still works, and if a request fails anyway it renews and can - * send "continue" so an unattended run resumes. - * The session start is not stored anywhere: it is inferred from refresh-token - * changes (a change after the end = new session; before the end = inherited). + * Detection (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an + * *application session* whose 1-hour token is refreshed "as long as the underlying + * user interactive session remains valid" (the browser's AWS sign-in, admin-capped; + * measured both 8 h and <= 6 h, so its length is NOT predictable). When it ends, + * the next refresh is refused. Inside the SDK's 5-minute window a refused refresh is + * silent: the SDK keeps returning the still-valid token, so requests only fail once + * that token runs out. The keeper makes the refresh attempt itself as soon as the + * window opens (same SDK, same cache file) and checks whether the cache advanced. + * If not, the session has ended: it logs in right away, while the old token still + * covers requests. A login while the browser sign-in is still valid is silent and + * inherits its end, which is why nothing is renewed on a clock. If a request fails + * anyway, it renews and can send "continue" so an unattended run resumes. * Tokens never leave this process except as sha256[:8] fingerprints. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { spawn } from "node:child_process"; 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 { join } from "node:path"; @@ -104,7 +105,6 @@ export interface State { enabled?: boolean; mode?: "mac" | "device"; sessionHours?: number; - renewAfterMinutes?: number; autoContinue?: boolean; /** since = start of the browser's interactive session (estimated). */ anchor?: { refreshFp: string; since: number }; @@ -113,24 +113,39 @@ export interface State { 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). - * none – no cached token - * expired – past the end AND the CLI's 1-hour token has run out too - * due – past the end + renewAfterMinutes: renew now (a fresh sign-in) - * ok – before that + * Should the keeper make a refresh attempt now? Only inside the SDK's window (outside + * it the SDK does not refresh, so an attempt proves nothing). Every 60 s while the + * token is still valid; every 10 min once it has run out (laptop slept, login pending). */ -export function assess(state: State, token: { refreshFp: string; accessExpiresAt: number } | null, now: number) { - const hours = state.sessionHours ?? DEFAULTS.sessionHours; - const after = (state.renewAfterMinutes ?? DEFAULTS.renewAfterMinutes) * 60e3; - if (!token) return { status: "none" as const, endsAt: null as number | null }; - 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 }; - if (endsAt !== null && now >= endsAt + after) return { status: "due" as const, endsAt }; - return { status: "ok" as const, endsAt }; +export function probeDue(accessExpiresAt: number, now: number, lastProbeAt: number): boolean { + const left = accessExpiresAt - now; + if (left > REFRESH_WINDOW_MS) return false; + return now - lastProbeAt >= (left > 0 ? 60e3 : 10 * 60e3); +} + +export type ProbeResult = { result: "fresh" | "refreshed" | "refused" | "unavailable"; before: number; after: 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. + */ +export async function probeWith(loadFromSso: () => (init: object) => () => Promise, readExpiresAt: () => number, profile: string, now: () => number = Date.now): Promise { + const before = readExpiresAt(); + if (before - now() > REFRESH_WINDOW_MS) return { result: "fresh", before, after: before }; + let provider: (init: object) => () => Promise; + 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; } } +/** The SDK pi itself uses (resolved from pi's install, not from this file's location). */ +let fromSsoCached: ((init: object) => () => Promise) | 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. */ function tryLock(): boolean { mkdirSync(DIR, { recursive: true, mode: 0o700 }); @@ -257,14 +285,17 @@ export default function (pi: ExtensionAPI) { let timer: ReturnType | null = null; let inFlight: Promise<{ ok: boolean; detail: string }> | 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; /** Observe the token, keep the anchor current, return the assessment. */ function observe() { const s = ssoSession(); - if ("error" in s) return { s: null, error: s.error, a: null as ReturnType | null, token: null }; + if ("error" in s) return { s: null, error: s.error, token: null, state: loadState() }; const token = readToken(s.sessionName); const state = loadState(); if (token) { @@ -276,7 +307,7 @@ export default function (pi: ExtensionAPI) { 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() { @@ -284,12 +315,26 @@ export default function (pi: ExtensionAPI) { if (!enabled()) { ui.setStatus("aws-sso", undefined); return; } if (inFlight) return; // the login path owns the status while it runs const o = observe(); - if (o.error || !o.a) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; } - const left = o.a.endsAt === null ? null : o.a.endsAt - Date.now(); - const txt = o.a.status === "none" ? "AWS ✗ not logged in" - : o.a.status === "expired" ? "AWS ✗ session expired" - : o.a.status === "due" ? "AWS ⚠ renewing" : left === null ? "AWS ✓" : `AWS ✓ ${fmtLeft(left)}`; - ui.setStatus("aws-sso", txt); + 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 ✓"); + } + + /** Refresh attempt inside the SDK window; on refusal, log in while the old token still works. */ + async function probe(why: string): Promise { + 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 { @@ -313,15 +358,17 @@ export default function (pi: ExtensionAPI) { } finally { inFlight = null; unlock(); render(); } } - function tick() { - if (!enabled()) { render(); return; } + async function tick() { + if (!enabled() || inFlight || probing) { render(); return; } const o = observe(); render(); - if (!o.a || inFlight) return; - // Just after the end: one attempt per session end, so an ignored login does not loop. - if ((o.a.status === "due" || o.a.status === "expired") && o.a.endsAt !== null && warnedEnd !== o.a.endsAt) { - warnedEnd = o.a.endsAt; - void login("session ended"); + if (!o.token) return; + if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return; + const r = await probe("refresh window"); + // At most one unprompted login per 30 min, so a login waiting for you does not pile up tabs. + 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; if (!ui) return; // print/json children: never start logins or timers render(); - timer = setInterval(tick, 60e3); - if (enabled()) tick(); + timer = setInterval(() => void tick(), 20e3); + if (enabled()) void tick(); }); 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. pi.on("before_agent_start", async () => { if (!ui || !enabled()) return; - const o = observe(); - if (o.a?.status === "expired" || o.a?.status === "none") await login("session expired"); - else if (inFlight) await inFlight; + if (inFlight) { await inFlight; return; } + let o = observe(); + 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. @@ -367,13 +418,13 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("aws-sso", { - description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off | signed-in HH:MM | hours ", + description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off", handler: async (args, ctx) => { ui = ctx.ui; const [cmd, val] = String(args ?? "").trim().split(/\s+/); const st = loadState(); 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 "now": await login("requested"); break; case "mode": @@ -382,29 +433,14 @@ export default function (pi: ExtensionAPI) { case "continue": 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; - 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 ", "error"); break; } - st.sessionHours = n; saveState(st); render(); ctx.ui.notify(`AWS SSO session length set to ${n} h.`, "info"); break; - } case "status": default: { const o = observe(); + const t = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 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.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 ? `token: refresh ${o.token.refreshFp} · access valid until ${new Date(o.token.accessExpiresAt).toLocaleTimeString()}` : "token: none", + 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", + 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: —", ].filter(Boolean); ctx.ui.notify(lines.join("\n"), "info"); diff --git a/test/aws-sso.test.mjs b/test/aws-sso.test.mjs index 191b387..87fba9c 100644 --- a/test/aws-sso.test.mjs +++ b/test/aws-sso.test.mjs @@ -5,7 +5,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput, - isSsoExpiredError, assess, reanchor, fmtLeft, DEFAULTS, + isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS, } from "../extensions/aws-sso.ts"; 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 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", () => { - const st = { anchor: { refreshFp: "aaaa1111", since: T0 } }; - assert.equal(assess(st, tok("aaaa1111", T0 + H), T0 + 1 * H).status, "ok"); - 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(assess(st, tok("aaaa1111", T0 + 8.5 * H), T0 + 8 * H + 60e3).status, "ok"); // inside the grace minutes - 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(assess(st, tok("aaaa1111", T0 + 8 * H), T0 + 8.2 * H).status, "expired"); - assert.equal(assess(st, null, T0).status, "none"); - assert.equal(assess({}, tok("aaaa1111", T0 + H), T0 + 30 * H).status, "ok"); // no anchor: never invent an end - assert.equal(assess(st, tok("bbbb2222", T0 + H), T0 + 30 * H).endsAt, null); // anchor belongs to another token +test("probeDue: only inside the SDK's 5-min window; 60 s apart while valid, 10 min apart once expired", () => { + const exp = T0 + H; + assert.equal(REFRESH_WINDOW_MS, 5 * 60e3); // token-providers 3.1127.0 EXPIRE_WINDOW_MS + assert.equal(probeDue(exp, exp - 6 * 60e3, 0), false); // outside: the SDK would not even try + assert.equal(probeDue(exp, exp - 5 * 60e3, 0), true); + assert.equal(probeDue(exp, exp - 4 * 60e3, exp - 4.5 * 60e3), false); // 30 s after the last attempt + assert.equal(probeDue(exp, exp - 3 * 60e3, exp - 4.5 * 60e3), true); + assert.equal(probeDue(exp, exp + 5 * 60e3, exp + 1 * 60e3), false); // expired: back off + assert.equal(probeDue(exp, exp + 12 * 60e3, exp + 1 * 60e3), true); }); -test("assess honours a configured session length", () => { - const st = { sessionHours: 72, anchor: { refreshFp: "aaaa1111", since: T0 } }; - assert.equal(assess(st, tok("aaaa1111", T0 + 9 * H), T0 + 8.2 * H).status, "ok"); - assert.equal(assess(st, tok("aaaa1111", T0 + 72.5 * H), T0 + 72.1 * H).status, "due"); +// 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); + 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", () => {