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 +587 -120
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
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
|
|
19
|
+
const stored = JSON.parse(raw);
|
|
21
20
|
return {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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/
|
|
49
|
-
import
|
|
50
|
-
|
|
51
|
-
var
|
|
52
|
-
var
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
430
|
+
res.end(errorPage(errorDescription || error));
|
|
71
431
|
cleanup();
|
|
72
|
-
reject(new Error(
|
|
432
|
+
reject(new Error(`OAuth error: ${errorDescription || error}`));
|
|
73
433
|
return;
|
|
74
434
|
}
|
|
75
|
-
if (
|
|
435
|
+
if (state !== expectedState) {
|
|
436
|
+
handled = true;
|
|
76
437
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
77
|
-
res.end(errorPage("
|
|
438
|
+
res.end(errorPage("Invalid state parameter"));
|
|
78
439
|
cleanup();
|
|
79
|
-
reject(new Error("
|
|
440
|
+
reject(new Error("OAuth state mismatch \u2014 possible CSRF attempt"));
|
|
80
441
|
return;
|
|
81
442
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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("
|
|
97
|
-
},
|
|
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.
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
484
|
+
</body></html>`;
|
|
485
|
+
}
|
|
486
|
+
function escapeHtml(input) {
|
|
487
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
621
|
+
_tokenPromise;
|
|
190
622
|
constructor() {
|
|
191
623
|
const config = loadConfig();
|
|
192
624
|
this.baseUrl = config.api_url;
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
721
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
252
722
|
function parseData(data) {
|
|
253
723
|
if (data.startsWith("@")) {
|
|
254
|
-
const content =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
970
|
+
const body = readFileSync5(opts.file);
|
|
501
971
|
const uploadUrl = data.upload_url.startsWith("http") ? data.upload_url : `${client.baseUrl}${data.upload_url}`;
|
|
502
|
-
|
|
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:
|
|
729
|
-
|
|
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.
|
|
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",
|