rekor-cli 0.1.18 → 0.1.20

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/index.js CHANGED
@@ -13,190 +13,629 @@ import { homedir } from "os";
13
13
  var CONFIG_DIR = join(homedir(), ".config", "rekor");
14
14
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
15
15
  function loadConfig() {
16
- const envToken = process.env["REKOR_TOKEN"];
17
16
  const envUrl = process.env["REKOR_API_URL"];
18
17
  try {
19
18
  const raw = readFileSync(CONFIG_FILE, "utf-8");
20
- const config = JSON.parse(raw);
19
+ const stored = JSON.parse(raw);
21
20
  return {
22
- ...config,
23
- token: envToken ?? config.token,
24
- api_url: envUrl ?? config.api_url
21
+ api_url: envUrl ?? stored.api_url ?? "http://localhost:8787",
22
+ default_workspace: stored.default_workspace,
23
+ org_id: stored.org_id
25
24
  };
26
25
  } catch {
27
26
  return {
28
- api_url: envUrl ?? "http://localhost:8787",
29
- token: envToken ?? ""
27
+ api_url: envUrl ?? "http://localhost:8787"
30
28
  };
31
29
  }
32
30
  }
33
31
  function saveConfig(config) {
34
32
  mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
35
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
33
+ let existing = {};
34
+ try {
35
+ existing = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
36
+ } catch {
37
+ existing = {};
38
+ }
39
+ const merged = { ...existing, ...config };
40
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
36
41
  }
37
42
 
