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:
+86
-40
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user