@indigoai-us/hq-cloud 6.14.11 → 6.14.13
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/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +6 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +7 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +3 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +10 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +83 -33
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +93 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +23 -60
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +2 -2
- package/dist/cli/reindex.test.js +60 -109
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +42 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +5 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +3 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +14 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +8 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +44 -12
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +440 -76
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +174 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.js +683 -5
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +2 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +45 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +77 -7
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +2 -1
- package/src/bin/sync-runner-company.ts +5 -0
- package/src/bin/sync-runner-planning.ts +2 -1
- package/src/bin/sync-runner-watch-loop.ts +7 -1
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +122 -4
- package/src/bin/sync-runner.ts +92 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue-classify-ordering.test.ts +49 -0
- package/src/cli/rescue-core.ts +5 -1
- package/src/cli/rescue.reindex.test.ts +3 -2
- package/src/cli/share.test.ts +20 -0
- package/src/cli/share.ts +7 -1
- package/src/cli/sync.test.ts +14 -0
- package/src/cli/sync.ts +5 -1
- package/src/cognito-auth.test.ts +227 -0
- package/src/cognito-auth.ts +533 -71
- package/src/index.ts +5 -1
- package/src/machine-auth.test.ts +808 -5
- package/src/vault-client.test.ts +100 -11
- package/src/vault-client.ts +55 -10
- package/test/share-sync.integration.test.ts +12 -9
package/src/cognito-auth.ts
CHANGED
|
@@ -68,18 +68,89 @@ export class CognitoAuthError extends Error {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A failed refresh attempt with enough classification for callers to decide
|
|
73
|
+
* whether the cached session is still worth preserving. Network/5xx/429
|
|
74
|
+
* failures remain recoverable; an invalid/expired refresh token means the
|
|
75
|
+
* stored session can no longer make progress and should be cleared.
|
|
76
|
+
*/
|
|
77
|
+
export class CognitoRefreshError extends CognitoAuthError {
|
|
78
|
+
public readonly requiresReauth: boolean;
|
|
79
|
+
public readonly statusCode?: number;
|
|
80
|
+
public readonly sessionFingerprint?: string;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
message: string,
|
|
84
|
+
requiresReauth: boolean,
|
|
85
|
+
statusCode?: number,
|
|
86
|
+
sessionFingerprint?: string,
|
|
87
|
+
) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "CognitoRefreshError";
|
|
90
|
+
this.requiresReauth = requiresReauth;
|
|
91
|
+
this.statusCode = statusCode;
|
|
92
|
+
this.sessionFingerprint = sessionFingerprint;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
// ---------------------------------------------------------------------------
|
|
72
97
|
// Token cache (~/.hq/cognito-tokens.json)
|
|
73
98
|
// ---------------------------------------------------------------------------
|
|
74
99
|
|
|
75
100
|
const HQ_DIR = path.join(os.homedir(), ".hq");
|
|
76
101
|
const TOKEN_FILE = path.join(HQ_DIR, "cognito-tokens.json");
|
|
102
|
+
const TOKEN_INVALIDATION_PREFIX = `${TOKEN_FILE}.invalid.`;
|
|
103
|
+
|
|
104
|
+
/** Stable, non-secret identity for one access-token generation. */
|
|
105
|
+
export function accessTokenFingerprint(accessToken: string): string {
|
|
106
|
+
return crypto.createHash("sha256").update(accessToken).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function invalidationPathForFingerprint(fingerprint: string): string | null {
|
|
110
|
+
return /^[a-f0-9]{64}$/i.test(fingerprint)
|
|
111
|
+
? `${TOKEN_INVALIDATION_PREFIX}${fingerprint.toLowerCase()}`
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mark only the rejected token generation unusable. Automatic auth failures
|
|
117
|
+
* must not unlink the shared cache: another process may have completed a
|
|
118
|
+
* refresh or login after the failing request started.
|
|
119
|
+
*/
|
|
120
|
+
export function invalidateCachedTokensByFingerprint(
|
|
121
|
+
fingerprint: string | undefined,
|
|
122
|
+
): void {
|
|
123
|
+
if (!fingerprint) return;
|
|
124
|
+
const invalidationPath = invalidationPathForFingerprint(fingerprint);
|
|
125
|
+
if (!invalidationPath) return;
|
|
126
|
+
if (!fs.existsSync(HQ_DIR)) {
|
|
127
|
+
fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(invalidationPath, "", { mode: 0o600, flag: "wx" });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const code =
|
|
133
|
+
err && typeof err === "object" && "code" in err
|
|
134
|
+
? (err as { code?: string }).code
|
|
135
|
+
: undefined;
|
|
136
|
+
if (code !== "EEXIST") throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function tokenIsInvalidated(accessToken: string): boolean {
|
|
141
|
+
const marker = invalidationPathForFingerprint(
|
|
142
|
+
accessTokenFingerprint(accessToken),
|
|
143
|
+
);
|
|
144
|
+
return marker !== null && fs.existsSync(marker);
|
|
145
|
+
}
|
|
77
146
|
|
|
78
147
|
export function loadCachedTokens(): CognitoTokens | null {
|
|
79
148
|
if (!fs.existsSync(TOKEN_FILE)) return null;
|
|
80
149
|
try {
|
|
81
150
|
const raw = fs.readFileSync(TOKEN_FILE, "utf-8");
|
|
82
|
-
|
|
151
|
+
const tokens = JSON.parse(raw) as CognitoTokens;
|
|
152
|
+
if (!tokens.accessToken || tokenIsInvalidated(tokens.accessToken)) return null;
|
|
153
|
+
return tokens;
|
|
83
154
|
} catch {
|
|
84
155
|
return null;
|
|
85
156
|
}
|
|
@@ -89,6 +160,12 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
|
|
|
89
160
|
if (!fs.existsSync(HQ_DIR)) {
|
|
90
161
|
fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
|
|
91
162
|
}
|
|
163
|
+
const invalidationPath = invalidationPathForFingerprint(
|
|
164
|
+
accessTokenFingerprint(tokens.accessToken),
|
|
165
|
+
);
|
|
166
|
+
if (invalidationPath && fs.existsSync(invalidationPath)) {
|
|
167
|
+
fs.unlinkSync(invalidationPath);
|
|
168
|
+
}
|
|
92
169
|
const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
|
|
93
170
|
fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
94
171
|
fs.renameSync(tmpPath, TOKEN_FILE);
|
|
@@ -96,6 +173,128 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
|
|
|
96
173
|
|
|
97
174
|
export function clearCachedTokens(): void {
|
|
98
175
|
if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
|
|
176
|
+
if (!fs.existsSync(HQ_DIR)) return;
|
|
177
|
+
const markerPrefix = `${path.basename(TOKEN_FILE)}.invalid.`;
|
|
178
|
+
for (const entry of fs.readdirSync(HQ_DIR)) {
|
|
179
|
+
if (entry.startsWith(markerPrefix)) {
|
|
180
|
+
fs.unlinkSync(path.join(HQ_DIR, entry));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Advisory lockfile guarding the read→refresh→save cycle (see withRefreshLock). */
|
|
186
|
+
const REFRESH_LOCK_FILE = `${TOKEN_FILE}.lock`;
|
|
187
|
+
|
|
188
|
+
/** Thrown when the refresh lock can't be acquired within the deadline. */
|
|
189
|
+
export class RefreshLockTimeoutError extends Error {
|
|
190
|
+
constructor() {
|
|
191
|
+
super("timed out acquiring the token refresh lock");
|
|
192
|
+
this.name = "RefreshLockTimeoutError";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The pid recorded in the lockfile, or null if absent/empty/malformed. */
|
|
197
|
+
function readLockOwnerPid(): number | null {
|
|
198
|
+
try {
|
|
199
|
+
const pid = Number.parseInt(fs.readFileSync(REFRESH_LOCK_FILE, "utf-8").trim(), 10);
|
|
200
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Whether `pid` is a live process. All contenders for this lock share one host
|
|
208
|
+
* (the cache is `~/.hq`), so pid liveness is authoritative. `EPERM` means the
|
|
209
|
+
* process exists but is owned by another user — still alive; any other error
|
|
210
|
+
* (`ESRCH`, bad pid) means it's gone.
|
|
211
|
+
*/
|
|
212
|
+
function processAlive(pid: number): boolean {
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0);
|
|
215
|
+
return true;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Serialize the token refresh/mint cycle across every process sharing this
|
|
223
|
+
* cache (~/.hq/cognito-tokens.json). Boxes run several hq processes (the
|
|
224
|
+
* auth-refresh timer, the sync runner, replica-sync) that refresh or mint the
|
|
225
|
+
* same session concurrently; without coordination they race on the shared
|
|
226
|
+
* cache. And if the app client ever issues single-use tokens (refresh-token
|
|
227
|
+
* rotation), two unsynchronized refreshers each send the same token — the first
|
|
228
|
+
* consumes it, the second gets `invalid_grant`. An advisory lockfile (atomic
|
|
229
|
+
* hard-link publication) makes them take turns; the caller MUST re-read the cache
|
|
230
|
+
* inside `fn` so a waiter reuses the winner's fresh token, never a consumed one.
|
|
231
|
+
* This is also the lock the per-box machine-identity mint path reuses (ADR-0009).
|
|
232
|
+
*
|
|
233
|
+
* Correctness over availability at the edges:
|
|
234
|
+
* - A held lock is reclaimed ONLY when its recorded holder pid is dead (a real
|
|
235
|
+
* crash) or the lockfile is malformed — NEVER because a live holder is merely
|
|
236
|
+
* slow (a slow Cognito response must not let a second refresher race in).
|
|
237
|
+
* - Release unlinks ONLY our own lock (pid re-checked), so we can't delete a
|
|
238
|
+
* successor's lock.
|
|
239
|
+
* - If we can't acquire within `timeoutMs` (a live holder never released — a
|
|
240
|
+
* hung network), we THROW rather than refresh unlocked; the caller prefers a
|
|
241
|
+
* still-valid cached token and otherwise retries next cycle. Proceeding
|
|
242
|
+
* unlocked would reintroduce the exact rotation race this guards.
|
|
243
|
+
*/
|
|
244
|
+
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
245
|
+
const timeoutMs = 20_000;
|
|
246
|
+
const pollMs = 50;
|
|
247
|
+
if (!fs.existsSync(HQ_DIR)) {
|
|
248
|
+
fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
|
|
249
|
+
}
|
|
250
|
+
// Publish a fully-written inode with an atomic hard link. Creating the lock
|
|
251
|
+
// itself and then writing the pid leaves an observable empty-file window in
|
|
252
|
+
// which a contender can misclassify and unlink a live holder's lock.
|
|
253
|
+
const candidate = `${REFRESH_LOCK_FILE}.candidate.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
254
|
+
fs.writeFileSync(candidate, String(process.pid), {
|
|
255
|
+
flag: "wx",
|
|
256
|
+
mode: 0o600,
|
|
257
|
+
});
|
|
258
|
+
const deadline = Date.now() + timeoutMs;
|
|
259
|
+
let held = false;
|
|
260
|
+
try {
|
|
261
|
+
for (;;) {
|
|
262
|
+
try {
|
|
263
|
+
fs.linkSync(candidate, REFRESH_LOCK_FILE);
|
|
264
|
+
held = true;
|
|
265
|
+
break;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
268
|
+
const owner = readLockOwnerPid();
|
|
269
|
+
if (owner === null || (owner !== process.pid && !processAlive(owner))) {
|
|
270
|
+
try {
|
|
271
|
+
fs.unlinkSync(REFRESH_LOCK_FILE); // crashed/malformed holder — reclaim
|
|
272
|
+
} catch {
|
|
273
|
+
/* raced with another reclaimer */
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (Date.now() >= deadline) throw new RefreshLockTimeoutError();
|
|
278
|
+
await sleep(pollMs);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return await fn();
|
|
282
|
+
} finally {
|
|
283
|
+
// Release ONLY our own lock — never clobber a successor's.
|
|
284
|
+
if (held && readLockOwnerPid() === process.pid) {
|
|
285
|
+
try {
|
|
286
|
+
fs.unlinkSync(REFRESH_LOCK_FILE);
|
|
287
|
+
} catch {
|
|
288
|
+
/* already released */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Remove the private proposal inode name on success or acquisition failure.
|
|
292
|
+
try {
|
|
293
|
+
fs.unlinkSync(candidate);
|
|
294
|
+
} catch {
|
|
295
|
+
/* already removed */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
99
298
|
}
|
|
100
299
|
|
|
101
300
|
/**
|
|
@@ -155,7 +354,7 @@ function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
|
|
155
354
|
}
|
|
156
355
|
}
|
|
157
356
|
|
|
158
|
-
function
|
|
357
|
+
function machineTokenPairMatchesIdentity(
|
|
159
358
|
tokens: CognitoTokens,
|
|
160
359
|
creds: MachineCreds,
|
|
161
360
|
expectedClientId: string,
|
|
@@ -181,8 +380,25 @@ function cachedTokensMatchMachineIdentity(
|
|
|
181
380
|
const idUsername =
|
|
182
381
|
stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
|
|
183
382
|
const idSub = stringClaim(idClaims, "sub");
|
|
184
|
-
const entityType = idClaims
|
|
185
|
-
const entityUid = idClaims
|
|
383
|
+
const entityType = stringClaim(idClaims, "custom:entityType");
|
|
384
|
+
const entityUid = stringClaim(idClaims, "custom:entityUid");
|
|
385
|
+
const delegatedSub = stringClaim(idClaims, "custom:delegatedSub");
|
|
386
|
+
const delegatedEmail = stringClaim(idClaims, "custom:delegatedEmail");
|
|
387
|
+
const hasValidMachineClaimShape =
|
|
388
|
+
(entityType === "agent" &&
|
|
389
|
+
entityUid !== null &&
|
|
390
|
+
entityUid.startsWith("agt_")) ||
|
|
391
|
+
(entityType === "outpost" &&
|
|
392
|
+
entityUid !== null &&
|
|
393
|
+
entityUid.startsWith("out_") &&
|
|
394
|
+
delegatedSub !== null &&
|
|
395
|
+
delegatedEmail !== null);
|
|
396
|
+
// Legacy agent credentials did not persist entityType. If a newer creds
|
|
397
|
+
// file adds only its agt_* uid, infer the only valid type for that uid.
|
|
398
|
+
const expectedEntityType = creds.entityType ?? "agent";
|
|
399
|
+
const entityMatchesCreds =
|
|
400
|
+
entityType === expectedEntityType &&
|
|
401
|
+
(creds.entityUid === undefined || entityUid === creds.entityUid);
|
|
186
402
|
const idTokenMatchesCreds =
|
|
187
403
|
idUsername === creds.username || idSub === creds.username;
|
|
188
404
|
const subjectBindings: boolean[] = [];
|
|
@@ -200,33 +416,32 @@ function cachedTokensMatchMachineIdentity(
|
|
|
200
416
|
accessClientId === expectedClientId &&
|
|
201
417
|
idClaims.token_use === "id" &&
|
|
202
418
|
idAudience === expectedClientId &&
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
entityUid.startsWith("agt_") &&
|
|
419
|
+
hasValidMachineClaimShape &&
|
|
420
|
+
entityMatchesCreds &&
|
|
206
421
|
idTokenMatchesCreds &&
|
|
207
422
|
tokensShareSubject
|
|
208
423
|
);
|
|
209
424
|
}
|
|
210
425
|
|
|
211
426
|
// ---------------------------------------------------------------------------
|
|
212
|
-
// Machine identity (company agents)
|
|
427
|
+
// Machine identity (company agents and Outpost boxes)
|
|
213
428
|
// ---------------------------------------------------------------------------
|
|
214
429
|
//
|
|
215
|
-
// HQ
|
|
430
|
+
// HQ agents and Outposts run headless on their own boxes with long-lived Cognito
|
|
216
431
|
// MACHINE credentials ({username: "machine-agt_<ulid>", secret}) provisioned
|
|
217
432
|
// by hq-pro's agent bootstrap and stored at ~/.hq-agent/machine-creds.json.
|
|
218
433
|
// There is no browser, no Hosted UI, and no refresh-token dance: the creds
|
|
219
434
|
// never expire, so the CLI simply re-mints a session via USER_PASSWORD_AUTH
|
|
220
435
|
// whenever the cached tokens are missing or expiring.
|
|
221
436
|
//
|
|
222
|
-
// Token semantics matter here.
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// token type each API
|
|
437
|
+
// Token semantics matter here. Machine identity claims ride the ID token only:
|
|
438
|
+
// agents carry custom:entityType=agent/custom:entityUid=agt_*, while Outposts
|
|
439
|
+
// carry outpost/out_* plus delegatedSub and delegatedEmail. APIs that verify
|
|
440
|
+
// token_use=access (e.g. hq-deploy) need the real access token. Both are cached
|
|
441
|
+
// with correct field semantics — callers pick the token type each API validates.
|
|
227
442
|
|
|
228
443
|
export interface MachineCreds {
|
|
229
|
-
/** Cognito username
|
|
444
|
+
/** Cognito username for the machine user. */
|
|
230
445
|
username: string;
|
|
231
446
|
/** Long-lived machine secret (USER_PASSWORD_AUTH password). */
|
|
232
447
|
secret: string;
|
|
@@ -235,6 +450,10 @@ export interface MachineCreds {
|
|
|
235
450
|
clientId?: string;
|
|
236
451
|
/** Cognito region for the mint endpoint; falls back to config. */
|
|
237
452
|
region?: string;
|
|
453
|
+
/** Expected machine kind carried in the ID token's custom:entityType claim. */
|
|
454
|
+
entityType?: "agent" | "outpost";
|
|
455
|
+
/** Exact expected machine uid carried in custom:entityUid. */
|
|
456
|
+
entityUid?: string;
|
|
238
457
|
}
|
|
239
458
|
|
|
240
459
|
/** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */
|
|
@@ -258,22 +477,45 @@ export function loadMachineCreds(): MachineCreds | null {
|
|
|
258
477
|
secret?: unknown;
|
|
259
478
|
clientId?: unknown;
|
|
260
479
|
region?: unknown;
|
|
480
|
+
entityType?: unknown;
|
|
481
|
+
entityUid?: unknown;
|
|
261
482
|
};
|
|
262
483
|
// The creds FILE is the machine-identity signal — no username-shape
|
|
263
484
|
// gate. (6.7.0 required a "machine-" prefix, but live boxes are
|
|
264
485
|
// provisioned with "agt-<ulid>@agents.getindigo.ai", so detection
|
|
265
486
|
// never engaged on the first real agent box, 2026-06-12.)
|
|
487
|
+
const validEntityIdentity =
|
|
488
|
+
(raw.entityType === undefined && raw.entityUid === undefined) ||
|
|
489
|
+
(raw.entityType === undefined &&
|
|
490
|
+
typeof raw.entityUid === "string" &&
|
|
491
|
+
raw.entityUid.startsWith("agt_") &&
|
|
492
|
+
raw.entityUid.length > "agt_".length) ||
|
|
493
|
+
(raw.entityType === "agent" &&
|
|
494
|
+
typeof raw.entityUid === "string" &&
|
|
495
|
+
raw.entityUid.startsWith("agt_") &&
|
|
496
|
+
raw.entityUid.length > "agt_".length) ||
|
|
497
|
+
(raw.entityType === "outpost" &&
|
|
498
|
+
typeof raw.entityUid === "string" &&
|
|
499
|
+
raw.entityUid.startsWith("out_") &&
|
|
500
|
+
raw.entityUid.length > "out_".length);
|
|
266
501
|
if (
|
|
267
502
|
typeof raw.username === "string" &&
|
|
268
503
|
raw.username.length > 0 &&
|
|
269
504
|
typeof raw.secret === "string" &&
|
|
270
|
-
raw.secret.length > 0
|
|
505
|
+
raw.secret.length > 0 &&
|
|
506
|
+
validEntityIdentity
|
|
271
507
|
) {
|
|
272
508
|
return {
|
|
273
509
|
username: raw.username,
|
|
274
510
|
secret: raw.secret,
|
|
275
511
|
clientId: typeof raw.clientId === "string" ? raw.clientId : undefined,
|
|
276
512
|
region: typeof raw.region === "string" ? raw.region : undefined,
|
|
513
|
+
...(raw.entityType === "agent" || raw.entityType === "outpost"
|
|
514
|
+
? { entityType: raw.entityType }
|
|
515
|
+
: {}),
|
|
516
|
+
...(typeof raw.entityUid === "string"
|
|
517
|
+
? { entityUid: raw.entityUid }
|
|
518
|
+
: {}),
|
|
277
519
|
};
|
|
278
520
|
}
|
|
279
521
|
return null;
|
|
@@ -352,7 +594,7 @@ function backoffDelayMs(
|
|
|
352
594
|
* full jitter (transient, per-user rate limit); every other non-OK response
|
|
353
595
|
* (bad creds, disabled flow) is a hard error surfaced immediately.
|
|
354
596
|
*/
|
|
355
|
-
|
|
597
|
+
async function mintMachineTokensUnlocked(
|
|
356
598
|
config: CognitoAuthConfig,
|
|
357
599
|
creds?: MachineCreds,
|
|
358
600
|
retry?: MintRetryOptions,
|
|
@@ -417,11 +659,16 @@ export async function mintMachineTokens(
|
|
|
417
659
|
accessToken: result.AccessToken,
|
|
418
660
|
idToken: result.IdToken,
|
|
419
661
|
// Machine creds never expire — expiry is handled by re-minting, so the
|
|
420
|
-
// refresh token (when Cognito returns one at all) is never
|
|
421
|
-
refreshToken:
|
|
662
|
+
// refresh token (when Cognito returns one at all) is never retained.
|
|
663
|
+
refreshToken: "",
|
|
422
664
|
expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
|
|
423
665
|
tokenType: "Bearer",
|
|
424
666
|
};
|
|
667
|
+
if (!machineTokenPairMatchesIdentity(tokens, machineCreds, clientId)) {
|
|
668
|
+
throw new CognitoAuthError(
|
|
669
|
+
"Machine token mint returned tokens that do not match the requested machine identity",
|
|
670
|
+
);
|
|
671
|
+
}
|
|
425
672
|
saveCachedTokens(tokens);
|
|
426
673
|
return tokens;
|
|
427
674
|
}
|
|
@@ -430,6 +677,19 @@ export async function mintMachineTokens(
|
|
|
430
677
|
throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
|
|
431
678
|
}
|
|
432
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Mint and persist a fresh machine session while holding the process-shared
|
|
682
|
+
* token lock. The unlocked implementation is private so public callers cannot
|
|
683
|
+
* race another process's cache write.
|
|
684
|
+
*/
|
|
685
|
+
export async function mintMachineTokens(
|
|
686
|
+
config: CognitoAuthConfig,
|
|
687
|
+
creds?: MachineCreds,
|
|
688
|
+
retry?: MintRetryOptions,
|
|
689
|
+
): Promise<CognitoTokens> {
|
|
690
|
+
return withRefreshLock(() => mintMachineTokensUnlocked(config, creds, retry));
|
|
691
|
+
}
|
|
692
|
+
|
|
433
693
|
// In-process machine-token memo (sync-runner mint-storm fix).
|
|
434
694
|
//
|
|
435
695
|
// `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
|
|
@@ -445,10 +705,37 @@ export async function mintMachineTokens(
|
|
|
445
705
|
// read and no network — a 300-file sync does ONE mint, not one-per-request;
|
|
446
706
|
// - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
|
|
447
707
|
// collapses onto a single in-flight mint instead of one mint per caller.
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
708
|
+
// The memo carries an exact credential/client key, and in-flight work is keyed,
|
|
709
|
+
// so reprovisioning a long-running process cannot reuse the prior identity.
|
|
710
|
+
interface MachineTokenMemo {
|
|
711
|
+
identityKey: string;
|
|
712
|
+
tokens: CognitoTokens;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let inProcessMachineTokens: MachineTokenMemo | null = null;
|
|
716
|
+
const inFlightMachineMints = new Map<string, Promise<CognitoTokens>>();
|
|
717
|
+
|
|
718
|
+
function machineIdentityKey(
|
|
719
|
+
config: CognitoAuthConfig,
|
|
720
|
+
creds: MachineCreds,
|
|
721
|
+
): string {
|
|
722
|
+
// Include every credential value that can change the effective mint. Hashing
|
|
723
|
+
// keeps the long-lived secret out of diagnostic heap strings while retaining
|
|
724
|
+
// exact-key behavior when a creds file is reprovisioned in place.
|
|
725
|
+
return crypto
|
|
726
|
+
.createHash("sha256")
|
|
727
|
+
.update(
|
|
728
|
+
JSON.stringify({
|
|
729
|
+
username: creds.username,
|
|
730
|
+
secret: creds.secret,
|
|
731
|
+
clientId: creds.clientId ?? config.clientId,
|
|
732
|
+
region: creds.region ?? config.region,
|
|
733
|
+
entityType: creds.entityType ?? "agent",
|
|
734
|
+
entityUid: creds.entityUid ?? null,
|
|
735
|
+
}),
|
|
736
|
+
)
|
|
737
|
+
.digest("hex");
|
|
738
|
+
}
|
|
452
739
|
|
|
453
740
|
/**
|
|
454
741
|
* Return a valid (non-expiring) machine session, re-minting on demand.
|
|
@@ -458,40 +745,99 @@ let inFlightMachineMint: Promise<CognitoTokens> | null = null;
|
|
|
458
745
|
export async function getValidMachineTokens(
|
|
459
746
|
config: CognitoAuthConfig,
|
|
460
747
|
): Promise<CognitoTokens> {
|
|
748
|
+
const machineCreds = loadMachineCreds();
|
|
749
|
+
if (!machineCreds) {
|
|
750
|
+
throw new CognitoAuthError(
|
|
751
|
+
`No machine credentials found at ${machineCredsFilePath()}`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const expectedClientId = machineCreds.clientId ?? config.clientId;
|
|
755
|
+
const identityKey = machineIdentityKey(config, machineCreds);
|
|
461
756
|
// Hot path: a live in-memory token serves every request after the first.
|
|
462
|
-
if (
|
|
463
|
-
|
|
757
|
+
if (
|
|
758
|
+
inProcessMachineTokens?.identityKey === identityKey &&
|
|
759
|
+
!isExpiring(inProcessMachineTokens.tokens, 120) &&
|
|
760
|
+
machineTokenPairMatchesIdentity(
|
|
761
|
+
inProcessMachineTokens.tokens,
|
|
762
|
+
machineCreds,
|
|
763
|
+
expectedClientId,
|
|
764
|
+
)
|
|
765
|
+
) {
|
|
766
|
+
return inProcessMachineTokens.tokens;
|
|
464
767
|
}
|
|
465
768
|
// Single-flight: collapse a concurrent burst of misses onto one resolution.
|
|
466
769
|
// The assignment below completes before the first `await` yields, so sibling
|
|
467
770
|
// callers in the same tick see the in-flight promise and reuse it.
|
|
468
|
-
|
|
771
|
+
const existingResolution = inFlightMachineMints.get(identityKey);
|
|
772
|
+
if (existingResolution) return existingResolution;
|
|
773
|
+
|
|
774
|
+
const resolution = (async () => {
|
|
775
|
+
const resolveUnderLock = async (): Promise<CognitoTokens> => {
|
|
776
|
+
// Re-read only after acquiring the process-shared lock. A different hq
|
|
777
|
+
// process may have minted and saved this machine's session while this
|
|
778
|
+
// caller was waiting.
|
|
779
|
+
const cached = loadCachedTokens();
|
|
780
|
+
if (cached && !isExpiring(cached, 120)) {
|
|
781
|
+
// Compare against the client we'd actually mint with (creds-file
|
|
782
|
+
// clientId wins over config), and require the cached ID token to prove
|
|
783
|
+
// the exact machine identity. Opaque/missing/human-shaped claims are
|
|
784
|
+
// treated as stale.
|
|
785
|
+
if (
|
|
786
|
+
machineTokenPairMatchesIdentity(
|
|
787
|
+
cached,
|
|
788
|
+
machineCreds,
|
|
789
|
+
expectedClientId,
|
|
790
|
+
)
|
|
791
|
+
) {
|
|
792
|
+
if (cached.refreshToken === "") return cached;
|
|
793
|
+
const normalized = { ...cached, refreshToken: "" };
|
|
794
|
+
saveCachedTokens(normalized);
|
|
795
|
+
return normalized;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// mintMachineTokens persists before returning, so the complete shared
|
|
799
|
+
// cache read -> USER_PASSWORD_AUTH mint -> save sequence is serialized.
|
|
800
|
+
return mintMachineTokensUnlocked(config, machineCreds);
|
|
801
|
+
};
|
|
469
802
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
803
|
+
try {
|
|
804
|
+
return await withRefreshLock(resolveUnderLock);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
if (!(err instanceof RefreshLockTimeoutError)) throw err;
|
|
807
|
+
// Never mint unlocked. If the holder managed to persist a matching fresh
|
|
808
|
+
// session, reuse it; otherwise surface a retryable auth failure.
|
|
809
|
+
const cached = loadCachedTokens();
|
|
810
|
+
if (cached && !isExpiring(cached, 120)) {
|
|
811
|
+
if (
|
|
812
|
+
machineTokenPairMatchesIdentity(
|
|
813
|
+
cached,
|
|
814
|
+
machineCreds,
|
|
815
|
+
expectedClientId,
|
|
816
|
+
)
|
|
817
|
+
) {
|
|
818
|
+
// The successful path always leaves a machine cache with no refresh
|
|
819
|
+
// token. If this is legacy state, only normalize it while holding the
|
|
820
|
+
// lock; after a timeout we must not race the live holder's write.
|
|
821
|
+
if (cached.refreshToken === "") return cached;
|
|
822
|
+
}
|
|
482
823
|
}
|
|
824
|
+
throw new CognitoAuthError(
|
|
825
|
+
"Machine token mint is held by another process and no fresh matching session is available; retry shortly.",
|
|
826
|
+
);
|
|
483
827
|
}
|
|
484
|
-
return mintMachineTokens(config, machineCreds ?? undefined);
|
|
485
828
|
})();
|
|
829
|
+
inFlightMachineMints.set(identityKey, resolution);
|
|
486
830
|
|
|
487
831
|
try {
|
|
488
|
-
const tokens = await
|
|
489
|
-
inProcessMachineTokens = tokens;
|
|
832
|
+
const tokens = await resolution;
|
|
833
|
+
inProcessMachineTokens = { identityKey, tokens };
|
|
490
834
|
return tokens;
|
|
491
835
|
} finally {
|
|
492
836
|
// Clear on both success and failure: a rejected mint must not wedge later
|
|
493
837
|
// calls onto a permanently-failed promise.
|
|
494
|
-
|
|
838
|
+
if (inFlightMachineMints.get(identityKey) === resolution) {
|
|
839
|
+
inFlightMachineMints.delete(identityKey);
|
|
840
|
+
}
|
|
495
841
|
}
|
|
496
842
|
}
|
|
497
843
|
|
|
@@ -700,42 +1046,146 @@ async function exchangeCodeForTokens(
|
|
|
700
1046
|
};
|
|
701
1047
|
}
|
|
702
1048
|
|
|
1049
|
+
/** Whether the cached session was minted by `config`'s app client. */
|
|
1050
|
+
function cachedIsForClient(
|
|
1051
|
+
cached: CognitoTokens,
|
|
1052
|
+
config: CognitoAuthConfig,
|
|
1053
|
+
): boolean {
|
|
1054
|
+
const clientId = decodeAccessTokenClientId(cached.accessToken);
|
|
1055
|
+
return clientId !== null && clientId === config.clientId;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
703
1058
|
/**
|
|
704
1059
|
* Use the refresh token to obtain a fresh access token without user interaction.
|
|
705
|
-
*
|
|
706
|
-
*
|
|
1060
|
+
*
|
|
1061
|
+
* Concurrency-safe. Several hq processes on a box refresh the same cached
|
|
1062
|
+
* session at once, so the whole read→refresh→save cycle runs under a
|
|
1063
|
+
* cross-process lock, and inside the lock we (a) reuse a token another refresher
|
|
1064
|
+
* just produced if it's still fresh, and (b) otherwise refresh with the FRESHEST
|
|
1065
|
+
* token on disk rather than the caller's snapshot. This keeps the shared cache
|
|
1066
|
+
* consistent, and stays correct if the app client uses single-use (rotated)
|
|
1067
|
+
* tokens — a stale snapshot would otherwise be an already-consumed token
|
|
1068
|
+
* (`invalid_grant`). NOTE: rotation is NOT how boxes stay signed in — a rotated
|
|
1069
|
+
* token inherits the original's remaining lifetime, so it does not extend the
|
|
1070
|
+
* session (AWS). Durable never-log-out is per-box machine identity (ADR-0009).
|
|
707
1071
|
*/
|
|
708
1072
|
export async function refreshTokens(
|
|
709
1073
|
config: CognitoAuthConfig,
|
|
710
1074
|
currentRefreshToken: string,
|
|
1075
|
+
currentAccessToken?: string,
|
|
711
1076
|
): Promise<CognitoTokens> {
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1077
|
+
const doRefresh = async (): Promise<CognitoTokens> => {
|
|
1078
|
+
const cached = loadCachedTokens();
|
|
1079
|
+
// Reuse a token another refresher just produced — but ONLY when it belongs
|
|
1080
|
+
// to THIS app client (a direct caller may target a different pool than the
|
|
1081
|
+
// shared cache holds) and is still fresh (a fresh access token has ~1h, so
|
|
1082
|
+
// >300s of life means "someone already refreshed", not the caller's own
|
|
1083
|
+
// expiring token).
|
|
1084
|
+
if (cached && cachedIsForClient(cached, config) && !isExpiring(cached, 300)) {
|
|
1085
|
+
return cached;
|
|
1086
|
+
}
|
|
717
1087
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1088
|
+
// Prefer the freshest refresh token on disk over the caller's snapshot when
|
|
1089
|
+
// it's for this client — a concurrent rotation may have consumed the
|
|
1090
|
+
// snapshot. Otherwise (different client / no cache) use the supplied token.
|
|
1091
|
+
const refreshToken =
|
|
1092
|
+
cached && cachedIsForClient(cached, config)
|
|
1093
|
+
? cached.refreshToken
|
|
1094
|
+
: currentRefreshToken;
|
|
1095
|
+
const sessionFingerprint = cached && cachedIsForClient(cached, config)
|
|
1096
|
+
? accessTokenFingerprint(cached.accessToken)
|
|
1097
|
+
: currentAccessToken
|
|
1098
|
+
? accessTokenFingerprint(currentAccessToken)
|
|
1099
|
+
: undefined;
|
|
1100
|
+
|
|
1101
|
+
const body = new URLSearchParams({
|
|
1102
|
+
grant_type: "refresh_token",
|
|
1103
|
+
client_id: config.clientId,
|
|
1104
|
+
refresh_token: refreshToken,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
let lastFailure: CognitoRefreshError | undefined;
|
|
1108
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1109
|
+
let res: Response;
|
|
1110
|
+
try {
|
|
1111
|
+
res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1114
|
+
body: body.toString(),
|
|
1115
|
+
});
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
lastFailure = new CognitoRefreshError(
|
|
1118
|
+
`Cognito refresh request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1119
|
+
false,
|
|
1120
|
+
undefined,
|
|
1121
|
+
sessionFingerprint,
|
|
1122
|
+
);
|
|
1123
|
+
if (attempt === 0) continue;
|
|
1124
|
+
throw lastFailure;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (!res.ok) {
|
|
1128
|
+
const text = await res.text();
|
|
1129
|
+
const retryable =
|
|
1130
|
+
res.status === 401 ||
|
|
1131
|
+
res.status === 408 ||
|
|
1132
|
+
res.status === 429 ||
|
|
1133
|
+
res.status >= 500;
|
|
1134
|
+
const requiresReauth =
|
|
1135
|
+
res.status === 401 ||
|
|
1136
|
+
(res.status >= 400 &&
|
|
1137
|
+
res.status < 500 &&
|
|
1138
|
+
![408, 429].includes(res.status));
|
|
1139
|
+
lastFailure = new CognitoRefreshError(
|
|
1140
|
+
requiresReauth
|
|
1141
|
+
? "Your HQ session needs a quick refresh. Sign in again to keep sync moving."
|
|
1142
|
+
: `Cognito refresh failed (${res.status}): ${text}`,
|
|
1143
|
+
requiresReauth,
|
|
1144
|
+
res.status,
|
|
1145
|
+
sessionFingerprint,
|
|
1146
|
+
);
|
|
1147
|
+
if (retryable && attempt === 0) continue;
|
|
1148
|
+
throw lastFailure;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const data = (await res.json()) as CognitoTokenResponse;
|
|
1152
|
+
const tokens: CognitoTokens = {
|
|
1153
|
+
accessToken: data.access_token,
|
|
1154
|
+
idToken: data.id_token,
|
|
1155
|
+
// Persist a rotated refresh token if the endpoint returns one (single-use
|
|
1156
|
+
// clients); otherwise keep the freshest token we sent. This is for cache
|
|
1157
|
+
// correctness, not longevity; rotation does not extend session lifetime.
|
|
1158
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
1159
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
1160
|
+
tokenType: "Bearer",
|
|
1161
|
+
};
|
|
1162
|
+
saveCachedTokens(tokens);
|
|
1163
|
+
return tokens;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
throw (
|
|
1167
|
+
lastFailure ??
|
|
1168
|
+
new CognitoRefreshError("Cognito refresh failed after retry", false)
|
|
727
1169
|
);
|
|
728
|
-
}
|
|
729
|
-
const data = (await res.json()) as CognitoTokenResponse;
|
|
730
|
-
const tokens: CognitoTokens = {
|
|
731
|
-
accessToken: data.access_token,
|
|
732
|
-
idToken: data.id_token,
|
|
733
|
-
refreshToken: data.refresh_token ?? currentRefreshToken,
|
|
734
|
-
expiresAt: Date.now() + data.expires_in * 1000,
|
|
735
|
-
tokenType: "Bearer",
|
|
736
1170
|
};
|
|
737
|
-
|
|
738
|
-
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
return await withRefreshLock(doRefresh);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
if (!(err instanceof RefreshLockTimeoutError)) throw err;
|
|
1176
|
+
// A live holder never released within the window (pathological — e.g. a
|
|
1177
|
+
// hung network). Prefer whatever it may have persisted for this client; do
|
|
1178
|
+
// NOT refresh unlocked (that reintroduces the rotation race). If nothing
|
|
1179
|
+
// usable is cached, surface the failure so the caller retries next cycle.
|
|
1180
|
+
const cached = loadCachedTokens();
|
|
1181
|
+
if (cached && cachedIsForClient(cached, config) && !isExpiring(cached)) {
|
|
1182
|
+
return cached;
|
|
1183
|
+
}
|
|
1184
|
+
throw new CognitoRefreshError(
|
|
1185
|
+
"Token refresh is held by another process and no fresh cached session is available; retry shortly.",
|
|
1186
|
+
false,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
739
1189
|
}
|
|
740
1190
|
|
|
741
1191
|
/**
|
|
@@ -778,7 +1228,9 @@ export async function getValidAccessToken(
|
|
|
778
1228
|
if (cached) {
|
|
779
1229
|
const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
|
|
780
1230
|
if (cachedClientId !== null && cachedClientId !== config.clientId) {
|
|
781
|
-
|
|
1231
|
+
invalidateCachedTokensByFingerprint(
|
|
1232
|
+
accessTokenFingerprint(cached.accessToken),
|
|
1233
|
+
);
|
|
782
1234
|
cached = null;
|
|
783
1235
|
}
|
|
784
1236
|
}
|
|
@@ -787,9 +1239,19 @@ export async function getValidAccessToken(
|
|
|
787
1239
|
|
|
788
1240
|
if (cached) {
|
|
789
1241
|
try {
|
|
790
|
-
const refreshed = await refreshTokens(
|
|
1242
|
+
const refreshed = await refreshTokens(
|
|
1243
|
+
config,
|
|
1244
|
+
cached.refreshToken,
|
|
1245
|
+
cached.accessToken,
|
|
1246
|
+
);
|
|
791
1247
|
return refreshed.accessToken;
|
|
792
|
-
} catch {
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
if (err instanceof CognitoRefreshError && err.requiresReauth) {
|
|
1250
|
+
invalidateCachedTokensByFingerprint(
|
|
1251
|
+
err.sessionFingerprint ?? accessTokenFingerprint(cached.accessToken),
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
if (!interactive) throw err;
|
|
793
1255
|
// fall through to interactive login
|
|
794
1256
|
}
|
|
795
1257
|
}
|