@nodii/dev 0.6.1 → 0.6.3

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/oauth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
- import { open, rm, stat } from "node:fs/promises";
3
- import { platform } from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, rm, stat, utimes } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import { CliError, configPath, readConfig, writeConfig } from "./core.js";
6
7
 
@@ -29,6 +30,7 @@ export function requestedScope(scope) {
29
30
 
30
31
  const service = "dev.nodii.cli.oauth";
31
32
  const account = (clientId) => `refresh:${clientId}`;
33
+ const accessAccount = (clientId) => `access:${clientId}`;
32
34
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
35
 
34
36
  function run(command, args, { input } = {}) {
@@ -43,42 +45,83 @@ function run(command, args, { input } = {}) {
43
45
  });
44
46
  }
45
47
 
46
- export async function storeRefreshToken(clientId, token, os = platform()) {
48
+ async function keychainStore(name, secret, os) {
47
49
  if (os === "darwin") {
48
- await run("security", ["add-generic-password", "-U", "-a", account(clientId), "-s", service, "-w", token]);
50
+ if (!/^[A-Za-z0-9_.:-]+$/.test(name)) throw new CliError("KEYCHAIN_UNAVAILABLE", "The OAuth client id has characters the Keychain helper cannot take; nothing was stored.", 2);
51
+ // Through `security -i` on stdin, so the secret never appears in argv
52
+ // (visible to `ps`). The interactive parser splits on whitespace and reads
53
+ // quotes, so a secret outside a token's own alphabet falls back to argv
54
+ // rather than being dropped (it may be a refresh token just rotated).
55
+ if (/^[A-Za-z0-9._~+/=-]+$/.test(secret)) await run("security", ["-i"], { input: `add-generic-password -U -a ${name} -s ${service} -w ${secret}\n` });
56
+ else await run("security", ["add-generic-password", "-U", "-a", name, "-s", service, "-w", secret]);
49
57
  return "macOS Keychain";
50
58
  }
51
59
  if (os === "linux") {
52
60
  try {
53
- await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", account(clientId)], { input: token });
61
+ await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", name], { input: secret });
54
62
  return "Secret Service";
55
63
  } catch {}
56
64
  }
57
65
  throw new CliError("KEYCHAIN_UNAVAILABLE", "OAuth refresh tokens require macOS Keychain or Linux Secret Service. No credential was stored; re-run login when a keychain is available.", 2);
58
66
  }
59
67
 
68
+ async function keychainLoad(name, os) {
69
+ if (os === "darwin") return (await run("security", ["find-generic-password", "-a", name, "-s", service, "-w"])).trim();
70
+ if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", name])).trim();
71
+ throw new Error("no keychain");
72
+ }
73
+
74
+ async function keychainDelete(name, os) {
75
+ try {
76
+ if (os === "darwin") await run("security", ["delete-generic-password", "-a", name, "-s", service]);
77
+ else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", name]);
78
+ } catch {}
79
+ }
80
+
81
+ export async function storeRefreshToken(clientId, token, os = platform()) {
82
+ return keychainStore(account(clientId), token, os);
83
+ }
84
+
60
85
  export async function loadRefreshToken(clientId, os = platform()) {
61
86
  try {
62
- if (os === "darwin") return (await run("security", ["find-generic-password", "-a", account(clientId), "-s", service, "-w"])).trim();
63
- if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", account(clientId)])).trim();
87
+ const token = await keychainLoad(account(clientId), os);
88
+ if (token) return token;
64
89
  } catch {}
65
90
  throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
66
91
  }
67
92
 
68
93
  export async function deleteRefreshToken(clientId, os = platform()) {
94
+ await keychainDelete(account(clientId), os);
95
+ }
96
+
97
+ // The access token is cached beside the refresh token so that every nodii-dev
98
+ // process on the machine shares one: without it each command rotated the
99
+ // refresh token, and N lanes rotated it N times as often.
100
+ export async function loadAccessToken(clientId, os = platform()) {
69
101
  try {
70
- if (os === "darwin") await run("security", ["delete-generic-password", "-a", account(clientId), "-s", service]);
71
- else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", account(clientId)]);
72
- } catch {}
102
+ const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os), "base64url").toString("utf8"));
103
+ return typeof cached?.token === "string" && Number.isFinite(cached.expiresAt) ? cached : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function storeAccessToken(clientId, cached, os = platform()) {
110
+ try { await keychainStore(accessAccount(clientId), Buffer.from(JSON.stringify(cached)).toString("base64url"), os); } catch {}
73
111
  }
