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