ag-awsauth 0.0.293 → 0.0.295

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -11,3 +11,6 @@ export declare const runConfig: () => void;
11
11
  export declare const timeoutMs = 30000;
12
12
  export declare const timeoutShortMs = 3000;
13
13
  export declare const timeoutMedMs = 6000;
14
+ export declare const refreshWindowMs: number;
15
+ export declare const credentialsLockTimeoutMs: number;
16
+ export declare const credentialsLockStaleMs: number;
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.timeoutMedMs = exports.timeoutShortMs = exports.timeoutMs = exports.runConfig = exports.setConfig = exports.validateConfig = exports.targetRegion = exports.ssoStartUrl = exports.identityCenterRegion = exports.setBasePath = exports.basePath = exports.stsDurationSeconds = exports.logPath = void 0;
6
+ exports.credentialsLockStaleMs = exports.credentialsLockTimeoutMs = exports.refreshWindowMs = exports.timeoutMedMs = exports.timeoutShortMs = exports.timeoutMs = exports.runConfig = exports.setConfig = exports.validateConfig = exports.targetRegion = exports.ssoStartUrl = exports.identityCenterRegion = exports.setBasePath = exports.basePath = exports.stsDurationSeconds = exports.logPath = void 0;
7
7
  const envfile_1 = require("envfile");
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
@@ -53,3 +53,10 @@ exports.runConfig = runConfig;
53
53
  exports.timeoutMs = 30000;
54
54
  exports.timeoutShortMs = 3000;
55
55
  exports.timeoutMedMs = 6000;
56
+ // Refresh the OIDC access token this far ahead of expiry so the rotating
57
+ // refresh-token chain stays alive instead of being used lazily after a 401.
58
+ exports.refreshWindowMs = 5 * 60 * 1000;
59
+ // Serialise credential file updates across processes. A refresh token is
60
+ // single-use, so two concurrent runs must never refresh at the same time.
61
+ exports.credentialsLockTimeoutMs = 10 * 1000;
62
+ exports.credentialsLockStaleMs = 30 * 1000;
@@ -1,3 +1,12 @@
1
1
  import type { IAwsCreds, IAwsCredsRaw } from '../types';
2
2
  export declare const getAwsCredentials: () => Promise<Record<string, IAwsCredsRaw>>;
3
+ export declare const readAwsCredentials: () => Promise<IAwsCreds | undefined>;
4
+ /**
5
+ * Read-modify-write of the credentials file. Callers must already hold the
6
+ * credentials lock (use `updateAwsCredentials` unless you are inside one).
7
+ *
8
+ * OIDC refresh material is preserved unless explicitly replaced: a partial
9
+ * credentials object must never wipe the only token that avoids a full login.
10
+ */
11
+ export declare const saveAwsCredentialsUnlocked: (p: IAwsCreds | undefined) => Promise<void>;
3
12
  export declare const updateAwsCredentials: (p: IAwsCreds | undefined) => Promise<void>;
@@ -12,15 +12,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.updateAwsCredentials = exports.getAwsCredentials = void 0;
15
+ exports.updateAwsCredentials = exports.saveAwsCredentialsUnlocked = exports.readAwsCredentials = exports.getAwsCredentials = void 0;
16
16
  const shared_ini_file_loader_1 = require("@aws-sdk/shared-ini-file-loader");
17
17
  const log_1 = require("ag-common/dist/common/helpers/log");
18
18
  const fs_1 = __importDefault(require("fs"));
19
19
  const ini_1 = require("ini");
20
20
  const path_1 = require("path");
21
+ const config_1 = require("../config");
22
+ const credentialsLock_1 = require("./credentialsLock");
21
23
  const getCredentialsFilepath_1 = require("./getCredentialsFilepath");