38
- // src/auth.ts
39
- function login(token, apiUrl) {
40
- const config = loadConfig();
41
- saveConfig({
42
- ...config,
43
- token,
44
- api_url: apiUrl ?? config.api_url
45
- });
43
+ // src/token-store.ts
44
+ import * as fs from "fs";
45
+ var KEYRING_SERVICE = "rekor";
46
+ var KEY_REC = "rec_token";
47
+ var KEY_OAUTH_ACCESS = "oauth_access_token";
48
+ var KEY_OAUTH_REFRESH = "oauth_refresh_token";
49
+ var keyringCache;
50
+ async function getKeyring() {
51
+ if (keyringCache !== void 0) return keyringCache;
52
+ try {
53
+ const mod = await import("@napi-rs/keyring");
54
+ const Entry = mod.Entry;
55
+ keyringCache = {
56
+ async getPassword(service, account) {
57
+ try {
58
+ return new Entry(service, account).getPassword();
59
+ } catch {
60
+ return null;
61
+ }
62
+ },
63
+ async setPassword(service, account, password) {
64
+ new Entry(service, account).setPassword(password);
65
+ },
66
+ async deletePassword(service, account) {
67
+ try {
68
+ return new Entry(service, account).deletePassword();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+ };
74
+ } catch {
75
+ keyringCache = null;
76
+ }
77
+ return keyringCache;
78
+ }
79
+ function manualCleanupCommand(account) {
80
+ if (process.platform === "darwin") {
81
+ return `security delete-generic-password -s ${KEYRING_SERVICE} -a ${account}`;
82
+ }
83
+ if (process.platform === "win32") {
84
+ return `cmdkey /delete:${KEYRING_SERVICE}/${account}`;
85
+ }
86
+ if (process.platform === "linux") {
87
+ return `secret-tool clear service ${KEYRING_SERVICE} account ${account}`;
88
+ }
89
+ return `(remove service="${KEYRING_SERVICE}" account="${account}" from your OS credential store)`;
90
+ }
91
+ function readConfigFile() {
92
+ if (!fs.existsSync(CONFIG_FILE)) return null;
93
+ try {
94
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ function writeConfigFile(data) {
100
+ if (!fs.existsSync(CONFIG_DIR)) {
101
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
102
+ }
103
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 384 });
104
+ }
105
+ var StaleKeyringSlotError = class extends Error {
106
+ constructor(account) {
107
+ super(
108
+ `Stale credential remains in OS keychain slot "${account}" after delete. Clean it up manually and retry:
109
+ ${manualCleanupCommand(account)}`
110
+ );
111
+ this.account = account;
112
+ this.name = "StaleKeyringSlotError";
113
+ }
114
+ };
115
+ async function clearSlot(keyring, account) {
116
+ await keyring.deletePassword(KEYRING_SERVICE, account);
117
+ try {
118
+ return await keyring.getPassword(KEYRING_SERVICE, account) === null;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ function stripTokenFieldsFromFile() {
124
+ const existing = readConfigFile();
125
+ if (!existing) return;
126
+ if (existing.token === void 0 && existing.access_token === void 0 && existing.refresh_token === void 0) return;
127
+ delete existing.token;
128
+ delete existing.access_token;
129
+ delete existing.refresh_token;
130
+ writeConfigFile(existing);
131
+ }
132
+ function readMetadataExpiresAt() {
133
+ return readConfigFile()?.token_expires_at;
134
+ }
135
+ function writeMetadataExpiresAt(value) {
136
+ const existing = readConfigFile() ?? {};
137
+ if (value === void 0) {
138
+ delete existing.token_expires_at;
139
+ } else {
140
+ existing.token_expires_at = value;
141
+ }
142
+ writeConfigFile(existing);
143
+ }
144
+ async function migrateLegacyFileToken(keyring) {
145
+ const existing = readConfigFile();
146
+ if (!existing) return null;
147
+ if (existing.access_token) {
148
+ const out = {
149
+ kind: "oauth",
150
+ access_token: existing.access_token,
151
+ refresh_token: existing.refresh_token,
152
+ expires_at: existing.token_expires_at
153
+ };
154
+ if (keyring) {
155
+ try {
156
+ const accessExisting = await keyring.getPassword(KEYRING_SERVICE, KEY_OAUTH_ACCESS);
157
+ if (!accessExisting) {
158
+ await keyring.setPassword(KEYRING_SERVICE, KEY_OAUTH_ACCESS, existing.access_token);
159
+ if (existing.refresh_token) {
160
+ await keyring.setPassword(KEYRING_SERVICE, KEY_OAUTH_REFRESH, existing.refresh_token);
161
+ }
162
+ }
163
+ try {
164
+ stripTokenFieldsFromFile();
165
+ } catch (err) {
166
+ console.warn(`rekor: failed to strip legacy OAuth tokens from ${CONFIG_FILE} (${err.message}).`);
167
+ }
168
+ } catch (err) {
169
+ console.warn(`rekor: OAuth migration to keychain failed (${err.message}); continuing with file-backed.`);
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ if (existing.token) {
175
+ const token = existing.token;
176
+ if (!keyring) return { kind: "rec", token };
177
+ try {
178
+ const recExisting = await keyring.getPassword(KEYRING_SERVICE, KEY_REC);
179
+ if (recExisting) {
180
+ try {
181
+ stripTokenFieldsFromFile();
182
+ } catch (err) {
183
+ console.warn(`rekor: failed to strip stale token from ${CONFIG_FILE} (${err.message}).`);
184
+ }
185
+ return null;
186
+ }
187
+ await keyring.setPassword(KEYRING_SERVICE, KEY_REC, token);
188
+ try {
189
+ stripTokenFieldsFromFile();
190
+ } catch (err) {
191
+ console.warn(`rekor: failed to strip legacy token from ${CONFIG_FILE} (${err.message}).`);
192
+ }
193
+ } catch (err) {
194
+ console.warn(`rekor: keychain migration failed (${err.message}); continuing with file-backed token.`);
195
+ }
196
+ return { kind: "rec", token };
197
+ }
198
+ return null;
199
+ }
200
+ async function getResolvedToken() {
201
+ const envToken = process.env["REKOR_TOKEN"];
202
+ if (envToken) {
203
+ return { kind: "rec", token: envToken };
204
+ }
205
+ const keyring = await getKeyring();
206
+ const migrated = await migrateLegacyFileToken(keyring);
207
+ if (migrated) return migrated;
208
+ if (keyring) {
209
+ const [rec, access, refresh] = await Promise.all([
210
+ keyring.getPassword(KEYRING_SERVICE, KEY_REC),
211
+ keyring.getPassword(KEYRING_SERVICE, KEY_OAUTH_ACCESS),
212
+ keyring.getPassword(KEYRING_SERVICE, KEY_OAUTH_REFRESH)
213
+ ]);
214
+ if (rec) return { kind: "rec", token: rec };
215
+ if (access) {
216
+ const out = { kind: "oauth", access_token: access };
217
+ if (refresh) out.refresh_token = refresh;
218
+ const expiresAt = readMetadataExpiresAt();
219
+ if (expiresAt) out.expires_at = expiresAt;
220
+ return out;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+ async function setRecToken(token) {
226
+ const keyring = await getKeyring();
227
+ if (keyring) {
228
+ try {
229
+ const [accessCleared, refreshCleared] = await Promise.all([
230
+ clearSlot(keyring, KEY_OAUTH_ACCESS),
231
+ clearSlot(keyring, KEY_OAUTH_REFRESH)
232
+ ]);
233
+ const stuck = [
234
+ accessCleared ? null : KEY_OAUTH_ACCESS,
235
+ refreshCleared ? null : KEY_OAUTH_REFRESH
236
+ ].filter((a) => a !== null);
237
+ if (stuck.length > 0) {
238
+ console.warn(
239
+ [
240
+ `rekor: stale OAuth credential remains in OS keychain after delete. Commands work but logout won't fully clear. Manually:`,
241
+ ...stuck.map((a) => ` ${manualCleanupCommand(a)}`)
242
+ ].join("\n")
243
+ );
244
+ }
245
+ await keyring.setPassword(KEYRING_SERVICE, KEY_REC, token);
246
+ writeMetadataExpiresAt(void 0);
247
+ try {
248
+ stripTokenFieldsFromFile();
249
+ } catch (err) {
250
+ console.warn(`rekor: failed to strip stale tokens from ${CONFIG_FILE} (${err.message}).`);
251
+ }
252
+ return;
253
+ } catch (err) {
254
+ if (err instanceof StaleKeyringSlotError) throw err;
255
+ console.warn(`rekor: keychain write failed (${err.message}); falling back to file storage.`);
256
+ }
257
+ }
258
+ const existing = readConfigFile() ?? {};
259
+ delete existing.access_token;
260
+ delete existing.refresh_token;
261
+ delete existing.token_expires_at;
262
+ existing.token = token;
263
+ writeConfigFile(existing);
264
+ }
265
+ async function setOAuthTokens(accessToken, refreshToken, expiresAt) {
266
+ const keyring = await getKeyring();
267
+ if (keyring) {
268
+ try {
269
+ if (!await clearSlot(keyring, KEY_REC)) {
270
+ throw new StaleKeyringSlotError(KEY_REC);
271
+ }
272
+ await keyring.setPassword(KEYRING_SERVICE, KEY_OAUTH_ACCESS, accessToken);
273
+ if (refreshToken) {
274
+ await keyring.setPassword(KEYRING_SERVICE, KEY_OAUTH_REFRESH, refreshToken);
275
+ } else {
276
+ await clearSlot(keyring, KEY_OAUTH_REFRESH);
277
+ }
278
+ writeMetadataExpiresAt(expiresAt);
279
+ try {
280
+ stripTokenFieldsFromFile();
281
+ } catch (err) {
282
+ console.warn(`rekor: failed to strip stale tokens from ${CONFIG_FILE} (${err.message}).`);
283
+ }
284
+ return;
285
+ } catch (err) {
286
+ if (err instanceof StaleKeyringSlotError) throw err;
287
+ console.warn(`rekor: keychain write failed (${err.message}); falling back to file storage.`);
288
+ }
289
+ }
290
+ const existing = readConfigFile() ?? {};
291
+ delete existing.token;
292
+ existing.access_token = accessToken;
293
+ if (refreshToken) existing.refresh_token = refreshToken;
294
+ existing.token_expires_at = expiresAt;
295
+ writeConfigFile(existing);
296
+ }
297
+ async function clearAllTokens() {
298
+ const keyring = await getKeyring();
299
+ if (keyring) {
300
+ const slots = [KEY_REC, KEY_OAUTH_ACCESS, KEY_OAUTH_REFRESH];
301
+ const cleared = await Promise.all(slots.map((s) => clearSlot(keyring, s)));
302
+ for (const [i, ok] of cleared.entries()) {
303
+ if (!ok) {
304
+ const account = slots[i];
305
+ console.warn(
306
+ `rekor: could not clear keychain slot "${account}". Subsequent commands may still pick the stale token. Manually:
307
+ ${manualCleanupCommand(account)}`
308
+ );
309
+ }
310
+ }
311
+ }
312
+ const existing = readConfigFile();
313
+ if (existing) {
314
+ let dirty = false;
315
+ for (const k of ["token", "access_token", "refresh_token", "token_expires_at"]) {
316
+ if (existing[k] !== void 0) {
317
+ delete existing[k];
318
+ dirty = true;
319
+ }
320
+ }
321
+ if (dirty) writeConfigFile(existing);
322
+ }
46
323
  }
47
324
 
48
- // src/browser-login.ts
49
- import { createServer } from "http";
50
- import { randomBytes } from "crypto";
51
- var DEFAULT_APP_URL = "https://rekor.pro";
52
- var TIMEOUT_MS = 12e4;
53
- var FALLBACK_URL_DELAY_MS = 5e3;
54
- async function browserLogin(apiUrl) {
55
- const open = await import("open").then((m) => m.default);
56
- const state = randomBytes(32).toString("hex");
57
- const config = loadConfig();
325
+ // src/oauth.ts
326
+ import * as http from "http";
327
+ var OAUTH_CALLBACK_PORT = 3927;
328
+ var DEFAULT_AUTHKIT_DOMAIN = "";
329
+ var DEFAULT_AUTHKIT_CLIENT_ID = "";
330
+ function getAuthKitDomain() {
331
+ const value = process.env["REKOR_AUTHKIT_DOMAIN"] || DEFAULT_AUTHKIT_DOMAIN;
332
+ if (!value) {
333
+ throw new Error("REKOR_AUTHKIT_DOMAIN is not configured \u2014 set the env var to your AuthKit tenant (e.g. https://<tenant>.authkit.app).");
334
+ }
335
+ return value;
336
+ }
337
+ function getAuthKitClientId() {
338
+ const value = process.env["REKOR_AUTHKIT_CLIENT_ID"] || DEFAULT_AUTHKIT_CLIENT_ID;
339
+ if (!value) {
340
+ throw new Error("REKOR_AUTHKIT_CLIENT_ID is not configured \u2014 set the env var to the AuthKit public PKCE client id registered for the Rekor CLI.");
341
+ }
342
+ return value;
343
+ }
344
+ function expiresInToIso(expiresInSeconds) {
345
+ return new Date(Date.now() + expiresInSeconds * 1e3).toISOString();
346
+ }
347
+ function asTokenResponse(value) {
348
+ if (typeof value !== "object" || value === null) {
349
+ throw new Error("AuthKit returned a non-object token payload");
350
+ }
351
+ const v = value;
352
+ if (typeof v.access_token !== "string" || typeof v.expires_in !== "number") {
353
+ throw new Error("AuthKit token payload missing required fields");
354
+ }
355
+ const out = { access_token: v.access_token, expires_in: v.expires_in };
356
+ if (typeof v.refresh_token === "string") out.refresh_token = v.refresh_token;
357
+ return out;
358
+ }
359
+ async function exchangeCodeForTokens(authkitDomain, clientId, code, codeVerifier, redirectUri) {
360
+ const response = await fetch(`${authkitDomain}/oauth2/token`, {
361
+ method: "POST",
362
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
363
+ body: new URLSearchParams({
364
+ grant_type: "authorization_code",
365
+ code,
366
+ code_verifier: codeVerifier,
367
+ redirect_uri: redirectUri,
368
+ client_id: clientId
369
+ }).toString()
370
+ });
371
+ if (!response.ok) {
372
+ const body = await response.text();
373
+ throw new Error(`Token exchange failed (${response.status}): ${body}`);
374
+ }
375
+ return asTokenResponse(await response.json());
376
+ }
377
+ var SessionExpiredError = class extends Error {
378
+ constructor() {
379
+ super("SESSION_EXPIRED");
380
+ this.name = "SessionExpiredError";
381
+ }
382
+ };
383
+ async function refreshAccessToken(authkitDomain, clientId, refreshToken) {
384
+ const response = await fetch(`${authkitDomain}/oauth2/token`, {
385
+ method: "POST",
386
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
387
+ body: new URLSearchParams({
388
+ grant_type: "refresh_token",
389
+ refresh_token: refreshToken,
390
+ client_id: clientId
391
+ }).toString()
392
+ });
393
+ if (!response.ok) {
394
+ const status = response.status;
395
+ const body = await response.text();
396
+ if (status === 400 || status === 401) {
397
+ try {
398
+ const parsed = JSON.parse(body);
399
+ if (parsed.error === "invalid_grant") throw new SessionExpiredError();
400
+ } catch (err) {
401
+ if (err instanceof SessionExpiredError) throw err;
402
+ }
403
+ }
404
+ throw new Error(`Token refresh failed (${status}): ${body}`);
405
+ }
406
+ return asTokenResponse(await response.json());
407
+ }
408
+ function startCallbackServer(port, expectedState, timeoutMs = 12e4) {
58
409
  return new Promise((resolve, reject) => {
59
- const server = createServer((req, res) => {
60
- const url = new URL(req.url, `http://127.0.0.1`);
410
+ let handled = false;
411
+ const server = http.createServer((req, res) => {
412
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
61
413
  if (url.pathname !== "/callback") {
62
414
  res.writeHead(404);
63
415
  res.end();
64
416
  return;
65
417
  }
66
- const token = url.searchParams.get("token");
67
- const returnedState = url.searchParams.get("state");
68
- if (returnedState !== state) {
418
+ if (handled) {
419
+ res.writeHead(409);
420
+ res.end();
421
+ return;
422
+ }
423
+ const code = url.searchParams.get("code");
424
+ const state = url.searchParams.get("state");
425
+ const error = url.searchParams.get("error");
426
+ const errorDescription = url.searchParams.get("error_description");
427
+ if (error) {
428
+ handled = true;
69
429
  res.writeHead(400, { "Content-Type": "text/html" });
70
- res.end(errorPage("Authentication failed: state mismatch. Please try again."));
430
+ res.end(errorPage(errorDescription || error));
71
431
  cleanup();
72
- reject(new Error("State mismatch \u2014 possible CSRF attempt"));
432
+ reject(new Error(`OAuth error: ${errorDescription || error}`));
73
433
  return;
74
434
  }
75
- if (!token) {
435
+ if (state !== expectedState) {
436
+ handled = true;
76
437
  res.writeHead(400, { "Content-Type": "text/html" });
77
- res.end(errorPage("Authentication failed: no token received."));
438
+ res.end(errorPage("Invalid state parameter"));
78
439
  cleanup();
79
- reject(new Error("No token in callback"));
440
+ reject(new Error("OAuth state mismatch \u2014 possible CSRF attempt"));
80
441
  return;
81
442
  }
82
- const orgId = url.searchParams.get("org_id") ?? void 0;
83
- saveConfig({
84
- ...config,
85
- token,
86
- api_url: apiUrl ?? config.api_url,
87
- org_id: orgId
88
- });
443
+ if (!code) {
444
+ handled = true;
445
+ res.writeHead(400, { "Content-Type": "text/html" });
446
+ res.end(errorPage("No authorization code received"));
447
+ cleanup();
448
+ reject(new Error("No authorization code received"));
449
+ return;
450
+ }
451
+ handled = true;
89
452
  res.writeHead(200, { "Content-Type": "text/html" });
90
453
  res.end(successPage());
91
454
  cleanup();
92
- resolve();
455
+ resolve({ code });
93
456
  });
94
457
  const timeout = setTimeout(() => {
95
458
  cleanup();
96
- reject(new Error("Login timed out \u2014 no response from browser within 2 minutes"));
97
- }, TIMEOUT_MS);
98
- let fallbackTimer;
459
+ reject(new Error("Timed out waiting for login. Run `rekor login` again."));
460
+ }, timeoutMs);
99
461
  function cleanup() {
100
462
  clearTimeout(timeout);
101
- if (fallbackTimer) {
102
- clearTimeout(fallbackTimer);
103
- fallbackTimer = void 0;
104
- }
105
463
  server.close();
106
464
  }
107
- server.listen(0, "127.0.0.1", () => {
108
- const addr = server.address();
109
- if (!addr || typeof addr === "string") {
110
- cleanup();
111
- reject(new Error("Failed to start local server"));
112
- return;
465
+ server.on("error", (err) => {
466
+ cleanup();
467
+ if (err.code === "EADDRINUSE") {
468
+ reject(new Error(`Port ${port} is in use. Close the other process and retry.`));
469
+ } else {
470
+ reject(err);
113
471
  }
114
- const port = addr.port;
115
- const appUrl = process.env["REKOR_APP_URL"] || DEFAULT_APP_URL;
116
- const authUrl = `${appUrl}/cli-auth?port=${port}&state=${state}`;
117
- console.log("Opening browser for authentication...");
118
- fallbackTimer = setTimeout(() => {
119
- fallbackTimer = void 0;
120
- console.log(`If the browser didn't open, visit: ${authUrl}`);
121
- }, FALLBACK_URL_DELAY_MS);
122
- open(authUrl).catch(() => {
123
- if (!fallbackTimer) return;
124
- clearTimeout(fallbackTimer);
125
- fallbackTimer = void 0;
126
- console.log("Could not open browser automatically.");
127
- console.log(`Visit: ${authUrl}`);
128
- });
129
472
  });
473
+ server.listen(port, "127.0.0.1");
130
474
  });
131
475
  }
132
476
  function successPage() {
133
477
  return `<!DOCTYPE html>
134
- <html>
135
- <head><title>Rekor CLI</title></head>
478
+ <html><head><title>Rekor CLI</title></head>
136
479
  <body style="font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa;">
137
480
  <div style="text-align: center;">
138
481
  <h1 style="font-size: 1.25rem; font-weight: 600;">Authentication successful</h1>
139
482
  <p style="color: #666; margin-top: 0.5rem;">You can close this tab and return to your terminal.</p>
140
483
  </div>
141
- </body>
142
- </html>`;
484
+ </body></html>`;
485
+ }
486
+ function escapeHtml(input) {
487
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
143
488
  }
144
489
  function errorPage(message) {
145
490
  return `<!DOCTYPE html>
146
- <html>
147
- <head><title>Rekor CLI</title></head>
491
+ <html><head><title>Rekor CLI</title></head>
148
492
  <body style="font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa;">
149
493
  <div style="text-align: center;">
150
494
  <h1 style="font-size: 1.25rem; font-weight: 600; color: #dc2626;">Authentication failed</h1>
151
- <p style="color: #666; margin-top: 0.5rem;">${message}</p>
495
+ <p style="color: #666; margin-top: 0.5rem;">${escapeHtml(message)}</p>
152
496
  </div>
153
- </body>
154
- </html>`;
497
+ </body></html>`;
498
+ }
499
+
500
+ // src/auth.ts
501
+ async function login(token, apiUrl) {
502
+ await setRecToken(token);
503
+ const config = loadConfig();
504
+ saveConfig({
505
+ ...config,
506
+ api_url: apiUrl ?? config.api_url
507
+ });
508
+ }
509
+ var REFRESH_LEEWAY_MS = 6e4;
510
+ async function getAccessToken() {
511
+ const resolved = await getResolvedToken();
512
+ if (!resolved) return null;
513
+ if (resolved.kind === "rec") return resolved.token;
514
+ const expiresAtMs = resolved.expires_at ? new Date(resolved.expires_at).getTime() : Number.NaN;
515
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs > Date.now() + REFRESH_LEEWAY_MS) {
516
+ return resolved.access_token;
517
+ }
518
+ if (!resolved.refresh_token) {
519
+ throw new Error("Access token expired and no refresh token available. Run `rekor login` again.");
520
+ }
521
+ try {
522
+ const result = await refreshAccessToken(getAuthKitDomain(), getAuthKitClientId(), resolved.refresh_token);
523
+ const newExpiresAt = expiresInToIso(result.expires_in);
524
+ await setOAuthTokens(result.access_token, result.refresh_token ?? resolved.refresh_token, newExpiresAt);
525
+ return result.access_token;
526
+ } catch (err) {
527
+ if (err instanceof SessionExpiredError) {
528
+ await clearAllTokens();
529
+ throw new Error("Session expired. Run `rekor login` again.");
530
+ }
531
+ throw err;
532
+ }
533
+ }
534
+ async function currentAuthKind() {
535
+ const resolved = await getResolvedToken();
536
+ return resolved?.kind ?? null;
537
+ }
538
+
539
+ // src/pkce.ts
540
+ import * as crypto from "crypto";
541
+ function generateCodeVerifier() {
542
+ return crypto.randomBytes(32).toString("base64url");
543
+ }
544
+ function generateCodeChallenge(verifier) {
545
+ return crypto.createHash("sha256").update(verifier).digest().toString("base64url");
546
+ }
547
+ function generateState() {
548
+ return crypto.randomBytes(16).toString("base64url");
155
549
  }
156
550
 
157
551
  // src/commands/login.ts
552
+ var FALLBACK_URL_DELAY_MS = 5e3;
158
553
  var loginCommand = new Command("login").description("Authenticate with Rekor").option("--token <token>", "API key for headless/CI authentication (rec_...)").option("--api-url <url>", "API base URL").action(async (opts) => {
159
554
  if (opts.token) {
160
- login(opts.token, opts.apiUrl);
555
+ await login(opts.token, opts.apiUrl);
161
556
  console.log("Authenticated successfully");
162
- } else {
163
- try {
164
- await browserLogin(opts.apiUrl);
165
- console.log("Authenticated successfully");
166
- } catch (err) {
167
- console.error(
168
- `Login failed: ${err instanceof Error ? err.message : "Unknown error"}`
169
- );
170
- process.exit(1);
171
- }
557
+ return;
558
+ }
559
+ try {
560
+ await browserLoginPkce(opts.apiUrl);
561
+ console.log("Authenticated successfully");
562
+ } catch (err) {
563
+ console.error(
564
+ `Login failed: ${err instanceof Error ? err.message : "Unknown error"}`
565
+ );
566
+ process.exit(1);
172
567
  }
173
568
  });
569
+ async function browserLoginPkce(apiUrl) {
570
+ const open = await import("open").then((m) => m.default);
571
+ const codeVerifier = generateCodeVerifier();
572
+ const codeChallenge = generateCodeChallenge(codeVerifier);
573
+ const state = generateState();
574
+ const port = OAUTH_CALLBACK_PORT;
575
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
576
+ const authkitDomain = getAuthKitDomain();
577
+ const clientId = getAuthKitClientId();
578
+ const authorizeUrl = new URL(`${authkitDomain}/oauth2/authorize`);
579
+ authorizeUrl.searchParams.set("client_id", clientId);
580
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
581
+ authorizeUrl.searchParams.set("response_type", "code");
582
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
583
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
584
+ authorizeUrl.searchParams.set("state", state);
585
+ authorizeUrl.searchParams.set("scope", "openid profile email offline_access");
586
+ console.log("Opening browser for authentication...");
587
+ const callbackPromise = startCallbackServer(port, state);
588
+ let fallbackPrinted = false;
589
+ const fallbackTimer = setTimeout(() => {
590
+ fallbackPrinted = true;
591
+ console.log(`If the browser didn't open, visit: ${authorizeUrl.toString()}`);
592
+ }, FALLBACK_URL_DELAY_MS);
593
+ open(authorizeUrl.toString()).catch(() => {
594
+ if (fallbackPrinted) return;
595
+ clearTimeout(fallbackTimer);
596
+ console.log("Could not open browser automatically.");
597
+ console.log(`Visit: ${authorizeUrl.toString()}`);
598
+ });
599
+ let code;
600
+ try {
601
+ ({ code } = await callbackPromise);
602
+ } finally {
603
+ clearTimeout(fallbackTimer);
604
+ }
605
+ const tokens = await exchangeCodeForTokens(authkitDomain, clientId, code, codeVerifier, redirectUri);
606
+ const expiresAt = expiresInToIso(tokens.expires_in);
607
+ await setOAuthTokens(tokens.access_token, tokens.refresh_token, expiresAt);
608
+ const config = loadConfig();
609
+ saveConfig({
610
+ ...config,
611
+ api_url: apiUrl ?? config.api_url
612
+ });
613
+ }
174
614
 
175
615
  // src/commands/logout.ts
176
616
  import { Command as Command2 } from "commander";
177
- var logoutCommand = new Command2("logout").description("Remove stored authentication credentials").action(() => {
178
- const config = loadConfig();
179
- saveConfig({ ...config, token: "" });
180
- console.log("Logged out successfully");
181
- });
182
-
183
- // src/commands/workspaces.ts
184
- import { Command as Command3 } from "commander";
185
617
 
186
618
  // src/client.ts
187
619
  var ApiClient = class {
188
620
  baseUrl;
189
- token;
621
+ _tokenPromise;
190
622
  constructor() {
191
623
  const config = loadConfig();
192
624
  this.baseUrl = config.api_url;
193
- this.token = config.token;
625
+ }
626
+ getToken() {
627
+ if (!this._tokenPromise) this._tokenPromise = getAccessToken();
628
+ return this._tokenPromise;
629
+ }
630
+ async authHeaders() {
631
+ const token = await this.getToken();
632
+ return token ? { "Authorization": `Bearer ${token}` } : {};
194
633
  }
195
634
  async request(method, path, body) {
196
635
  const res = await fetch(`${this.baseUrl}${path}`, {
197
636
  method,
198
637
  headers: {
199
- "Authorization": `Bearer ${this.token}`,
638
+ ...await this.authHeaders(),
200
639
  "Content-Type": "application/json"
201
640
  },
202
641
  body: body ? JSON.stringify(body) : void 0
@@ -207,8 +646,39 @@ var ApiClient = class {
207
646
  }
208
647
  return json.data;
209
648
  }
649
+ async uploadFile(url, body, contentType) {
650
+ const res = await fetch(url, {
651
+ method: "PUT",
652
+ headers: {
653
+ ...await this.authHeaders(),
654
+ "Content-Type": contentType
655
+ },
656
+ body
657
+ });
658
+ if (!res.ok) {
659
+ throw new Error(`Upload failed: HTTP ${res.status}`);
660
+ }
661
+ }
210
662
  };
211
663
 
664
+ // src/commands/logout.ts
665
+ var logoutCommand = new Command2("logout").description("Remove stored authentication credentials").action(async () => {
666
+ const kind = await currentAuthKind();
667
+ if (kind === "oauth") {
668
+ try {
669
+ const client = new ApiClient();
670
+ await client.request("POST", "/v1/auth/logout");
671
+ } catch (err) {
672
+ console.warn(`rekor: server-side logout failed (${err instanceof Error ? err.message : "unknown"}); clearing local credentials anyway.`);
673
+ }
674
+ }
675
+ await clearAllTokens();
676
+ console.log("Logged out successfully");
677
+ });
678
+
679
+ // src/commands/workspaces.ts
680
+ import { Command as Command3 } from "commander";
681
+
212
682
  // src/output.ts
213
683
  import chalk from "chalk";
214
684
  import Table from "cli-table3";
@@ -248,10 +718,10 @@ function formatKeyValue(obj) {
248
718
  }
249
719
 
250
720
  // src/helpers.ts
251
- import { readFileSync as readFileSync2 } from "fs";
721
+ import { readFileSync as readFileSync3 } from "fs";
252
722
  function parseData(data) {
253
723
  if (data.startsWith("@")) {
254
- const content = readFileSync2(data.slice(1), "utf-8");
724
+ const content = readFileSync3(data.slice(1), "utf-8");
255
725
  return JSON.parse(content);
256
726
  }
257
727
  return JSON.parse(data);
@@ -406,13 +876,13 @@ recordsCommand.command("delete <collection> <id>").description("Delete a record"
406
876
 
407
877
  // src/commands/sql.ts
408
878
  import { Command as Command6 } from "commander";
409
- import { readFileSync as readFileSync3 } from "fs";
879
+ import { readFileSync as readFileSync4 } from "fs";
410
880
  var sqlCommand = new Command6("sql").description("Execute a read-only SQL query against workspace data").argument("[query]", "SQL query (SELECT only)").option("--file <path>", "Read SQL from a file instead of argument").option("--param <kv...>", "Named parameters as key=value pairs (e.g., --param status=issued)").action(async function(queryArg, opts) {
411
881
  const ws = getWorkspace(this);
412
882
  const client = new ApiClient();
413
883
  let query;
414
884
  if (opts.file) {
415
- query = readFileSync3(opts.file, "utf-8").trim();
885
+ query = readFileSync4(opts.file, "utf-8").trim();
416
886
  } else if (queryArg) {
417
887
  query = queryArg;
418
888
  } else {
@@ -487,7 +957,7 @@ var queryRelationshipsCommand = new Command8("query-relationships").description(
487
957
 
488
958
  // src/commands/attachments.ts
489
959
  import { Command as Command9 } from "commander";
490
- import { readFileSync as readFileSync4 } from "fs";
960
+ import { readFileSync as readFileSync5 } from "fs";
491
961
  var attachmentsCommand = new Command9("attachments").description("Manage record attachments");
492
962
  attachmentsCommand.command("upload <collection> <id>").description("Get a presigned upload URL for a record attachment. With --file, uploads the file content in one step.").requiredOption("--filename <name>", "File name or path (e.g. docs/guide.md)").option("--content-type <type>", "MIME type", "application/octet-stream").option("--file <path>", "Local file to upload (skips presigned URL output, uploads directly)").action(async function(collection, id, opts) {
493
963
  const ws = getWorkspace(this);
@@ -497,16 +967,9 @@ attachmentsCommand.command("upload <collection> <id>").description("Get a presig
497
967
  content_type: opts.contentType
498
968
  });
499
969
  if (opts.file) {
500
- const body = readFileSync4(opts.file);
970
+ const body = readFileSync5(opts.file);
501
971
  const uploadUrl = data.upload_url.startsWith("http") ? data.upload_url : `${client.baseUrl}${data.upload_url}`;
502
- const uploadRes = await fetch(uploadUrl, {
503
- method: "PUT",
504
- headers: { "Content-Type": opts.contentType, "Authorization": `Bearer ${client.token}` },
505
- body
506
- });
507
- if (!uploadRes.ok) {
508
- throw new Error(`Upload failed: HTTP ${uploadRes.status}`);
509
- }
972
+ await client.uploadFile(uploadUrl, body, opts.contentType);
510
973
  console.log(formatOutput({ ...data, uploaded: true }, getFormat(this)));
511
974
  } else {
512
975
  console.log(formatOutput(data, getFormat(this)));
@@ -725,8 +1188,8 @@ providersCommand.command("export <provider>").description(`Export collections as
725
1188
  const query = opts.collections ? `?collections=${opts.collections}` : "";
726
1189
  const data = await client.request("GET", `/v1/${ws}/providers/${provider}/export${query}`);
727
1190
  if (opts.output) {
728
- const { writeFileSync: writeFileSync2 } = await import("fs");
729
- writeFileSync2(opts.output, JSON.stringify(data, null, 2));
1191
+ const { writeFileSync: writeFileSync3 } = await import("fs");
1192
+ writeFileSync3(opts.output, JSON.stringify(data, null, 2));
730
1193
  console.log(`Written to ${opts.output}`);
731
1194
  } else {
732
1195
  console.log(formatOutput(data, getFormat(this)));
@@ -774,8 +1237,11 @@ tokensCommand.command("revoke <token_id>").description("Revoke an API token").ac
774
1237
  // package.json
775
1238
  var package_default = {
776
1239
  name: "rekor-cli",
777
- version: "0.1.18",
1240
+ version: "0.1.20",
778
1241
  type: "module",
1242
+ engines: {
1243
+ node: ">=20.0.0"
1244
+ },
779
1245
  bin: {
780
1246
  rekor: "dist/index.js"
781
1247
  },
@@ -789,6 +1255,7 @@ var package_default = {
789
1255
  dev: "tsup --watch"
790
1256
  },
791
1257
  dependencies: {
1258
+ "@napi-rs/keyring": "^1.1.6",
792
1259
  chalk: "^5.4.1",
793
1260
  "cli-table3": "^0.6.5",
794
1261
  commander: "^13.1.0",