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:
+38
-18
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user