22
24
  const getAwsCredentials = () => __awaiter(void 0, void 0, void 0, function* () {
23
- const config = yield (0, shared_ini_file_loader_1.loadSharedConfigFiles)();
25
+ // Read fresh from disk: the SDK caches file contents per path, which hides
26
+ // tokens rotated by another process (or by an earlier step of this run).
27
+ const config = yield (0, shared_ini_file_loader_1.loadSharedConfigFiles)({ ignoreCache: true });
24
28
  const creds = config.credentialsFile;
25
29
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
26
30
  if (!creds.default) {
@@ -31,7 +35,34 @@ const getAwsCredentials = () => __awaiter(void 0, void 0, void 0, function* () {
31
35
  return ret;
32
36
  });
33
37
  exports.getAwsCredentials = getAwsCredentials;
34
- const updateAwsCredentials = (p) => __awaiter(void 0, void 0, void 0, function* () {
38
+ const readAwsCredentials = () => __awaiter(void 0, void 0, void 0, function* () {
39
+ const credraw = yield (0, exports.getAwsCredentials)();
40
+ if (!credraw.default.aws_access_token) {
41
+ return undefined;
42
+ }
43
+ return {
44
+ accessKeyId: credraw.default.aws_access_key_id,
45
+ secretAccessKey: credraw.default.aws_secret_access_key,
46
+ sessionToken: credraw.default.aws_session_token,
47
+ accessToken: credraw.default.aws_access_token,
48
+ ssoAuthn: credraw.default.aws_sso_authn,
49
+ refreshToken: credraw.default.aws_sso_refresh_token,
50
+ clientId: credraw.default.aws_sso_client_id,
51
+ clientSecret: credraw.default.aws_sso_client_secret,
52
+ clientSecretExpiresAt: Number(credraw.default.aws_sso_client_secret_expires_at) || undefined,
53
+ accessTokenExpiresAt: Number(credraw.default.aws_sso_access_token_expires_at) || undefined,
54
+ region: config_1.identityCenterRegion,
55
+ };
56
+ });
57
+ exports.readAwsCredentials = readAwsCredentials;
58
+ /**
59
+ * Read-modify-write of the credentials file. Callers must already hold the
60
+ * credentials lock (use `updateAwsCredentials` unless you are inside one).
61
+ *
62
+ * OIDC refresh material is preserved unless explicitly replaced: a partial
63
+ * credentials object must never wipe the only token that avoids a full login.
64
+ */
65
+ const saveAwsCredentialsUnlocked = (p) => __awaiter(void 0, void 0, void 0, function* () {
35
66
  const creds = yield (0, exports.getAwsCredentials)();
36
67
  if (!p) {
37
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -44,26 +75,23 @@ const updateAwsCredentials = (p) => __awaiter(void 0, void 0, void 0, function*
44
75
  creds.default.aws_session_token = p.sessionToken;
45
76
  creds.default.aws_access_token = p.accessToken;
46
77
  creds.default.aws_sso_authn = p.ssoAuthn;
47
- if (p.refreshToken &&
48
- p.clientId &&
49
- p.clientSecret &&
50
- p.clientSecretExpiresAt &&
51
- p.accessTokenExpiresAt) {
78
+ if (p.refreshToken) {
52
79
  creds.default.aws_sso_refresh_token = p.refreshToken;
80
+ }
81
+ if (p.clientId) {
53
82
  creds.default.aws_sso_client_id = p.clientId;
83
+ }
84
+ if (p.clientSecret) {
54
85
  creds.default.aws_sso_client_secret = p.clientSecret;
86
+ }
87
+ if (p.clientSecretExpiresAt) {
55
88
  creds.default.aws_sso_client_secret_expires_at =
56
89
  p.clientSecretExpiresAt.toString();
90
+ }
91
+ if (p.accessTokenExpiresAt) {
57
92
  creds.default.aws_sso_access_token_expires_at =
58
93
  p.accessTokenExpiresAt.toString();
59
94
  }
60
- else {
61
- delete creds.default.aws_sso_refresh_token;
62
- delete creds.default.aws_sso_client_id;
63
- delete creds.default.aws_sso_client_secret;
64
- delete creds.default.aws_sso_client_secret_expires_at;
65
- delete creds.default.aws_sso_access_token_expires_at;
66
- }
67
95
  }
68
96
  const newcreds = (0, ini_1.stringify)(creds);
69
97
  (0, log_1.info)(`saving updated default creds to .aws/credentials`);
@@ -73,6 +101,8 @@ const updateAwsCredentials = (p) => __awaiter(void 0, void 0, void 0, function*
73
101
  fs_1.default.mkdirSync(credentialsDir, { recursive: true });
74
102
  (0, log_1.info)(`created AWS credentials directory: ${credentialsDir}`);
75
103
  }
76
- fs_1.default.writeFileSync(credspath, newcreds);
104
+ (0, credentialsLock_1.writeFileAtomic)(credspath, newcreds);
77
105
  });
106
+ exports.saveAwsCredentialsUnlocked = saveAwsCredentialsUnlocked;
107
+ const updateAwsCredentials = (p) => __awaiter(void 0, void 0, void 0, function* () { return (0, credentialsLock_1.withCredentialsLock)(() => (0, exports.saveAwsCredentialsUnlocked)(p)); });
78
108
  exports.updateAwsCredentials = updateAwsCredentials;
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.allowAccessIfRequested = exports.dismissAllowAccessPrompt = exports.allowAccessSelectors = exports.goToPage = exports.launchBrowser = exports.closeBrowser = void 0;
13
13
  exports.getMFA = getMFA;
14
14
  const log_1 = require("ag-common/dist/common/helpers/log");
15
+ const sleep_1 = require("ag-common/dist/common/helpers/sleep");
15
16
  const puppeteer_1 = require("puppeteer");
16
17
  const __1 = require("..");
17
18
  const config_1 = require("../config");
@@ -103,9 +104,7 @@ const clickSelectorIfPresent = (page_1, selector_1, ...args_1) => __awaiter(void
103
104
  catch (_a) {
104
105
  // Element may detach between query and click (SPA re-render).
105
106
  // Fall back to an in-page click which re-queries the selector.
106
- yield page
107
- .$eval(selector, (bn) => bn.click())
108
- .catch(() => undefined);
107
+ yield page.$eval(selector, (bn) => bn.click()).catch(() => undefined);
109
108
  }
110
109
  return true;
111
110
  }
@@ -113,24 +112,23 @@ const clickSelectorIfPresent = (page_1, selector_1, ...args_1) => __awaiter(void
113
112
  return false;
114
113
  }
115
114
  });
116
- const clickAllowAccessByText = (page) => __awaiter(void 0, void 0, void 0, function* () {
115
+ const clickAllowAccessByText = (page) => {
117
116
  try {
118
- return yield page.evaluate(() => {
117
+ const clicked = page.evaluate(() => {
119
118
  const isVisible = (el) => {
120
119
  const rect = el.getBoundingClientRect();
121
- return (rect.width > 0 &&
122
- rect.height > 0 &&
123
- getComputedStyle(el).visibility !== 'hidden');
120
+ return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
124
121
  };
125
122
  // Match button, link, or ARIA-button variants: AWS renders the
126
123
  // "Allow access" consent action differently across releases
127
124
  // (orange button vs link), so don't assume a <button>.
128
125
  const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="submit"], input[type="button"]')).filter((el) => isVisible(el));
129
126
  const label = (el) => {
130
- var _a, _b;
131
- return ((_b = (_a = el.value) !== null && _a !== void 0 ? _a : el.textContent) !== null && _b !== void 0 ? _b : '')
132
- .trim()
133
- .toLowerCase();
127
+ var _a;
128
+ const inputValue = el.value;
129
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- value is undefined on non-inputs
130
+ const text = typeof inputValue === 'string' ? inputValue : ((_a = el.textContent) !== null && _a !== void 0 ? _a : '');
131
+ return text.trim().toLowerCase();
134
132
  };
135
133
  const exact = candidates.find((el) => label(el) === 'allow access');
136
134
  const target = exact !== null && exact !== void 0 ? exact : candidates.find((el) => label(el).includes('allow access'));
@@ -140,17 +138,17 @@ const clickAllowAccessByText = (page) => __awaiter(void 0, void 0, void 0, funct
140
138
  }
141
139
  return false;
142
140
  });
141
+ return clicked;
143
142
  }
144
143
  catch (_a) {
145
- return false;
144
+ return Promise.resolve(false);
146
145
  }
147
- });
146
+ };
148
147
  const dismissAllowAccessPrompt = (page_1, ...args_1) => __awaiter(void 0, [page_1, ...args_1], void 0, function* (page, perSelectorTimeout = 1000) {
149
- for (const selector of exports.allowAccessSelectors) {
150
- if (yield clickSelectorIfPresent(page, selector, perSelectorTimeout)) {
151
- (0, log_1.info)(`click allow-access-button (${selector})`);
152
- return true;
153
- }
148
+ const clicked = yield Promise.all(exports.allowAccessSelectors.map((selector) => clickSelectorIfPresent(page, selector, perSelectorTimeout)));
149
+ if (clicked.some(Boolean)) {
150
+ (0, log_1.info)('click allow-access-button');
151
+ return true;
154
152
  }
155
153
  if (yield clickAllowAccessByText(page)) {
156
154
  (0, log_1.info)('click allow-access-button (text match "Allow access")');
@@ -159,29 +157,40 @@ const dismissAllowAccessPrompt = (page_1, ...args_1) => __awaiter(void 0, [page_
159
157
  return false;
160
158
  });
161
159
  exports.dismissAllowAccessPrompt = dismissAllowAccessPrompt;
160
+ const pollDelayMs = 500;
161
+ const sleepUntilDeadline = (deadline) => __awaiter(void 0, void 0, void 0, function* () {
162
+ const remaining = deadline - Date.now();
163
+ if (remaining <= 0) {
164
+ return;
165
+ }
166
+ yield (0, sleep_1.sleep)(Math.min(pollDelayMs, remaining));
167
+ });
162
168
  const allowAccessIfRequested = (page_1, ...args_1) => __awaiter(void 0, [page_1, ...args_1], void 0, function* (page, timeout = config_1.timeoutMs) {
163
169
  const deadline = Date.now() + timeout;
164
170
  // Poll so a slow-rendering consent dialog is not missed, while still
165
171
  // returning quickly when the prompt appears (or never appears).
166
- while (Date.now() < deadline) {
172
+ // oxlint-disable-next-line no-await-in-loop -- polling must stay sequential to match dialog timing
173
+ for (;;) {
174
+ // oxlint-disable-next-line no-await-in-loop -- each poll depends on the current page state
167
175
  if (yield (0, exports.dismissAllowAccessPrompt)(page)) {
168
176
  return true;
169
177
  }
170
- if (Date.now() + 500 >= deadline) {
171
- break;
178
+ if (Date.now() >= deadline) {
179
+ return false;
172
180
  }
173
- yield new Promise((resolve) => setTimeout(resolve, 500));
181
+ // oxlint-disable-next-line no-await-in-loop -- delay must elapse before the next poll
182
+ yield sleepUntilDeadline(deadline);
174
183
  }
175
- return false;
176
184
  });
177
185
  exports.allowAccessIfRequested = allowAccessIfRequested;
178
186
  const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page_1, ...args_1], void 0, function* (page, timeout = config_1.timeoutMs) {
179
- var _a;
180
187
  const deadline = Date.now() + timeout;
181
188
  let lastError;
182
- while (Date.now() < deadline) {
189
+ // oxlint-disable-next-line no-await-in-loop -- success and consent checks are order-dependent
190
+ for (;;) {
183
191
  // Success already visible?
184
192
  try {
193
+ // oxlint-disable-next-line no-await-in-loop -- must check before dismissing prompts
185
194
  const success = yield page.waitForSelector('[data-analytics-alert="success"]', { timeout: 2000 });
186
195
  if (success) {
187
196
  return;
@@ -194,6 +203,7 @@ const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page
194
203
  // This covers the "Allow <user> to access your data? / Allow access"
195
204
  // dialog which otherwise blocks success indefinitely.
196
205
  try {
206
+ // oxlint-disable-next-line no-await-in-loop -- prompt check follows the success check
197
207
  if (yield (0, exports.dismissAllowAccessPrompt)(page)) {
198
208
  (0, log_1.info)('dismissed allow-access prompt while waiting for success');
199
209
  continue;
@@ -204,6 +214,7 @@ const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page
204
214
  }
205
215
  // Also re-check the other intermediate confirmations while polling.
206
216
  try {
217
+ // oxlint-disable-next-line no-await-in-loop -- confirmation check follows the success check
207
218
  if (yield clickSelectorIfPresent(page, '[data-analytics="accept-user-code"]', 1000)) {
208
219
  (0, log_1.info)('click accept-user-code (while waiting for success)');
209
220
  continue;
@@ -213,6 +224,7 @@ const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page
213
224
  (0, log_1.debug)('error clicking accept-user-code while waiting:', e);
214
225
  }
215
226
  try {
227
+ // oxlint-disable-next-line no-await-in-loop -- auth request check follows the success check
216
228
  if (yield clickSelectorIfPresent(page, '#cli_verification_btn', 1000)) {
217
229
  (0, log_1.info)('clicking auth request button (while waiting for success)');
218
230
  }
@@ -220,6 +232,11 @@ const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page
220
232
  catch (e) {
221
233
  (0, log_1.debug)('error clicking auth request button while waiting:', e);
222
234
  }
235
+ if (Date.now() >= deadline) {
236
+ break;
237
+ }
238
+ // oxlint-disable-next-line no-await-in-loop -- delay must elapse before the next poll
239
+ yield sleepUntilDeadline(deadline);
223
240
  }
224
241
  // Final wait to surface a clear TimeoutError if success never arrived.
225
242
  try {
@@ -227,8 +244,11 @@ const waitForSuccessDefensively = (page_1, ...args_1) => __awaiter(void 0, [page
227
244
  timeout: config_1.timeoutShortMs,
228
245
  });
229
246
  }
230
- catch (_b) {
231
- throw ((_a = lastError) !== null && _a !== void 0 ? _a : new Error('Timed out waiting for success'));
247
+ catch (_a) {
248
+ if (lastError instanceof Error) {
249
+ throw lastError;
250
+ }
251
+ throw new Error('Timed out waiting for success');
232
252
  }
233
253
  });
234
254
  function getMFA(p) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Runs `fn` while holding an exclusive, cross-process lock next to the
3
+ * credentials file. Credential updates must be serialised because a rotated
4
+ * OIDC refresh token is single-use: two overlapping runs that refresh at the
5
+ * same time leave a dead token on disk.
6
+ */
7
+ export declare function withCredentialsLock<T>(fn: () => Promise<T>): Promise<T>;
8
+ /**
9
+ * Writes via a temp file and an atomic rename so a crash can never leave a
10
+ * truncated credentials file (which would lose the refresh token).
11
+ */
12
+ export declare const writeFileAtomic: (filePath: string, contents: string) => void;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.writeFileAtomic = void 0;
16
+ exports.withCredentialsLock = withCredentialsLock;
17
+ /* eslint-disable no-await-in-loop */
18
+ const sleep_1 = require("ag-common/dist/common/helpers/sleep");
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = require("path");
21
+ const config_1 = require("../config");
22
+ const getCredentialsFilepath_1 = require("./getCredentialsFilepath");
23
+ const getLockPath = () => `${(0, getCredentialsFilepath_1.getCredentialsFilepath)()}.lock`;
24
+ const ensureCredentialsDir = () => {
25
+ const dir = (0, path_1.dirname)((0, getCredentialsFilepath_1.getCredentialsFilepath)());
26
+ if (!fs_1.default.existsSync(dir)) {
27
+ fs_1.default.mkdirSync(dir, { recursive: true });
28
+ }
29
+ };
30
+ const tryAcquire = () => {
31
+ try {
32
+ fs_1.default.mkdirSync(getLockPath());
33
+ return true;
34
+ }
35
+ catch (e) {
36
+ if (e.code === 'EEXIST') {
37
+ return false;
38
+ }
39
+ throw e;
40
+ }
41
+ };
42
+ const isStale = () => {
43
+ try {
44
+ return (Date.now() - fs_1.default.statSync(getLockPath()).mtimeMs > config_1.credentialsLockStaleMs);
45
+ }
46
+ catch (_a) {
47
+ return false;
48
+ }
49
+ };
50
+ const release = () => {
51
+ try {
52
+ fs_1.default.rmSync(getLockPath(), { recursive: true, force: true });
53
+ }
54
+ catch (_a) {
55
+ //
56
+ }
57
+ };
58
+ /**
59
+ * Runs `fn` while holding an exclusive, cross-process lock next to the
60
+ * credentials file. Credential updates must be serialised because a rotated
61
+ * OIDC refresh token is single-use: two overlapping runs that refresh at the
62
+ * same time leave a dead token on disk.
63
+ */
64
+ function withCredentialsLock(fn) {
65
+ return __awaiter(this, void 0, void 0, function* () {
66
+ ensureCredentialsDir();
67
+ const deadline = Date.now() + config_1.credentialsLockTimeoutMs;
68
+ while (!tryAcquire()) {
69
+ if (isStale()) {
70
+ release();
71
+ continue;
72
+ }
73
+ if (Date.now() >= deadline) {
74
+ // Another process is holding the lock. Proceed best-effort rather than
75
+ // blocking the user forever; stale-lock handling bounds the risk.
76
+ return fn();
77
+ }
78
+ yield (0, sleep_1.sleep)(100);
79
+ }
80
+ try {
81
+ const result = yield fn();
82
+ return result;
83
+ }
84
+ finally {
85
+ release();
86
+ }
87
+ });
88
+ }
89
+ /**
90
+ * Writes via a temp file and an atomic rename so a crash can never leave a
91
+ * truncated credentials file (which would lose the refresh token).
92
+ */
93
+ const writeFileAtomic = (filePath, contents) => {
94
+ const dir = (0, path_1.dirname)(filePath);
95
+ if (!fs_1.default.existsSync(dir)) {
96
+ fs_1.default.mkdirSync(dir, { recursive: true });
97
+ }
98
+ const tmp = (0, path_1.join)(dir, `.${process.pid}.${Date.now()}.tmp`);
99
+ fs_1.default.writeFileSync(tmp, contents);
100
+ fs_1.default.renameSync(tmp, filePath);
101
+ };
102
+ exports.writeFileAtomic = writeFileAtomic;
@@ -3,4 +3,18 @@ export declare class InteractiveSignInRequiredError extends Error {
3
3
  name: string;
4
4
  }
5
5
  export declare function requiresInteractiveSignIn(e: unknown): boolean;
6
+ /**
7
+ * True when the access token is missing, has unknown expiry, or is within the
8
+ * proactive refresh window of expiring.
9
+ */
10
+ export declare function needsRefresh(p: IAwsCreds): boolean;
6
11
  export declare function refreshAccessToken(p: IAwsCreds): Promise<IAwsCreds>;
12
+ /**
13
+ * Refreshes the OIDC access token ahead of expiry so the rotating refresh
14
+ * token chain stays alive, then persists the result atomically under a lock.
15
+ *
16
+ * Terminal failures (rejected/expired refresh token) are rethrown so the
17
+ * caller can start an interactive sign-in. Transient failures keep the cached
18
+ * token and never wipe it.
19
+ */
20
+ export declare function ensureFreshCredentials(p: IAwsCreds): Promise<IAwsCreds>;
@@ -11,9 +11,14 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.InteractiveSignInRequiredError = void 0;
13
13
  exports.requiresInteractiveSignIn = requiresInteractiveSignIn;
14
+ exports.needsRefresh = needsRefresh;
14
15
  exports.refreshAccessToken = refreshAccessToken;
16
+ exports.ensureFreshCredentials = ensureFreshCredentials;
15
17
  const client_sso_oidc_1 = require("@aws-sdk/client-sso-oidc");
18
+ const log_1 = require("ag-common/dist/common/helpers/log");
16
19
  const config_1 = require("../config");
20
+ const awsconfig_1 = require("./awsconfig");
21
+ const credentialsLock_1 = require("./credentialsLock");
17
22
  class InteractiveSignInRequiredError extends Error {
18
23
  constructor() {
19
24
  super(...arguments);
@@ -21,24 +26,41 @@ class InteractiveSignInRequiredError extends Error {
21
26
  }
22
27
  }
23
28
  exports.InteractiveSignInRequiredError = InteractiveSignInRequiredError;
29
+ // Errors that mean the stored registration/token can never be reused.
30
+ const terminalErrorNames = [
31
+ 'InteractiveSignInRequiredError',
32
+ 'InvalidGrantException',
33
+ 'InvalidClientException',
34
+ 'UnauthorizedClientException',
35
+ ];
24
36
  function requiresInteractiveSignIn(e) {
25
- return (e instanceof Error &&
26
- [
27
- 'InteractiveSignInRequiredError',
28
- 'InvalidGrantException',
29
- 'InvalidClientException',
30
- 'UnauthorizedClientException',
31
- ].includes(e.name));
37
+ return e instanceof Error && terminalErrorNames.includes(e.name);
32
38
  }
39
+ const hasRefreshMaterial = (p) => Boolean(p.refreshToken && p.clientId && p.clientSecret);
40
+ /**
41
+ * True when the access token is missing, has unknown expiry, or is within the
42
+ * proactive refresh window of expiring.
43
+ */
44
+ function needsRefresh(p) {
45
+ if (!p.accessToken) {
46
+ return true;
47
+ }
48
+ if (!p.accessTokenExpiresAt) {
49
+ return true;
50
+ }
51
+ return p.accessTokenExpiresAt - Date.now() <= config_1.refreshWindowMs;
52
+ }
53
+ const fingerprint = (t) => (t ? `${t.slice(0, 8)}...` : 'none');
33
54
  function refreshAccessToken(p) {
34
55
  return __awaiter(this, void 0, void 0, function* () {
35
- if (!p.refreshToken || !p.clientId || !p.clientSecret) {
56
+ if (!hasRefreshMaterial(p)) {
36
57
  throw new InteractiveSignInRequiredError('OIDC refresh details are missing');
37
58
  }
38
59
  if (p.clientSecretExpiresAt &&
39
60
  p.clientSecretExpiresAt <= Math.floor(Date.now() / 1000)) {
40
61
  throw new InteractiveSignInRequiredError('OIDC client registration has expired');
41
62
  }
63
+ (0, log_1.info)(`refreshing OIDC token with refresh token ${fingerprint(p.refreshToken)}`);
42
64
  const response = yield new client_sso_oidc_1.SSOOIDCClient({
43
65
  region: config_1.identityCenterRegion,
44
66
  }).send(new client_sso_oidc_1.CreateTokenCommand({
@@ -50,6 +72,45 @@ function refreshAccessToken(p) {
50
72
  if (!response.accessToken || !response.expiresIn) {
51
73
  throw new Error('OIDC refresh did not return an access token');
52
74
  }
75
+ const rotated = Boolean(response.refreshToken && response.refreshToken !== p.refreshToken);
76
+ (0, log_1.info)(`OIDC refresh succeeded; refresh token ${rotated ? 'rotated' : 'reused'}`);
53
77
  return Object.assign(Object.assign({}, p), { accessToken: response.accessToken, refreshToken: response.refreshToken || p.refreshToken, accessTokenExpiresAt: Date.now() + response.expiresIn * 1000 });
54
78
  });
55
79
  }
80
+ /**
81
+ * Refreshes the OIDC access token ahead of expiry so the rotating refresh
82
+ * token chain stays alive, then persists the result atomically under a lock.
83
+ *
84
+ * Terminal failures (rejected/expired refresh token) are rethrown so the
85
+ * caller can start an interactive sign-in. Transient failures keep the cached
86
+ * token and never wipe it.
87
+ */
88
+ function ensureFreshCredentials(p) {
89
+ return __awaiter(this, void 0, void 0, function* () {
90
+ if (!hasRefreshMaterial(p) || !needsRefresh(p)) {
91
+ return p;
92
+ }
93
+ try {
94
+ const refreshed = yield (0, credentialsLock_1.withCredentialsLock)(() => __awaiter(this, void 0, void 0, function* () {
95
+ var _a;
96
+ // Another process may have refreshed while we waited for the lock.
97
+ const latest = (_a = (yield (0, awsconfig_1.readAwsCredentials)())) !== null && _a !== void 0 ? _a : p;
98
+ if (!hasRefreshMaterial(latest) || !needsRefresh(latest)) {
99
+ (0, log_1.info)('OIDC token already refreshed by another process; reusing it');
100
+ return latest;
101
+ }
102
+ const rotated = yield refreshAccessToken(latest);
103
+ yield (0, awsconfig_1.saveAwsCredentialsUnlocked)(rotated);
104
+ return rotated;
105
+ }));
106
+ return refreshed;
107
+ }
108
+ catch (e) {
109
+ if (requiresInteractiveSignIn(e)) {
110
+ throw e;
111
+ }
112
+ (0, log_1.warn)(`Unable to refresh OIDC token (${e instanceof Error ? e.name : 'unknown error'}); using cached token`);
113
+ return p;
114
+ }
115
+ });
116
+ }
@@ -114,23 +114,24 @@ function getSamlAssertion(p, instance) {
114
114
  });
115
115
  }
116
116
  const tryExistingCredentials = () => __awaiter(void 0, void 0, void 0, function* () {
117
- const credraw = yield (0, awsconfig_1.getAwsCredentials)();
118
- if (!credraw.default.aws_access_token) {
117
+ const loaded = yield (0, awsconfig_1.readAwsCredentials)();
118
+ if (!loaded) {
119
119
  return undefined;
120
120
  }
121
- let credentials = {
122
- accessKeyId: credraw.default.aws_access_key_id,
123
- secretAccessKey: credraw.default.aws_secret_access_key,
124
- sessionToken: credraw.default.aws_session_token,
125
- accessToken: credraw.default.aws_access_token,
126
- ssoAuthn: credraw.default.aws_sso_authn,
127
- refreshToken: credraw.default.aws_sso_refresh_token,
128
- clientId: credraw.default.aws_sso_client_id,
129
- clientSecret: credraw.default.aws_sso_client_secret,
130
- clientSecretExpiresAt: Number(credraw.default.aws_sso_client_secret_expires_at) || undefined,
131
- accessTokenExpiresAt: Number(credraw.default.aws_sso_access_token_expires_at) || undefined,
132
- region: config_1.identityCenterRegion,
133
- };
121
+ let credentials = loaded;
122
+ // Refresh proactively, independent of the portal session: the portal token
123
+ // can still be valid while the OIDC token is stale, and letting the refresh
124
+ // token go unused is what eventually kills it.
125
+ try {
126
+ credentials = yield (0, oidcRefresh_1.ensureFreshCredentials)(credentials);
127
+ }
128
+ catch (e) {
129
+ if (!(0, oidcRefresh_1.requiresInteractiveSignIn)(e)) {
130
+ throw e;
131
+ }
132
+ (0, log_1.warn)(`Unable to reuse the cached SSO session (${e instanceof Error ? e.name : 'unknown error'}); interactive sign-in required.`);
133
+ return emptyCredentials();
134
+ }
134
135
  const refresh = () => __awaiter(void 0, void 0, void 0, function* () {
135
136
  credentials = yield (0, oidcRefresh_1.refreshAccessToken)(credentials);
136
137
  yield (0, awsconfig_1.updateAwsCredentials)(credentials);
package/dist/index.js CHANGED
@@ -74,6 +74,9 @@ function main(args) {
74
74
  identityCenterRegion: config_1.identityCenterRegion,
75
75
  ssoStartUrl: config_1.ssoStartUrl,
76
76
  });
77
+ // Persist the fresh refresh registration before any AWS call can fail;
78
+ // otherwise a later error would throw away the only non-interactive token.
79
+ yield (0, awsconfig_1.updateAwsCredentials)(credentials);
77
80
  (0, log_1.info)('get oidc creds');
78
81
  credentials = yield (0, sso_1.getOIDCCredentialsFromAccessToken)(credentials);
79
82
  }
@@ -86,8 +89,20 @@ function main(args) {
86
89
  let debugRole = '';
87
90
  if (instance.searchMetadata) {
88
91
  (0, log_1.info)('account is native aws, directly connecting');
92
+ const accountId = instance.searchMetadata.AccountId;
89
93
  try {
90
- credentials = yield (0, sso_1.getOIDCCredentialsFromAccessToken)(credentials, instance.searchMetadata.AccountId);
94
+ credentials = yield (0, oidcRefresh_1.ensureFreshCredentials)(credentials);
95
+ }
96
+ catch (e) {
97
+ if (!(0, oidcRefresh_1.requiresInteractiveSignIn)(e)) {
98
+ throw e;
99
+ }
100
+ (0, log_1.warn)(`Unable to reuse the cached SSO session (${e instanceof Error ? e.name : 'unknown error'}); interactive sign-in required.`);
101
+ credentials = yield (0, oidc_1.requestMFA)({ identityCenterRegion: config_1.identityCenterRegion, ssoStartUrl: config_1.ssoStartUrl });
102
+ yield (0, awsconfig_1.updateAwsCredentials)(credentials);
103
+ }
104
+ try {
105
+ credentials = yield (0, sso_1.getOIDCCredentialsFromAccessToken)(credentials, accountId);
91
106
  }
92
107
  catch (e) {
93
108
  if (!(e instanceof Error) || e.name !== 'UnauthorizedException') {
@@ -95,20 +110,20 @@ function main(args) {
95
110
  }
96
111
  try {
97
112
  credentials = yield (0, oidcRefresh_1.refreshAccessToken)(credentials);
113
+ yield (0, awsconfig_1.updateAwsCredentials)(credentials);
98
114
  }
99
- catch (e) {
100
- if (!(0, oidcRefresh_1.requiresInteractiveSignIn)(e)) {
101
- throw e;
115
+ catch (refreshError) {
116
+ if (!(0, oidcRefresh_1.requiresInteractiveSignIn)(refreshError)) {
117
+ throw refreshError;
102
118
  }
103
- (0, log_1.warn)(`Unable to reuse the cached SSO session (${e instanceof Error ? e.name : 'unknown error'}); interactive sign-in required.`);
119
+ (0, log_1.warn)(`Unable to reuse the cached SSO session (${refreshError instanceof Error ? refreshError.name : 'unknown error'}); interactive sign-in required.`);
104
120
  credentials = yield (0, oidc_1.requestMFA)({ identityCenterRegion: config_1.identityCenterRegion, ssoStartUrl: config_1.ssoStartUrl });
121
+ yield (0, awsconfig_1.updateAwsCredentials)(credentials);
105
122
  }
106
- // Save rotated tokens before another AWS request can fail.
107
- yield (0, awsconfig_1.updateAwsCredentials)(credentials);
108
- credentials = yield (0, sso_1.getOIDCCredentialsFromAccessToken)(credentials, instance.searchMetadata.AccountId);
123
+ credentials = yield (0, sso_1.getOIDCCredentialsFromAccessToken)(credentials, accountId);
109
124
  }
110
125
  credentials.region = config_1.targetRegion;
111
- debugRole = instance.searchMetadata.AccountId;
126
+ debugRole = accountId;
112
127
  }
113
128
  else {
114
129
  (0, log_1.info)('account is external app, getting saml');
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "ag-awsauth",
3
+ "version": "0.0.295",
4
+ "private": false,
3
5
  "description": "auth to aws sso/iamv2 easily",
4
- "main": "dist/index.js",
5
- "author": "andreigec@hotmail.com",
6
6
  "license": "ISC",
7
- "private": false,
8
- "version": "0.0.293",
9
- "preferGlobal": true,
7
+ "author": "andreigec@hotmail.com",
10
8
  "bin": {
11
9
  "ag-awsauth": "./bin/awsauth.js"
12
10
  },
@@ -16,18 +14,21 @@
16
14
  "README.md",
17
15
  "LICENSE.md"
18
16
  ],
17
+ "main": "dist/index.js",
19
18
  "dependencies": {
20
19
  "@aws-sdk/client-sso": "3.972.0",
21
20
  "@aws-sdk/client-sso-oidc": "3.972.0",
22
21
  "@aws-sdk/client-sts": "3.972.0",
23
22
  "@aws-sdk/shared-ini-file-loader": "3.374.0",
24
- "ag-common": "0.0.861",
23
+ "ag-common": "0.0.911",
25
24
  "cli-select": "1.1.2",
26
25
  "dotenv": "17.2.3",
27
26
  "envfile": "7.1.0",
28
- "eslint-config-e7npm": "0.1.31",
27
+ "eslint-config-e7npm": "0.1.68",
29
28
  "ini": "5.0.0",
30
29
  "node-fetch": "3.3.2",
30
+ "oxfmt": "^0.65.0",
31
+ "oxlint": "^1.83.0",
31
32
  "puppeteer": "24.35.0",
32
33
  "readline-sync": "1.4.10",
33
34
  "typescript": "5.9.3",
@@ -43,12 +44,14 @@
43
44
  "resolutions": {
44
45
  "ws": ">=8.17.1"
45
46
  },
47
+ "preferGlobal": true,
46
48
  "engines": {
47
49
  "node": ">=20"
48
50
  },
49
51
  "scripts": {
50
- "lint": "eslint src",
51
- "format": "eslint src --fix",
52
+ "format": "e7-oxfmt --config .oxfmtrc.json --disable-nested-config .",
53
+ "lint": "e7-oxlint --config .oxlintrc.json --deny-warnings src && pnpm run typecheck",
54
+ "typecheck": "tsc --noEmit",
52
55
  "test": "tsc && node --test test/*.test.js",
53
56
  "start": "tsc && node bin/awsauth.js",
54
57
  "build": "tsc"