@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/README.md +36 -3
- package/dist/api.js +10 -2
- package/dist/cli.js +14 -2
- package/dist/core.js +2 -2
- package/dist/devops-openapi.json +1 -1
- package/dist/guidelines.js +223 -0
- package/dist/oauth.js +334 -50
- package/package.json +1 -1
package/dist/oauth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
48
|
+
async function keychainStore(name, secret, os) {
|
|
47
49
|
if (os === "darwin") {
|
|
48
|
-
|
|
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",
|
|
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
|
-
|
|
63
|
-
if (
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 ??=
|
|
391
|
+
this.#refreshPromise ??= (async () => {
|
|
168
392
|
const config = await readConfig();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
441
|
+
await this.deleteAccess(config.clientId);
|
|
174
442
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
}
|