74
112
 
75
- async function oauthPost(authUrl, path, fields, fetchImpl = fetch) {
113
+ export async function deleteAccessToken(clientId, os = platform()) {
114
+ await keychainDelete(accessAccount(clientId), os);
115
+ }
116
+
117
+ async function oauthPost(authUrl, path, fields, fetchImpl = fetch, timeoutMs = REFRESH_TIMEOUT_MS) {
76
118
  let response;
77
119
  try {
78
120
  response = await fetchImpl(`${authUrl.replace(/\/$/, "")}${path}`, {
79
121
  method: "POST",
80
122
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
81
123
  body: new URLSearchParams(fields),
124
+ signal: AbortSignal.timeout(timeoutMs),
82
125
  });
83
126
  } catch (error) {
84
127
  throw new CliError("AUTH_UNREACHABLE", `Cannot reach nodii-auth: ${error.message}`, 1);
@@ -112,7 +155,20 @@ function openBrowser(url) {
112
155
  child.unref();
113
156
  }
114
157
 
115
- export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUrl = DEFAULT_EDGE_URL, tenantHint = PLATFORM_TENANT, noBrowser = false, scope, fetchImpl = fetch, output = console.log }) {
158
+ /**
159
+ * The agent registration a new login keeps. Dev binds an agent to the person,
160
+ * the app and the installation, so signing in again as the same person through
161
+ * the same installation keeps it; anything else starts unregistered.
162
+ */
163
+ export function keptAgent(previous, { clientId, person, installationId, edgeUrl }) {
164
+ if (!previous?.agentId || !person) return null;
165
+ if (previous.clientId !== clientId || previous.person !== person) return null;
166
+ if ((previous.installationId ?? null) !== (installationId ?? null)) return null;
167
+ if (edgeUrl && previous.edgeUrl && previous.edgeUrl.replace(/\/$/, "") !== edgeUrl.replace(/\/$/, "")) return null;
168
+ return previous.agentId;
169
+ }
170
+
171
+ export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUrl = DEFAULT_EDGE_URL, tenantHint = PLATFORM_TENANT, noBrowser = false, scope, fetchImpl = fetch, output = console.log, previous = null, lock = withRefreshLock }) {
116
172
  const requested = requestedScope(scope);
117
173
  const start = await oauthPost(authUrl, "/oauth/device/authorize", {
118
174
  client_id: clientId, tenant_hint: tenantHint, ...(requested ? { scope: requested } : {}),
@@ -123,61 +179,284 @@ export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUr
123
179
  if (!noBrowser && process.stdout.isTTY) openBrowser(d.verification_uri_complete ?? d.verification_uri);
124
180
  const token = await pollDeviceToken({ authUrl, clientId, deviceCode: d.device_code, interval: d.interval, expiresIn: d.expires_in, fetchImpl });
125
181
  if (!token.refresh_token) throw new CliError("OAUTH_REFRESH_MISSING", "nodii-auth did not return a refresh token.", 1);
126
- const storage = await storeRefreshToken(clientId, token.refresh_token);
182
+ const storage = await lock(async () => {
183
+ const stored = await storeRefreshToken(clientId, token.refresh_token);
184
+ await deleteAccessToken(clientId);
185
+ return stored;
186
+ }, { clientId });
127
187
  const claims = decodeClaims(token.access_token);
128
- await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId: null, person: claims.sub ?? null, installationId: claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null });
129
- return { storage, claims };
188
+ const person = claims.sub ?? null;
189
+ const installationId = claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null;
190
+ const agentId = keptAgent(previous, { clientId, person, installationId, edgeUrl });
191
+ await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId, person, installationId });
192
+ return { storage, claims, agentId };
130
193
  }
131
194
 
132
195
  export function decodeClaims(token) {
133
196
  try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); } catch { return {}; }
134
197
  }
135
198
 
