azdo-cli 0.10.0-develop.268 → 0.10.0-develop.317

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.
@@ -0,0 +1,1087 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/services/oauth-token-refresh.ts
4
+ import { closeSync, mkdirSync, openSync, unlinkSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+
8
+ // src/services/oauth-flow.ts
9
+ import { createServer } from "http";
10
+
11
+ // src/lib/pkce.ts
12
+ import { createHash, randomBytes } from "crypto";
13
+ function base64urlNoPad(buf) {
14
+ return buf.toString("base64url");
15
+ }
16
+ function generateVerifier(byteLen = 32) {
17
+ if (byteLen < 32 || byteLen > 96) {
18
+ throw new RangeError("verifier byte length must be in [32, 96]");
19
+ }
20
+ return base64urlNoPad(randomBytes(byteLen));
21
+ }
22
+ function challengeForVerifier(verifier) {
23
+ return base64urlNoPad(createHash("sha256").update(verifier, "ascii").digest());
24
+ }
25
+ function randomState(byteLen = 16) {
26
+ if (byteLen < 12 || byteLen > 64) {
27
+ throw new RangeError("state byte length must be in [12, 64]");
28
+ }
29
+ return base64urlNoPad(randomBytes(byteLen));
30
+ }
31
+ var CODE_CHALLENGE_METHOD = "S256";
32
+
33
+ // src/services/config-store.ts
34
+ import fs from "fs";
35
+ import path from "path";
36
+ import os from "os";
37
+ var SETTINGS = [
38
+ {
39
+ key: "org",
40
+ description: "Azure DevOps organization name",
41
+ type: "string",
42
+ example: "mycompany",
43
+ required: true
44
+ },
45
+ {
46
+ key: "project",
47
+ description: "Azure DevOps project name",
48
+ type: "string",
49
+ example: "MyProject",
50
+ required: true
51
+ },
52
+ {
53
+ key: "fields",
54
+ description: "Extra work item fields to include (comma-separated reference names)",
55
+ type: "string[]",
56
+ example: "System.Tags,Custom.Priority",
57
+ required: false
58
+ },
59
+ {
60
+ key: "markdown",
61
+ description: "Convert rich text fields to markdown on display",
62
+ type: "boolean",
63
+ example: "true",
64
+ required: false
65
+ }
66
+ ];
67
+ var VALID_KEYS = SETTINGS.map((s) => s.key);
68
+ function getConfigPath() {
69
+ return path.join(os.homedir(), ".azdo", "config.json");
70
+ }
71
+ function loadConfig() {
72
+ const configPath = getConfigPath();
73
+ let raw;
74
+ try {
75
+ raw = fs.readFileSync(configPath, "utf-8");
76
+ } catch (err) {
77
+ if (err.code === "ENOENT") {
78
+ return {};
79
+ }
80
+ throw err;
81
+ }
82
+ try {
83
+ return JSON.parse(raw);
84
+ } catch {
85
+ process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
86
+ `);
87
+ return {};
88
+ }
89
+ }
90
+ function saveConfig(config) {
91
+ const configPath = getConfigPath();
92
+ const dir = path.dirname(configPath);
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
95
+ }
96
+ function validateKey(key) {
97
+ if (!VALID_KEYS.includes(key)) {
98
+ throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
99
+ }
100
+ }
101
+ function getConfigValue(key) {
102
+ validateKey(key);
103
+ const config = loadConfig();
104
+ return config[key];
105
+ }
106
+ function setConfigValue(key, value) {
107
+ validateKey(key);
108
+ const config = loadConfig();
109
+ if (value === "") {
110
+ delete config[key];
111
+ } else if (key === "markdown") {
112
+ if (value !== "true" && value !== "false") {
113
+ throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
114
+ }
115
+ config.markdown = value === "true";
116
+ } else if (key === "fields") {
117
+ config.fields = value.split(",").map((s) => s.trim());
118
+ } else {
119
+ config[key] = value;
120
+ }
121
+ saveConfig(config);
122
+ }
123
+ function unsetConfigValue(key) {
124
+ validateKey(key);
125
+ const config = loadConfig();
126
+ delete config[key];
127
+ saveConfig(config);
128
+ }
129
+
130
+ // src/services/oauth-config.ts
131
+ var DEFAULT_OAUTH_CLIENT_ID = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1";
132
+ var DEFAULT_OAUTH_TENANT_ID = "common";
133
+ var AZDO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798";
134
+ function defaultScopes() {
135
+ return [
136
+ `${AZDO_RESOURCE_ID}/vso.work`,
137
+ `${AZDO_RESOURCE_ID}/vso.work_write`,
138
+ `${AZDO_RESOURCE_ID}/vso.code`,
139
+ "offline_access",
140
+ "openid"
141
+ ];
142
+ }
143
+ function firstPartyShippedScopes() {
144
+ return [`${AZDO_RESOURCE_ID}/.default`, "offline_access", "openid"];
145
+ }
146
+ function resolveOAuthConfig(opts = {}) {
147
+ const envClientId = opts.envClientId ?? process.env.AZDO_OAUTH_CLIENT_ID;
148
+ const envTenantId = opts.envTenantId ?? process.env.AZDO_OAUTH_TENANT_ID;
149
+ const fileConfig = loadConfig();
150
+ const fileClientId = fileConfig.oauth?.clientId;
151
+ const fileTenantId = fileConfig.oauth?.tenantId;
152
+ let clientId;
153
+ let clientIdSource;
154
+ if (opts.clientIdOverride && opts.clientIdOverride.length > 0) {
155
+ clientId = opts.clientIdOverride;
156
+ clientIdSource = "flag";
157
+ } else if (envClientId && envClientId.length > 0) {
158
+ clientId = envClientId;
159
+ clientIdSource = "env";
160
+ } else if (fileClientId && fileClientId.length > 0) {
161
+ clientId = fileClientId;
162
+ clientIdSource = "config";
163
+ } else {
164
+ clientId = DEFAULT_OAUTH_CLIENT_ID;
165
+ clientIdSource = "default";
166
+ }
167
+ const tenantId = opts.tenantIdOverride ?? (envTenantId && envTenantId.length > 0 ? envTenantId : null) ?? (fileTenantId && fileTenantId.length > 0 ? fileTenantId : null) ?? DEFAULT_OAUTH_TENANT_ID;
168
+ const scopes = (() => {
169
+ if (opts.scopesOverride && opts.scopesOverride.length > 0) return [...opts.scopesOverride];
170
+ if (clientIdSource === "default") return [...firstPartyShippedScopes()];
171
+ return [...defaultScopes()];
172
+ })();
173
+ return {
174
+ clientId,
175
+ tenantId,
176
+ scopes,
177
+ clientIdSource,
178
+ authorizationEndpoint: `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/authorize`,
179
+ tokenEndpoint: `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`,
180
+ deviceCodeEndpoint: `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/devicecode`
181
+ };
182
+ }
183
+ var REDIRECT_URI_PATTERN = /^http:\/\/(?:127\.0\.0\.1|localhost):\d+(?:\/callback)?$/;
184
+ function validateRedirectUri(uri) {
185
+ return REDIRECT_URI_PATTERN.test(uri);
186
+ }
187
+ function buildScopeString(scopes) {
188
+ return scopes.join(" ");
189
+ }
190
+
191
+ // src/services/browser-open.ts
192
+ import { execFile } from "child_process";
193
+ function isHeadless(platform, hasDisplay) {
194
+ if (platform === "linux") {
195
+ return !hasDisplay;
196
+ }
197
+ return false;
198
+ }
199
+ function commandForPlatform(platform) {
200
+ switch (platform) {
201
+ case "darwin":
202
+ return { cmd: "open", args: (url) => [url] };
203
+ case "win32":
204
+ return { cmd: "rundll32", args: (url) => ["url.dll,FileProtocolHandler", url] };
205
+ case "linux":
206
+ return { cmd: "xdg-open", args: (url) => [url] };
207
+ default:
208
+ return null;
209
+ }
210
+ }
211
+ async function openUrl(url, opts = {}) {
212
+ const platform = opts.platform ?? process.platform;
213
+ const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
214
+ const forcePrint = opts.forcePrint ?? false;
215
+ if (forcePrint || isHeadless(platform, hasDisplay)) {
216
+ process.stderr.write(`Open this URL in your browser: ${url}
217
+ `);
218
+ return "printed";
219
+ }
220
+ const spec = commandForPlatform(platform);
221
+ if (!spec) {
222
+ process.stderr.write(`Open this URL in your browser: ${url}
223
+ `);
224
+ return "printed";
225
+ }
226
+ const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
227
+ return await new Promise((resolve) => {
228
+ try {
229
+ runner(spec.cmd, spec.args(url), (err) => {
230
+ if (err) {
231
+ process.stderr.write(`Open this URL in your browser: ${url}
232
+ `);
233
+ resolve("printed");
234
+ } else {
235
+ resolve("opened");
236
+ }
237
+ });
238
+ } catch {
239
+ process.stderr.write(`Open this URL in your browser: ${url}
240
+ `);
241
+ resolve("printed");
242
+ }
243
+ });
244
+ }
245
+
246
+ // src/services/oauth-flow.ts
247
+ var OAuthFlowError = class extends Error {
248
+ reason;
249
+ /**
250
+ * Structured IdP error code preserved verbatim from the OAuth error
251
+ * response (e.g. "invalid_grant", "AADSTS70008", "consent_required").
252
+ * Populated for `idp-error` reason when the IdP returned a parseable JSON
253
+ * error body. Undefined for other reasons (state-mismatch, port-conflict,
254
+ * timeout, etc.) where there is no IdP error code to forward.
255
+ *
256
+ * Callers that translate OAuth errors into a finer classification
257
+ * (e.g. oauth-token-refresh.classifyRefreshFailure) MUST inspect this
258
+ * field — the formatted .message string is intended for human display
259
+ * and is NOT a stable parsing surface.
260
+ */
261
+ idpErrorCode;
262
+ idpErrorDescription;
263
+ constructor(reason, message, cause, idp) {
264
+ super(message);
265
+ this.name = "OAuthFlowError";
266
+ this.reason = reason;
267
+ if (cause instanceof Error) {
268
+ this.cause = cause;
269
+ }
270
+ if (idp?.error) this.idpErrorCode = idp.error;
271
+ if (idp?.error_description) this.idpErrorDescription = idp.error_description;
272
+ }
273
+ };
274
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
275
+ var SHARED_HTML_STYLE = "<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:8em auto;text-align:center;color:#222}p{color:#555}</style>";
276
+ function successHtml(org) {
277
+ const escapedOrg = escapeHtml(org);
278
+ return '<!doctype html><html><head><meta charset="utf-8"><title>Login complete</title>' + SHARED_HTML_STYLE + '</head><body><h1 style="color:#107c10">Login complete</h1><p>You can close this tab and return to the terminal.</p><p style="font-size:0.9em">Organization: <code>' + escapedOrg + "</code></p></body></html>";
279
+ }
280
+ function errorHtml(msg) {
281
+ const escapedMsg = escapeHtml(msg);
282
+ return '<!doctype html><html><head><meta charset="utf-8"><title>Login failed</title>' + SHARED_HTML_STYLE + '</head><body><h1 style="color:#c50f1f">Login failed</h1><p>' + escapedMsg + '</p><p style="font-size:0.9em">Return to the terminal for details.</p></body></html>';
283
+ }
284
+ function escapeHtml(s) {
285
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
286
+ }
287
+ var LoopbackListenerImpl = class {
288
+ constructor(server, port) {
289
+ this.server = server;
290
+ this.port = port;
291
+ }
292
+ active = null;
293
+ awaitCallback(session, signal) {
294
+ return new Promise((rResolve, rReject) => {
295
+ this.active = {
296
+ session,
297
+ resolve: (r) => this.finish(rResolve, r),
298
+ reject: (e) => this.finishError(rReject, e)
299
+ };
300
+ signal.addEventListener("abort", () => this.onAbort());
301
+ });
302
+ }
303
+ close() {
304
+ return new Promise((cResolve) => {
305
+ this.server.close(() => cResolve());
306
+ });
307
+ }
308
+ finish(rResolve, r) {
309
+ this.active = null;
310
+ rResolve(r);
311
+ }
312
+ finishError(rReject, e) {
313
+ this.active = null;
314
+ rReject(e);
315
+ }
316
+ onAbort() {
317
+ if (!this.active) return;
318
+ const a = this.active;
319
+ this.active = null;
320
+ a.reject(new OAuthFlowError("timeout", "OAuth flow aborted before callback"));
321
+ }
322
+ };
323
+ function bindLoopbackServer(factory, resolve, reject) {
324
+ const ref = { listener: null };
325
+ const server = factory((req, res) => {
326
+ handleCallback(req, res, ref.listener?.active ?? null);
327
+ });
328
+ server.once("error", (err) => {
329
+ reject(new OAuthFlowError("port-conflict", `failed to bind loopback listener: ${err.message}`, err));
330
+ });
331
+ server.listen({ host: "127.0.0.1", port: 0 }, () => {
332
+ const addr = server.address();
333
+ if (!addr || typeof addr === "string" || addr.port === 0) {
334
+ reject(new OAuthFlowError("port-conflict", "loopback listener did not return a numeric port"));
335
+ return;
336
+ }
337
+ ref.listener = new LoopbackListenerImpl(server, addr.port);
338
+ resolve(ref.listener);
339
+ });
340
+ }
341
+ async function openLoopbackListener(deps = {}) {
342
+ const factory = deps.createServer ?? createServer;
343
+ return new Promise((resolve, reject) => {
344
+ bindLoopbackServer(factory, resolve, reject);
345
+ });
346
+ }
347
+ function handleCallback(req, res, active) {
348
+ if (!req.url) {
349
+ writeError(res, 400, "missing request URL");
350
+ return;
351
+ }
352
+ const url = new URL(req.url, "http://127.0.0.1");
353
+ const path3 = url.pathname;
354
+ const q = url.searchParams;
355
+ if (path3 !== "/callback" && path3 !== "/") {
356
+ writeError(res, 404, `unexpected path ${path3}`);
357
+ if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${path3}"`));
358
+ return;
359
+ }
360
+ if (!active) {
361
+ writeError(res, 503, "no active OAuth flow");
362
+ return;
363
+ }
364
+ const error = q.get("error");
365
+ if (error) {
366
+ const rawDesc = q.get("error_description") ?? "";
367
+ const descSuffix = rawDesc ? `: ${rawDesc}` : "";
368
+ writeError(res, 400, `IdP error: ${error}`);
369
+ active.reject(new OAuthFlowError("idp-error", `${error}${descSuffix}`));
370
+ return;
371
+ }
372
+ const state = q.get("state");
373
+ if (!state || state !== active.session.state) {
374
+ writeError(res, 400, "state mismatch");
375
+ active.reject(new OAuthFlowError("state-mismatch", "OAuth state parameter did not match originating session"));
376
+ return;
377
+ }
378
+ const code = q.get("code");
379
+ if (!code) {
380
+ writeError(res, 400, "missing authorization code");
381
+ active.reject(new OAuthFlowError("idp-error", "callback missing authorization code"));
382
+ return;
383
+ }
384
+ writeSuccess(res, active.session.org);
385
+ active.resolve({ code });
386
+ }
387
+ function writeSuccess(res, org) {
388
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
389
+ res.end(successHtml(org));
390
+ }
391
+ function writeError(res, status, msg) {
392
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
393
+ res.end(errorHtml(msg));
394
+ }
395
+ function prepareAuthCodeSession(input) {
396
+ if (!validateRedirectUri(input.redirectUri)) {
397
+ throw new OAuthFlowError(
398
+ "redirect-mismatch",
399
+ `redirect URI must be loopback-only (http://127.0.0.1:<port>/callback): ${input.redirectUri}`
400
+ );
401
+ }
402
+ const verifier = generateVerifier();
403
+ const challenge = challengeForVerifier(verifier);
404
+ const state = randomState();
405
+ return {
406
+ flow: "auth-code",
407
+ org: input.org,
408
+ state,
409
+ codeVerifier: verifier,
410
+ codeChallenge: challenge,
411
+ redirectUri: input.redirectUri,
412
+ clientId: input.oauthConfig.clientId,
413
+ tenantId: input.oauthConfig.tenantId,
414
+ scope: buildScopeString(input.oauthConfig.scopes),
415
+ startedAt: Math.floor(input.now / 1e3),
416
+ timeoutAt: Math.floor((input.now + input.timeoutMs) / 1e3)
417
+ };
418
+ }
419
+ function buildAuthorizationUrl(session, oauthConfig) {
420
+ const params = new URLSearchParams({
421
+ client_id: session.clientId,
422
+ response_type: "code",
423
+ redirect_uri: session.redirectUri,
424
+ scope: session.scope,
425
+ state: session.state,
426
+ code_challenge: session.codeChallenge,
427
+ code_challenge_method: CODE_CHALLENGE_METHOD,
428
+ prompt: "select_account"
429
+ });
430
+ return `${oauthConfig.authorizationEndpoint}?${params.toString()}`;
431
+ }
432
+ async function exchangeCodeForToken(code, session, oauthConfig, fetchFn) {
433
+ const body = new URLSearchParams({
434
+ grant_type: "authorization_code",
435
+ code,
436
+ redirect_uri: session.redirectUri,
437
+ client_id: session.clientId,
438
+ code_verifier: session.codeVerifier,
439
+ scope: session.scope
440
+ });
441
+ const response = await fetchFn(oauthConfig.tokenEndpoint, {
442
+ method: "POST",
443
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
444
+ body: body.toString()
445
+ });
446
+ return await readTokenResponse(response);
447
+ }
448
+ async function readTokenResponse(response) {
449
+ const text = await response.text();
450
+ let parsed;
451
+ try {
452
+ parsed = JSON.parse(text);
453
+ } catch {
454
+ throw new OAuthFlowError(
455
+ "idp-error",
456
+ `IdP returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`
457
+ );
458
+ }
459
+ if (!response.ok) {
460
+ const err = parsed;
461
+ const code = err.error ?? "unknown";
462
+ const desc = err.error_description ? `: ${err.error_description}` : "";
463
+ throw new OAuthFlowError(
464
+ "idp-error",
465
+ `IdP rejected request (${response.status}): ${code}${desc}`,
466
+ void 0,
467
+ err
468
+ );
469
+ }
470
+ const ok = parsed;
471
+ if (typeof ok.access_token !== "string" || ok.access_token.length === 0) {
472
+ throw new OAuthFlowError("idp-error", "token response missing access_token");
473
+ }
474
+ if (typeof ok.expires_in !== "number" || ok.expires_in <= 0) {
475
+ throw new OAuthFlowError("idp-error", "token response missing valid expires_in");
476
+ }
477
+ return ok;
478
+ }
479
+ function decodeIdTokenClaims(idToken) {
480
+ const parts = idToken.split(".");
481
+ if (parts.length < 2) return {};
482
+ try {
483
+ const payload = Buffer.from(parts[1], "base64url").toString("utf8");
484
+ return JSON.parse(payload);
485
+ } catch {
486
+ return {};
487
+ }
488
+ }
489
+ function tokenResponseToCredential(org, oauthConfig, token, now) {
490
+ const claims = token.id_token ? decodeIdTokenClaims(token.id_token) : {};
491
+ const accountId = claims.oid ?? claims.preferred_username ?? claims.upn ?? claims.email ?? "unknown";
492
+ const issuedAt = Math.floor(now / 1e3);
493
+ return {
494
+ kind: "oauth",
495
+ accessToken: token.access_token,
496
+ refreshToken: token.refresh_token ?? null,
497
+ expiresAt: issuedAt + token.expires_in,
498
+ issuedAt,
499
+ accountId,
500
+ scope: token.scope ?? buildScopeString(oauthConfig.scopes),
501
+ tenantId: oauthConfig.tenantId
502
+ };
503
+ }
504
+ async function runAuthCodeFlow(org, oauthConfig, deps = {}) {
505
+ const fetchFn = deps.fetch ?? fetch;
506
+ const open = deps.openUrl ?? openUrl;
507
+ const now = deps.now ?? (() => Date.now());
508
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
509
+ const listener = await openLoopbackListener(deps);
510
+ const redirectUri = oauthConfig.clientIdSource === "default" ? `http://localhost:${listener.port}` : `http://127.0.0.1:${listener.port}/callback`;
511
+ const session = prepareAuthCodeSession({ org, oauthConfig, redirectUri, now: now(), timeoutMs });
512
+ const ac = new AbortController();
513
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
514
+ try {
515
+ const authUrl = buildAuthorizationUrl(session, oauthConfig);
516
+ process.stderr.write(`Opening browser to authorise ${org} (loopback callback at ${redirectUri})\u2026
517
+ `);
518
+ await open(authUrl);
519
+ const cb = await listener.awaitCallback(session, ac.signal);
520
+ const token = await exchangeCodeForToken(cb.code, session, oauthConfig, fetchFn);
521
+ const credential = tokenResponseToCredential(org, oauthConfig, token, now());
522
+ return { credential, flowUsed: "auth-code" };
523
+ } finally {
524
+ clearTimeout(timer);
525
+ await listener.close();
526
+ }
527
+ }
528
+
529
+ // src/services/audit-log.ts
530
+ import fs2 from "fs";
531
+ import os2 from "os";
532
+ import path2 from "path";
533
+ function getAuditLogPath() {
534
+ return path2.join(os2.homedir(), ".azdo", "audit.log");
535
+ }
536
+ function ensureDirWithPerms(dir) {
537
+ if (!fs2.existsSync(dir)) {
538
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
539
+ return;
540
+ }
541
+ try {
542
+ fs2.chmodSync(dir, 448);
543
+ } catch {
544
+ }
545
+ }
546
+ function ensureFileWithPerms(file) {
547
+ if (!fs2.existsSync(file)) {
548
+ fs2.writeFileSync(file, "", { mode: 384 });
549
+ return;
550
+ }
551
+ try {
552
+ fs2.chmodSync(file, 384);
553
+ } catch {
554
+ }
555
+ }
556
+ var FORBIDDEN_FIELDS = /* @__PURE__ */ new Set([
557
+ "token",
558
+ "accessToken",
559
+ "access_token",
560
+ "refreshToken",
561
+ "refresh_token",
562
+ "pat"
563
+ ]);
564
+ function stripForbidden(input) {
565
+ const out = { ...input };
566
+ for (const key of Object.keys(out)) {
567
+ if (FORBIDDEN_FIELDS.has(key)) {
568
+ delete out[key];
569
+ }
570
+ }
571
+ return out;
572
+ }
573
+ function whenSet(value, key) {
574
+ return value === void 0 ? {} : { [key]: value };
575
+ }
576
+ function appendAuthAuditEvent(input) {
577
+ const auditLog = getAuditLogPath();
578
+ const dir = path2.dirname(auditLog);
579
+ ensureDirWithPerms(dir);
580
+ ensureFileWithPerms(auditLog);
581
+ const safe = stripForbidden(input);
582
+ const record = {
583
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
584
+ event: safe.event,
585
+ org: safe.org,
586
+ backend: safe.backend,
587
+ ...whenSet(safe.masked_pat, "masked_pat"),
588
+ ...whenSet(safe.flow, "flow"),
589
+ ...whenSet(safe.clientIdSource, "clientIdSource"),
590
+ ...whenSet(safe.accountId, "accountId"),
591
+ ...whenSet(safe.scope, "scope"),
592
+ ...whenSet(safe.tokenLifetimeSec, "tokenLifetimeSec"),
593
+ ...whenSet(safe.reason, "reason")
594
+ };
595
+ fs2.appendFileSync(auditLog, `${JSON.stringify(record)}
596
+ `);
597
+ }
598
+ function readAuditEvents() {
599
+ const auditLog = getAuditLogPath();
600
+ if (!fs2.existsSync(auditLog)) {
601
+ return [];
602
+ }
603
+ const contents = fs2.readFileSync(auditLog, "utf8");
604
+ const out = [];
605
+ for (const line of contents.split("\n")) {
606
+ const trimmed = line.trim();
607
+ if (!trimmed) continue;
608
+ try {
609
+ const parsed = JSON.parse(trimmed);
610
+ if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
611
+ out.push(parsed);
612
+ }
613
+ } catch {
614
+ }
615
+ }
616
+ return out;
617
+ }
618
+
619
+ // src/services/credential-store.ts
620
+ import { Entry } from "@napi-rs/keyring";
621
+
622
+ // src/types/credential.ts
623
+ var CredentialStoreUnavailableError = class extends Error {
624
+ backend;
625
+ constructor(backend, cause) {
626
+ super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
627
+ this.name = "CredentialStoreUnavailableError";
628
+ this.backend = backend;
629
+ if (cause instanceof Error) {
630
+ this.cause = cause;
631
+ }
632
+ }
633
+ };
634
+ var CredentialMissingError = class extends Error {
635
+ org;
636
+ constructor(org) {
637
+ super(`No stored credential for org "${org}". Run \`azdo auth login --org ${org}\` to authenticate.`);
638
+ this.name = "CredentialMissingError";
639
+ this.org = org;
640
+ }
641
+ };
642
+ var CredentialRefreshError = class extends Error {
643
+ org;
644
+ reason;
645
+ userMessage;
646
+ constructor(org, reason, cause) {
647
+ const userMessage = reason === "network" ? `OAuth refresh for org \`${org}\` failed (network error); check connectivity and retry the command. The stored credential is preserved.` : `Refresh token rejected for org \`${org}\`; run \`azdo auth login --org ${org}\` to re-authorise. The stored credential is preserved (FR-014) \u2014 inspect it with \`azdo auth status --org ${org}\`.`;
648
+ super(userMessage);
649
+ this.name = "CredentialRefreshError";
650
+ this.org = org;
651
+ this.reason = reason;
652
+ this.userMessage = userMessage;
653
+ if (cause instanceof Error) {
654
+ this.cause = cause;
655
+ }
656
+ }
657
+ };
658
+
659
+ // src/services/auth-masking.ts
660
+ var VISIBLE_CHARS = 5;
661
+ function maskedDisplay(pat) {
662
+ if (pat.length <= VISIBLE_CHARS * 2) {
663
+ return pat;
664
+ }
665
+ const hiddenCount = pat.length - VISIBLE_CHARS * 2;
666
+ return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
667
+ }
668
+ function normalizePat(rawPat) {
669
+ const trimmedPat = rawPat.trim();
670
+ return trimmedPat.length > 0 ? trimmedPat : null;
671
+ }
672
+
673
+ // src/services/credential-store.ts
674
+ var SERVICE = "azdo-cli";
675
+ var LEGACY_ACCOUNT = "pat";
676
+ function accountFor(org) {
677
+ return `pat:${org}`;
678
+ }
679
+ function probeBackend() {
680
+ switch (process.platform) {
681
+ case "win32":
682
+ return "windows-credential-manager";
683
+ case "darwin":
684
+ return "macos-keychain";
685
+ case "linux":
686
+ return "linux-libsecret";
687
+ default:
688
+ return "unknown";
689
+ }
690
+ }
691
+ function wrapUnavailable(fn) {
692
+ try {
693
+ return fn();
694
+ } catch (err) {
695
+ throw new CredentialStoreUnavailableError(probeBackend(), err);
696
+ }
697
+ }
698
+ function entryFor(account) {
699
+ return wrapUnavailable(() => new Entry(SERVICE, account));
700
+ }
701
+ var legacyUnsetNoticeEmitted = false;
702
+ function emitLegacyUnsetNoticeOnce() {
703
+ if (legacyUnsetNoticeEmitted) return;
704
+ legacyUnsetNoticeEmitted = true;
705
+ process.stderr.write(
706
+ 'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
707
+ );
708
+ }
709
+ function isValidOAuthEnvelope(value) {
710
+ if (!value || typeof value !== "object") return false;
711
+ const v = value;
712
+ if (v.kind !== "oauth") return false;
713
+ if (typeof v.accessToken !== "string" || v.accessToken.length === 0) return false;
714
+ if (v.refreshToken !== null && typeof v.refreshToken !== "string") return false;
715
+ if (typeof v.expiresAt !== "number" || typeof v.issuedAt !== "number") return false;
716
+ if (v.expiresAt <= v.issuedAt) return false;
717
+ if (v.expiresAt - v.issuedAt > 24 * 3600) return false;
718
+ if (typeof v.accountId !== "string" || typeof v.scope !== "string" || typeof v.tenantId !== "string") {
719
+ return false;
720
+ }
721
+ return true;
722
+ }
723
+ function isValidPatEnvelope(value) {
724
+ if (!value || typeof value !== "object") return false;
725
+ const v = value;
726
+ return v.kind === "pat" && typeof v.token === "string" && v.token.length > 0;
727
+ }
728
+ function parseStoredValue(raw) {
729
+ let parsed;
730
+ try {
731
+ parsed = JSON.parse(raw);
732
+ } catch {
733
+ return { kind: "pat", token: raw };
734
+ }
735
+ if (!parsed || typeof parsed !== "object" || !("kind" in parsed)) {
736
+ return { kind: "pat", token: raw };
737
+ }
738
+ if (isValidOAuthEnvelope(parsed)) {
739
+ return parsed;
740
+ }
741
+ if (isValidPatEnvelope(parsed)) {
742
+ return parsed;
743
+ }
744
+ throw new CredentialStoreUnavailableError(
745
+ probeBackend(),
746
+ new Error(`unknown or invalid credential envelope kind`)
747
+ );
748
+ }
749
+ function serializeCredential(cred) {
750
+ if (cred.kind === "oauth") {
751
+ if (cred.expiresAt <= cred.issuedAt) {
752
+ throw new Error("expiresAt must be greater than issuedAt");
753
+ }
754
+ if (cred.expiresAt - cred.issuedAt > 24 * 3600) {
755
+ throw new Error("OAuth access-token lifetime exceeds 24h sanity bound");
756
+ }
757
+ if (!cred.accessToken) {
758
+ throw new Error("OAuth credential missing accessToken");
759
+ }
760
+ } else if (!cred.token) {
761
+ throw new Error("PAT credential missing token");
762
+ }
763
+ return JSON.stringify(cred);
764
+ }
765
+ async function maybeMigrateLegacy(targetOrg) {
766
+ const config = loadConfig();
767
+ if (!config.org || config.org !== targetOrg) {
768
+ if (!config.org) {
769
+ let legacyExists;
770
+ try {
771
+ const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
772
+ legacyExists = legacyEntry2.getPassword() !== null;
773
+ } catch {
774
+ legacyExists = false;
775
+ }
776
+ if (legacyExists) {
777
+ emitLegacyUnsetNoticeOnce();
778
+ }
779
+ }
780
+ return null;
781
+ }
782
+ const newEntry = entryFor(accountFor(targetOrg));
783
+ const existingNew = wrapUnavailable(() => newEntry.getPassword());
784
+ if (existingNew !== null) {
785
+ return null;
786
+ }
787
+ const legacyEntry = entryFor(LEGACY_ACCOUNT);
788
+ const legacy = wrapUnavailable(() => legacyEntry.getPassword());
789
+ if (legacy === null) {
790
+ return null;
791
+ }
792
+ wrapUnavailable(() => {
793
+ newEntry.setPassword(legacy);
794
+ legacyEntry.deletePassword();
795
+ });
796
+ appendAuthAuditEvent({
797
+ event: "auth.store",
798
+ org: targetOrg,
799
+ backend: probeBackend(),
800
+ masked_pat: maskedDisplay(legacy)
801
+ });
802
+ process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
803
+ `);
804
+ return legacy;
805
+ }
806
+ async function getPat(org) {
807
+ const cred = await getStoredCredential(org);
808
+ if (cred === null) return null;
809
+ if (cred.kind === "pat") return cred.token;
810
+ return null;
811
+ }
812
+ async function getStoredCredential(org) {
813
+ const entry = entryFor(accountFor(org));
814
+ const value = wrapUnavailable(() => entry.getPassword());
815
+ if (value === null) {
816
+ const migrated = await maybeMigrateLegacy(org);
817
+ if (migrated === null) return null;
818
+ return parseStoredValue(migrated);
819
+ }
820
+ return parseStoredValue(value);
821
+ }
822
+ async function storePat(org, pat) {
823
+ const cred = { kind: "pat", token: pat };
824
+ const entry = entryFor(accountFor(org));
825
+ wrapUnavailable(() => entry.setPassword(serializeCredential(cred)));
826
+ appendAuthAuditEvent({
827
+ event: "auth.store",
828
+ org,
829
+ backend: probeBackend(),
830
+ masked_pat: maskedDisplay(pat)
831
+ });
832
+ }
833
+ async function storeOAuthCredential(org, cred) {
834
+ const entry = entryFor(accountFor(org));
835
+ wrapUnavailable(() => entry.setPassword(serializeCredential(cred)));
836
+ }
837
+ async function deletePat(org) {
838
+ const entry = entryFor(accountFor(org));
839
+ const existing = wrapUnavailable(() => entry.getPassword());
840
+ if (existing === null) {
841
+ return false;
842
+ }
843
+ let parsed;
844
+ try {
845
+ parsed = parseStoredValue(existing);
846
+ } catch {
847
+ parsed = { kind: "pat", token: existing };
848
+ }
849
+ wrapUnavailable(() => entry.deletePassword());
850
+ if (parsed.kind === "oauth") {
851
+ appendAuthAuditEvent({
852
+ event: "oauth-logout",
853
+ org,
854
+ backend: probeBackend(),
855
+ accountId: parsed.accountId
856
+ });
857
+ } else {
858
+ appendAuthAuditEvent({
859
+ event: "auth.delete",
860
+ org,
861
+ backend: probeBackend(),
862
+ masked_pat: maskedDisplay(parsed.token)
863
+ });
864
+ }
865
+ try {
866
+ const { unlinkSync: unlinkSync2 } = await import("fs");
867
+ const { lockPath: lockPath2 } = await import("./oauth-token-refresh-PHW66RC4.js");
868
+ unlinkSync2(lockPath2(org));
869
+ } catch {
870
+ }
871
+ return true;
872
+ }
873
+ async function listOrgsWithStoredPat() {
874
+ const seen = /* @__PURE__ */ new Set();
875
+ for (const ev of readAuditEvents()) {
876
+ if (ev.event === "auth.store" || ev.event === "oauth-login-success") {
877
+ seen.add(ev.org);
878
+ } else if (ev.event === "auth.delete" || ev.event === "oauth-logout") {
879
+ seen.delete(ev.org);
880
+ }
881
+ }
882
+ const present = [];
883
+ for (const org of seen) {
884
+ const entry = entryFor(accountFor(org));
885
+ const value = wrapUnavailable(() => entry.getPassword());
886
+ if (value !== null) {
887
+ present.push(org);
888
+ }
889
+ }
890
+ present.sort((a, b) => a.localeCompare(b));
891
+ return present;
892
+ }
893
+
894
+ // src/services/oauth-token-refresh.ts
895
+ var DEFAULT_LOCK_WAIT_MS = 5e3;
896
+ var inFlight = /* @__PURE__ */ new Map();
897
+ function locksDir() {
898
+ return join(homedir(), ".azdo", ".locks");
899
+ }
900
+ var FILENAME_UNSAFE = /[^A-Za-z0-9_.-]/g;
901
+ function lockPath(org) {
902
+ const safe = org.replaceAll(FILENAME_UNSAFE, "_");
903
+ return join(locksDir(), `${safe}.refresh`);
904
+ }
905
+ async function defaultAcquireLock(org, waitMs = DEFAULT_LOCK_WAIT_MS) {
906
+ const path3 = lockPath(org);
907
+ mkdirSync(locksDir(), { recursive: true, mode: 448 });
908
+ const deadline = Date.now() + waitMs;
909
+ for (; ; ) {
910
+ try {
911
+ const fd = openSync(path3, "wx");
912
+ closeSync(fd);
913
+ return {
914
+ release: () => {
915
+ try {
916
+ unlinkSync(path3);
917
+ } catch {
918
+ }
919
+ }
920
+ };
921
+ } catch (err) {
922
+ if (err.code !== "EEXIST") throw err;
923
+ if (Date.now() > deadline) return null;
924
+ await new Promise((r) => setTimeout(r, 100));
925
+ }
926
+ }
927
+ }
928
+ function classifyRefreshFailure(error) {
929
+ const code = typeof error === "string" ? error : error.error ?? "";
930
+ switch (code) {
931
+ case "invalid_grant":
932
+ return "invalid-grant";
933
+ case "AADSTS70008":
934
+ // refresh_token expired
935
+ case "expired_token":
936
+ return "window-exceeded";
937
+ case "AADSTS50173":
938
+ // Auth method change requires fresh login
939
+ case "consent_required":
940
+ case "interaction_required":
941
+ case "login_required":
942
+ case "access_denied":
943
+ return "revoked";
944
+ case "network":
945
+ return "network";
946
+ default:
947
+ return "unknown";
948
+ }
949
+ }
950
+ async function performRefresh(org, current, oauthConfig, fetchFn, now, persist) {
951
+ if (!current.refreshToken) {
952
+ throw new CredentialRefreshError(org, "invalid-grant");
953
+ }
954
+ const body = new URLSearchParams({
955
+ grant_type: "refresh_token",
956
+ client_id: oauthConfig.clientId,
957
+ refresh_token: current.refreshToken,
958
+ scope: buildScopeString(oauthConfig.scopes)
959
+ });
960
+ let response;
961
+ try {
962
+ response = await fetchFn(oauthConfig.tokenEndpoint, {
963
+ method: "POST",
964
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
965
+ body: body.toString()
966
+ });
967
+ } catch (err) {
968
+ appendAuthAuditEvent({
969
+ event: "oauth-refresh-failed",
970
+ org,
971
+ backend: probeBackend(),
972
+ accountId: current.accountId,
973
+ reason: "network"
974
+ });
975
+ throw new CredentialRefreshError(org, "network", err);
976
+ }
977
+ let token;
978
+ try {
979
+ token = await readTokenResponse(response);
980
+ } catch (err) {
981
+ let reason;
982
+ if (err instanceof OAuthFlowError && err.idpErrorCode) {
983
+ reason = classifyRefreshFailure({ error: err.idpErrorCode, error_description: err.idpErrorDescription });
984
+ } else {
985
+ reason = classifyRefreshFailure({ error: "unknown" });
986
+ }
987
+ appendAuthAuditEvent({
988
+ event: "oauth-refresh-failed",
989
+ org,
990
+ backend: probeBackend(),
991
+ accountId: current.accountId,
992
+ reason
993
+ });
994
+ throw new CredentialRefreshError(org, reason, err);
995
+ }
996
+ const issuedAt = Math.floor(now() / 1e3);
997
+ const next = {
998
+ kind: "oauth",
999
+ accessToken: token.access_token,
1000
+ refreshToken: token.refresh_token ?? current.refreshToken,
1001
+ expiresAt: issuedAt + token.expires_in,
1002
+ issuedAt,
1003
+ accountId: current.accountId,
1004
+ scope: token.scope ?? current.scope,
1005
+ tenantId: current.tenantId
1006
+ };
1007
+ await persist(org, next);
1008
+ appendAuthAuditEvent({
1009
+ event: "oauth-refresh-success",
1010
+ org,
1011
+ backend: probeBackend(),
1012
+ accountId: current.accountId,
1013
+ tokenLifetimeSec: token.expires_in
1014
+ });
1015
+ return next;
1016
+ }
1017
+ async function refreshIfNeeded(org, current, deps = {}) {
1018
+ const now = deps.now ?? (() => Date.now());
1019
+ const nowSec = Math.floor(now() / 1e3);
1020
+ if (current.expiresAt - nowSec > 60) {
1021
+ return current;
1022
+ }
1023
+ const inProcess = inFlight.get(org);
1024
+ if (inProcess) {
1025
+ return inProcess;
1026
+ }
1027
+ const fetchFn = deps.fetch ?? fetch;
1028
+ const oauthConfig = deps.oauthConfigOverride ?? resolveOAuthConfig({
1029
+ tenantIdOverride: current.tenantId
1030
+ });
1031
+ const acquire = deps.acquireLock ?? ((o) => defaultAcquireLock(o));
1032
+ const persist = deps.persist ?? (async (o, c) => {
1033
+ await storeOAuthCredential(o, c);
1034
+ });
1035
+ const op = (async () => {
1036
+ const lock = await acquire(org);
1037
+ try {
1038
+ return await performRefresh(org, current, oauthConfig, fetchFn, now, persist);
1039
+ } finally {
1040
+ lock?.release();
1041
+ }
1042
+ })();
1043
+ inFlight.set(org, op);
1044
+ try {
1045
+ return await op;
1046
+ } finally {
1047
+ inFlight.delete(org);
1048
+ }
1049
+ }
1050
+ function _resetInFlight() {
1051
+ inFlight.clear();
1052
+ }
1053
+
1054
+ export {
1055
+ CredentialStoreUnavailableError,
1056
+ CredentialMissingError,
1057
+ appendAuthAuditEvent,
1058
+ readAuditEvents,
1059
+ SETTINGS,
1060
+ loadConfig,
1061
+ getConfigValue,
1062
+ setConfigValue,
1063
+ unsetConfigValue,
1064
+ maskedDisplay,
1065
+ normalizePat,
1066
+ AZDO_RESOURCE_ID,
1067
+ defaultScopes,
1068
+ firstPartyShippedScopes,
1069
+ resolveOAuthConfig,
1070
+ buildScopeString,
1071
+ openUrl,
1072
+ readTokenResponse,
1073
+ tokenResponseToCredential,
1074
+ runAuthCodeFlow,
1075
+ locksDir,
1076
+ lockPath,
1077
+ classifyRefreshFailure,
1078
+ refreshIfNeeded,
1079
+ _resetInFlight,
1080
+ probeBackend,
1081
+ getPat,
1082
+ getStoredCredential,
1083
+ storePat,
1084
+ storeOAuthCredential,
1085
+ deletePat,
1086
+ listOrgsWithStoredPat
1087
+ };