136
- export async function withRefreshLock(fn, { sleep = wait } = {}) {
137
- const path = join(dirname(configPath()), "oauth-refresh.lock");
138
- for (let attempt = 0; attempt < 300; attempt++) {
199
+ // ONE lock per OAuth client per user, beside the Keychain item it guards. It
200
+ // used to live next to the config file, so two processes with different
201
+ // NODII_DEV_CONFIG_PATH values shared the credential but not the lock.
202
+ export function refreshLockPath(clientId = DEFAULT_CLIENT_ID) {
203
+ return join(homedir(), ".config/nodii-dev", `oauth-refresh-${String(clientId).replace(/[^A-Za-z0-9_.-]/g, "_")}.lock`);
204
+ }
205
+
206
+ const LOCK_STALE_MS = 30_000;
207
+ const LOCK_LIVE_STALE_MS = 120_000;
208
+ const LOCK_BEAT_MS = 5_000;
209
+ const LOCK_WAIT_MS = 90_000;
210
+ const RECLAIM_STALE_MS = 10_000;
211
+
212
+ function ownerPid(owner) {
213
+ const pid = Number.parseInt(owner, 10);
214
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
215
+ }
216
+
217
+ function pidAlive(pid) {
218
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
219
+ }
220
+
221
+ async function readOwner(path) {
222
+ try { return await readFile(path, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
223
+ }
224
+
225
+ async function lockAge(path) {
226
+ try { return Date.now() - (await stat(path)).mtimeMs; } catch (error) { if (error.code === "ENOENT") return null; throw error; }
227
+ }
228
+
229
+ // A lock is reclaimable at once when its holder's pid is gone; when the file
230
+ // names no pid yet (being written, or left by 0.6.2) after `staleMs` without a
231
+ // touch; and from a live holder only after LOCK_LIVE_STALE_MS — far longer than
232
+ // any refresh, so a holder that slept with the laptop is not robbed on waking.
233
+ function reclaimable(owner, age, staleMs) {
234
+ const pid = ownerPid(owner);
235
+ if (pid !== null && !pidAlive(pid)) return true;
236
+ return age > Math.max(staleMs, pid === null ? staleMs : LOCK_LIVE_STALE_MS);
237
+ }
238
+
239
+ // Reclaims are serialized through `<lock>.reclaim`, taken with O_EXCL: two
240
+ // waiters that both judged the same dead lock cannot both delete — the second
241
+ // would otherwise delete the lock the first had just re-created.
242
+ async function reclaim(path, seen, staleMs) {
243
+ const guard = `${path}.reclaim`;
244
+ const mine = `${process.pid}:${randomUUID()}`;
245
+ let handle;
246
+ try {
247
+ handle = await open(guard, "wx", 0o600);
248
+ } catch (error) {
249
+ if (error.code !== "EEXIST") throw error;
250
+ const holder = await readOwner(guard);
251
+ const age = await lockAge(guard);
252
+ if (holder !== null && age !== null && age > RECLAIM_STALE_MS && (await readOwner(guard)) === holder) await rm(guard, { force: true });
253
+ return;
254
+ }
255
+ try {
256
+ try { await handle.writeFile(mine); } finally { await handle.close(); }
257
+ const owner = await readOwner(path);
258
+ const age = await lockAge(path);
259
+ if (owner === seen && age !== null && reclaimable(owner, age, staleMs) && (await readOwner(guard)) === mine) await rm(path, { force: true });
260
+ } finally {
261
+ if ((await readOwner(guard)) === mine) await rm(guard, { force: true });
262
+ }
263
+ }
264
+
265
+ async function acquire(path, { sleep, waitMs, staleMs }) {
266
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
267
+ const owner = `${process.pid}:${randomUUID()}`;
268
+ const deadline = Date.now() + waitMs;
269
+ while (Date.now() < deadline) {
270
+ let handle;
139
271
  try {
140
- const handle = await open(path, "wx", 0o600);
141
- try { return await fn(); } finally { await handle.close(); await rm(path, { force: true }); }
272
+ handle = await open(path, "wx", 0o600);
142
273
  } catch (error) {
143
274
  if (error.code !== "EEXIST") throw error;
144
- try {
145
- if (Date.now() - (await stat(path)).mtimeMs > 60_000) {
146
- await rm(path, { force: true });
147
- continue;
148
- }
149
- } catch (statError) {
150
- if (statError.code !== "ENOENT") throw statError;
151
- }
152
- await sleep(100);
275
+ const seen = await readOwner(path);
276
+ const age = await lockAge(path);
277
+ if (seen !== null && age !== null && reclaimable(seen, age, staleMs)) await reclaim(path, seen, staleMs);
278
+ else await sleep(50 + Math.floor(Math.random() * 100));
279
+ continue;
153
280
  }
281
+ try { await handle.writeFile(owner); } finally { await handle.close(); }
282
+ const beat = setInterval(async () => {
283
+ try { if ((await readOwner(path)) === owner) await utimes(path, new Date(), new Date()); } catch {}
284
+ }, Math.max(250, Math.min(LOCK_BEAT_MS, Math.floor(staleMs / 3))));
285
+ beat.unref();
286
+ return {
287
+ held: async () => (await readOwner(path)) === owner,
288
+ release: async () => {
289
+ clearInterval(beat);
290
+ if ((await readOwner(path)) === owner) await rm(path, { force: true });
291
+ },
292
+ };
293
+ }
294
+ throw new CliError("OAUTH_REFRESH_BUSY", "Timed out waiting for another nodii-dev token refresh; the saved sign-in was kept. Retry the command.", 1);
295
+ }
296
+
297
+ // 0.6.2 and earlier locked `oauth-refresh.lock` beside the config file. While
298
+ // both versions may run on one machine, this release takes that lock too
299
+ // (always after its own, so the order is fixed). 0.6.2 reclaims it after 60 s
300
+ // without a touch, which the heartbeat prevents.
301
+ export const legacyRefreshLockPath = () => join(dirname(configPath()), "oauth-refresh.lock");
302
+
303
+ /**
304
+ * Run `fn` holding the cross-process refresh lock. The holder writes its owner
305
+ * id into the file and touches it every few seconds; a waiter reclaims the
306
+ * lock only from a holder that exited or went quiet, one reclaimer at a time,
307
+ * and a holder only ever removes its own lock file. `fn` receives `held()`, to
308
+ * confirm the lock is still its own before acting on the shared credential.
309
+ */
310
+ export async function withRefreshLock(fn, { clientId, path = refreshLockPath(clientId), legacyPath = path === refreshLockPath(clientId) ? legacyRefreshLockPath() : null,
311
+ sleep = wait, waitMs = LOCK_WAIT_MS, staleMs = LOCK_STALE_MS } = {}) {
312
+ const locks = [await acquire(path, { sleep, waitMs, staleMs })];
313
+ try {
314
+ if (legacyPath && legacyPath !== path) locks.push(await acquire(legacyPath, { sleep, waitMs, staleMs: Math.max(staleMs, 60_000) }));
315
+ const held = async () => (await Promise.all(locks.map((l) => l.held()))).every(Boolean);
316
+ return await fn({ held });
317
+ } finally {
318
+ for (const l of locks.reverse()) await l.release();
154
319
  }
155
- throw new CliError("OAUTH_REFRESH_BUSY", "Timed out waiting for another nodii-dev token refresh.", 1);
156
320
  }
157
321
 
322
+ // What a refresh answer means for the stored credential. Only the server saying
323
+ // the GRANT is dead (RFC 6749 § 5.2 invalid_grant: expired, revoked, replayed)
324
+ // ends the sign-in. Everything else — 429, 5xx, a timeout, no network, an HTML
325
+ // error page from a proxy or CDN, a 2xx without tokens — says nothing about the
326
+ // grant, so the credential is kept.
327
+ const DEAD_GRANT_ERRORS = new Set(["invalid_grant", "expired_token", "token_revoked"]);
328
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
329
+ export const REFRESH_TIMEOUT_MS = 10_000;
330
+ const REFRESH_ATTEMPTS = 4;
331
+ const RETRY_AFTER_CAP_MS = 5_000;
332
+ // The whole refresh, retries included, fits well inside the lock's wait
333
+ // (LOCK_WAIT_MS) so a waiter behind it sees the real outcome, not BUSY.
334
+ export const REFRESH_BUDGET_MS = 25_000;
335
+ // No request starts with less than this left: one cut off after the server
336
+ // rotated the token would lose the new token and replay the old one next run.
337
+ const MIN_ATTEMPT_MS = 5_000;
338
+
339
+ export function classifyRefresh(result) {
340
+ if (result.ok && result.body.access_token && result.body.refresh_token) return "ok";
341
+ if (result.status >= 400 && result.status < 500 && DEAD_GRANT_ERRORS.has(result.body.error)) return "dead";
342
+ if (result.status === 0 || result.ok || RETRYABLE_STATUS.has(result.status) || result.status >= 500 || !result.json) return "retry";
343
+ return "refused";
344
+ }
345
+
346
+ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs) {
347
+ let response;
348
+ try {
349
+ response = await fetchImpl(`${authUrl.replace(/\/$/, "")}/oauth/token`, {
350
+ method: "POST",
351
+ headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
352
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: refreshToken }),
353
+ signal: AbortSignal.timeout(timeoutMs),
354
+ });
355
+ } catch (error) {
356
+ return { ok: false, status: 0, body: {}, json: false, reason: error?.name === "TimeoutError" ? "timeout" : "unreachable" };
357
+ }
358
+ let raw = "";
359
+ try { raw = await response.text(); } catch {}
360
+ let body = {};
361
+ let json = false;
362
+ try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { body = parsed; json = true; } } catch {}
363
+ const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") ?? "", 10);
364
+ return { ok: response.ok, status: response.status, body, json, retryAfterMs: Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined };
365
+ }
366
+
367
+ const describe = (result) => result.status === 0 ? (result.reason === "timeout" ? "timed out" : "nodii-auth unreachable") : `HTTP ${result.status}${result.json && typeof result.body.error === "string" ? ` ${result.body.error}` : ""}`;
368
+
158
369
  export class OAuthAuthProvider {
159
370
  #accessToken;
160
371
  #expiresAt = 0;
161
372
  #refreshPromise;
162
- constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock } = {}) {
163
- Object.assign(this, { fetchImpl, loadToken, storeToken, deleteToken, lock });
373
+ constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock,
374
+ loadAccess, storeAccess, deleteAccess, sleep = wait, timeoutMs = REFRESH_TIMEOUT_MS, budgetMs = REFRESH_BUDGET_MS } = {}) {
375
+ // Injected token storage (tests) gets no shared access cache unless one is
376
+ // injected too, so a test never reads or writes the real Keychain.
377
+ const realStorage = loadToken === loadRefreshToken;
378
+ Object.assign(this, { fetchImpl, loadToken, storeToken, deleteToken, lock, sleep, timeoutMs, budgetMs,
379
+ loadAccess: loadAccess ?? (realStorage ? loadAccessToken : async () => null),
380
+ storeAccess: storeAccess ?? (realStorage ? storeAccessToken : async () => {}),
381
+ deleteAccess: deleteAccess ?? (realStorage ? deleteAccessToken : async () => {}) });
382
+ }
383
+ #usable(cached) {
384
+ if (!cached || Date.now() >= cached.expiresAt - 60_000) return false;
385
+ this.#accessToken = cached.token;
386
+ this.#expiresAt = cached.expiresAt;
387
+ return true;
164
388
  }
165
389
  async accessToken() {
166
390
  if (this.#accessToken && Date.now() < this.#expiresAt - 30_000) return this.#accessToken;
167
- this.#refreshPromise ??= this.lock(async () => {
391
+ this.#refreshPromise ??= (async () => {
168
392
  const config = await readConfig();
169
- const refresh = await this.loadToken(config.clientId);
170
- const result = await oauthPost(config.authUrl, "/oauth/token", { grant_type: "refresh_token", client_id: config.clientId, refresh_token: refresh }, this.fetchImpl);
171
- if (!result.ok || !result.body.refresh_token) {
393
+ // Another process may have refreshed already: its access token is shared.
394
+ if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
395
+ return this.lock(async ({ held } = {}) => {
396
+ // Re-checked under the lock: a process that held it before us has just
397
+ // rotated the refresh token and cached the access token it got.
398
+ if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
399
+ return this.#refresh(config, held ?? (async () => true));
400
+ }, { clientId: config.clientId });
401
+ })().finally(() => { this.#refreshPromise = undefined; });
402
+ return this.#refreshPromise;
403
+ }
404
+ async #attempt(config, refresh, deadline, held) {
405
+ const floor = Math.min(MIN_ATTEMPT_MS, this.timeoutMs);
406
+ let result;
407
+ for (let attempt = 1; ; attempt++) {
408
+ if (result && deadline - Date.now() < floor) return { kind: classifyRefresh(result), result, attempts: attempt - 1 };
409
+ // Never present the refresh token without the lock: a second holder
410
+ // would present the same one, and the server revokes the chain on reuse.
411
+ if (!(await held())) throw new CliError("OAUTH_REFRESH_BUSY", "Lost the refresh lock to another nodii-dev process; the saved sign-in was kept. Retry the command.", 1);
412
+ result = await postRefresh(config.authUrl, config.clientId, refresh, this.fetchImpl, Math.max(floor, Math.min(this.timeoutMs, deadline - Date.now())));
413
+ const kind = classifyRefresh(result);
414
+ if (kind !== "retry" || attempt >= REFRESH_ATTEMPTS) return { kind, result, attempts: attempt };
415
+ const backoff = 500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);
416
+ const pause = Math.min(Math.max(backoff, result.retryAfterMs ?? 0), RETRY_AFTER_CAP_MS);
417
+ if (Date.now() + pause + floor > deadline) return { kind, result, attempts: attempt };
418
+ await this.sleep(pause);
419
+ }
420
+ }
421
+ async #refresh(config, held) {
422
+ const deadline = Date.now() + this.budgetMs;
423
+ let refresh = await this.loadToken(config.clientId);
424
+ let outcome = await this.#attempt(config, refresh, deadline, held);
425
+ if (outcome.kind === "dead" || outcome.kind === "refused") {
426
+ // The stored token may have moved while this one was refused (a login,
427
+ // or a 0.6.2 process outside this lock). One more try with it.
428
+ const current = await this.loadToken(config.clientId).catch(() => null);
429
+ if (current && current !== refresh && deadline - Date.now() >= Math.min(MIN_ATTEMPT_MS, this.timeoutMs)) {
430
+ refresh = current;
431
+ outcome = await this.#attempt(config, refresh, deadline, held);
432
+ }
433
+ }
434
+ const { kind, result, attempts } = outcome;
435
+ if (kind === "dead") {
436
+ // Clear only the token the server rejected, never one stored since.
437
+ const current = await this.loadToken(config.clientId).catch(() => null);
438
+ const cleared = current === refresh;
439
+ if (cleared) {
172
440
  await this.deleteToken(config.clientId);
173
- throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth refresh failed and the saved credential was cleared; run `nodii-dev login` again.", 2);
441
+ await this.deleteAccess(config.clientId);
174
442
  }
175
- await this.storeToken(config.clientId, result.body.refresh_token);
176
- this.#accessToken = result.body.access_token;
177
- this.#expiresAt = Date.now() + result.body.expires_in * 1000;
178
- return this.#accessToken;
179
- }).finally(() => { this.#refreshPromise = undefined; });
180
- return this.#refreshPromise;
443
+ throw new CliError("OAUTH_LOGIN_REQUIRED", `nodii-auth no longer accepts this sign-in (invalid_grant: expired, revoked or replayed)${cleared ? " and the saved credential was cleared" : ""}; run \`nodii-dev login\` again.`, 2);
444
+ }
445
+ if (kind === "retry")
446
+ throw new CliError("OAUTH_REFRESH_UNAVAILABLE", `Could not refresh the sign-in (${describe(result)}) after ${attempts} attempt${attempts === 1 ? "" : "s"}; the saved credential was kept. Retry the command shortly.`, 1);
447
+ if (kind === "refused")
448
+ throw new CliError("OAUTH_REFRESH_FAILED", `nodii-auth refused the refresh (${describe(result)}); the saved credential was kept.`, 1);
449
+ const body = result.body;
450
+ for (let attempt = 1; ; attempt++) {
451
+ try { await this.storeToken(config.clientId, body.refresh_token); break; } catch (error) {
452
+ if (attempt >= 3) throw error;
453
+ await this.sleep(200 * attempt);
454
+ }
455
+ }
456
+ this.#accessToken = body.access_token;
457
+ this.#expiresAt = Date.now() + Number(body.expires_in ?? 0) * 1000;
458
+ await this.storeAccess(config.clientId, { token: this.#accessToken, expiresAt: this.#expiresAt });
459
+ return this.#accessToken;
181
460
  }
182
461
  async headers() {
183
462
  const config = await readConfig();
@@ -185,11 +464,16 @@ export class OAuthAuthProvider {
185
464
  }
186
465
  }
187
466
 
188
- export async function revokeOAuth(config, fetchImpl = fetch) {
189
- const token = await loadRefreshToken(config.clientId);
190
- try {
191
- await oauthPost(config.authUrl, "/oauth/revoke", { token, token_type_hint: "refresh_token", client_id: config.clientId }, fetchImpl);
192
- } finally {
193
- await deleteRefreshToken(config.clientId);
194
- }
467
+ export async function revokeOAuth(config, fetchImpl = fetch, lock = withRefreshLock) {
468
+ // Under the refresh lock, so a refresh in flight cannot store a new token
469
+ // after logout has cleared the old one.
470
+ await lock(async () => {
471
+ try {
472
+ const token = await loadRefreshToken(config.clientId);
473
+ await oauthPost(config.authUrl, "/oauth/revoke", { token, token_type_hint: "refresh_token", client_id: config.clientId }, fetchImpl);
474
+ } finally {
475
+ await deleteRefreshToken(config.clientId);
476
+ await deleteAccessToken(config.clientId);
477
+ }
478
+ }, { clientId: config.clientId });
195
479
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {