neon 2.43.0 → 2.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -15
- package/dist/_shared/auth_selection.js +86 -0
- package/dist/_shared/credentials.js +209 -0
- package/dist/_shared/paths.js +149 -0
- package/dist/{profiles.js → _shared/profiles.js} +121 -35
- package/dist/_shared/secure_file.js +43 -0
- package/dist/analytics.js +16 -6
- package/dist/auth_context.js +53 -8
- package/dist/commands/api_keys.js +8 -56
- package/dist/commands/auth.js +131 -59
- package/dist/commands/bootstrap.js +16 -3
- package/dist/commands/init.js +17 -0
- package/dist/commands/profile.js +835 -45
- package/dist/config.js +1 -22
- package/dist/context.js +7 -0
- package/dist/index.js +16 -9
- package/dist/profile_keys.js +55 -0
- package/dist/utils/flags.js +52 -0
- package/dist/utils/middlewares.js +16 -2
- package/dist/utils/package_manager.js +8 -1
- package/package.json +12 -12
package/dist/commands/profile.js
CHANGED
|
@@ -1,18 +1,85 @@
|
|
|
1
|
-
import { existsSync, readFileSync, rmSync
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
3
|
import prompts from "prompts";
|
|
4
|
-
import {
|
|
4
|
+
import { credentialInputs } from "../_shared/auth_selection.js";
|
|
5
|
+
import { API_KEY, apiKeyCredentials, credentialKind, describeScope, inspectCredentials, interpretCredentials, isSameCredential, OAUTH, readCredentials, scopeOf, writeCredentials, } from "../_shared/credentials.js";
|
|
6
|
+
import { assertProfilesUsable, assertValidProfileName, DEFAULT_PROFILE, listProfiles, newProfileCredentialsPath, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, upsertProfile, } from "../_shared/profiles.js";
|
|
7
|
+
import { writeSecretFile } from "../_shared/secure_file.js";
|
|
8
|
+
import { getApiClient, isNeonApiError } from "../api.js";
|
|
9
|
+
import { auth, revokeToken } from "../auth.js";
|
|
10
|
+
import { setAuthContext } from "../auth_context.js";
|
|
11
|
+
import { isInsideConfigDir } from "../config.js";
|
|
5
12
|
import { isCi } from "../env.js";
|
|
6
13
|
import { log } from "../log.js";
|
|
7
|
-
import {
|
|
14
|
+
import { identityFromAuthDetails, isApiKeyMethod, mintedKeyName, notAnApiKeyMessage, } from "../profile_keys.js";
|
|
15
|
+
import { noPassthrough, single } from "../utils/flags.js";
|
|
8
16
|
import { writer } from "../writer.js";
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
import { orgIdForProject } from "./api_keys.js";
|
|
18
|
+
import { authFlow, credentialsPathForName, usableCredential } from "./auth.js";
|
|
19
|
+
export const command = "profile";
|
|
20
|
+
export const aliases = ["profiles"];
|
|
11
21
|
export const describe = "Manage named sets of Neon credentials";
|
|
12
22
|
export const builder = (argv) => argv
|
|
13
|
-
.usage("$0
|
|
14
|
-
.command("list", "List profiles, the account each holds, and where its credentials live", (y) => y, async (args) => await list(args))
|
|
15
|
-
.command("
|
|
23
|
+
.usage("$0 profile <sub-command> [options]")
|
|
24
|
+
.command("list", "List profiles, the account each holds, and where its credentials live", (y) => y.strict().check(noPassthrough("profile list")), async (args) => await list(args))
|
|
25
|
+
.command("create <name>", "Create a profile. It holds either a browser sign-in or an API key, never both", (y) => y
|
|
26
|
+
.positional("name", {
|
|
27
|
+
describe: "Name for the new profile",
|
|
28
|
+
type: "string",
|
|
29
|
+
demandOption: true,
|
|
30
|
+
})
|
|
31
|
+
.options({
|
|
32
|
+
// `create` deliberately ignores NEON_API_KEY (see `resolveKeyToStore`),
|
|
33
|
+
// but the global option renders "default: NEON_API_KEY" into this help
|
|
34
|
+
// screen — so a user with it exported would expect it to be stored.
|
|
35
|
+
"api-key": {
|
|
36
|
+
describe: 'API key to store, or "-" to read it from stdin',
|
|
37
|
+
defaultDescription: "none; this command ignores NEON_API_KEY",
|
|
38
|
+
type: "string",
|
|
39
|
+
// Force the next token to be taken as the value. Without it yargs
|
|
40
|
+
// reads the `-` in `--api-key -` as an option of its own and reports
|
|
41
|
+
// "Unknown command: -", so only the `=` form would bind.
|
|
42
|
+
nargs: 1,
|
|
43
|
+
},
|
|
44
|
+
mint: {
|
|
45
|
+
describe: "Sign in once in the browser, then store a freshly minted API key and nothing else",
|
|
46
|
+
type: "boolean",
|
|
47
|
+
default: false,
|
|
48
|
+
},
|
|
49
|
+
"org-id": {
|
|
50
|
+
describe: "With --mint, mint a key for this organization instead of your account",
|
|
51
|
+
type: "string",
|
|
52
|
+
coerce: single("org-id"),
|
|
53
|
+
},
|
|
54
|
+
"project-id": {
|
|
55
|
+
describe: "With --mint, mint a key that can access only this project",
|
|
56
|
+
type: "string",
|
|
57
|
+
coerce: single("project-id"),
|
|
58
|
+
},
|
|
59
|
+
force: {
|
|
60
|
+
describe: "Replace an existing profile, revoking the credential it holds now",
|
|
61
|
+
type: "boolean",
|
|
62
|
+
default: false,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
// A project-scoped key is already an organization key, and its org is
|
|
66
|
+
// derived from the project rather than chosen — same rule as `api-keys`.
|
|
67
|
+
.conflicts("org-id", "project-id")
|
|
68
|
+
.strict()
|
|
69
|
+
.check(noPassthrough("profile create"))
|
|
70
|
+
.example("$0 profile create work", "Sign in with the browser, like `neon auth --profile work`")
|
|
71
|
+
.example('$0 profile create work --api-key "$KEY"', "Store a key you already have")
|
|
72
|
+
.example('echo "$KEY" | $0 profile create work --api-key -', "Or pipe it, so it never reaches the process arguments")
|
|
73
|
+
.example("$0 profile create ci --mint --org-id org-abc-123", "One browser sign-in, then an org-scoped key and no session left behind"), async (args) => await create(args))
|
|
74
|
+
.command("rotate-key <name>", "Mint a fresh API key for a profile, at the same scope, and revoke the one it replaces", (y) => y
|
|
75
|
+
.positional("name", {
|
|
76
|
+
describe: "Profile to rotate",
|
|
77
|
+
type: "string",
|
|
78
|
+
demandOption: true,
|
|
79
|
+
})
|
|
80
|
+
.strict()
|
|
81
|
+
.check(noPassthrough("profile rotate-key")), async (args) => await rotateKey(args))
|
|
82
|
+
.command("remove <name>", "Revoke a profile's credential where possible, and remove it", (y) => y
|
|
16
83
|
.positional("name", {
|
|
17
84
|
describe: "Profile to remove",
|
|
18
85
|
type: "string",
|
|
@@ -23,32 +90,743 @@ export const builder = (argv) => argv
|
|
|
23
90
|
describe: "Skip the confirmation prompt",
|
|
24
91
|
type: "boolean",
|
|
25
92
|
default: false,
|
|
26
|
-
})
|
|
27
|
-
.
|
|
93
|
+
})
|
|
94
|
+
.strict()
|
|
95
|
+
.check(noPassthrough("profile remove")), async (args) => await remove(args))
|
|
96
|
+
.demandCommand(1, "Run `neon profile --help` to see the subcommands.");
|
|
28
97
|
export const handler = (_args) => {
|
|
29
98
|
/* subcommands only */
|
|
30
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* Refuse a global credential flag that a `profile` subcommand cannot honour.
|
|
102
|
+
*
|
|
103
|
+
* These commands name their target as a positional and authenticate as that profile, so both
|
|
104
|
+
* `--profile` and `--api-key` have nothing to select here and were silently dropped — which is
|
|
105
|
+
* precisely the class of bug this command family exists to stop. `.strict()` cannot catch
|
|
106
|
+
* either, because both are global options and yargs has already accepted them.
|
|
107
|
+
*
|
|
108
|
+
* No "did you mean". The suggestion this used to make was built from the flag rather than the
|
|
109
|
+
* positional, so it renamed the target: `profile remove work --profile other` answered "did you
|
|
110
|
+
* mean `neon profile remove other`". Since the name is a required positional, that was wrong in
|
|
111
|
+
* every case it fired, and on `remove` it was copy-pasteable destructive advice for an account
|
|
112
|
+
* the user had not mentioned.
|
|
113
|
+
*/
|
|
114
|
+
const rejectProfileFlag = (props, sub) => {
|
|
115
|
+
const named = props.profile?.trim();
|
|
116
|
+
if (!named)
|
|
117
|
+
return;
|
|
118
|
+
throw new Error(`--profile does not apply to \`profile ${sub}\`, which takes the profile name as an argument. You passed both "${props.name}" and --profile ${named}; drop --profile.`);
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Refuse `--api-key` on a subcommand that authenticates as the profile it was given.
|
|
122
|
+
*
|
|
123
|
+
* The mirror image of the flag above, and it was the one left silent: `profile remove work
|
|
124
|
+
* --api-key napi_…` ignored the key, used the stored one, and reported a revoke failure against
|
|
125
|
+
* a credential the user had not passed. This PR exists because a dropped credential flag chose
|
|
126
|
+
* the wrong account; leaving that unsaid on the commands that manage credentials would be the
|
|
127
|
+
* same bug with the arguments swapped.
|
|
128
|
+
*/
|
|
129
|
+
const rejectApiKeyFlag = (sub, instead) => {
|
|
130
|
+
if (credentialInputs().apiKeyFlag.trim() === "")
|
|
131
|
+
return;
|
|
132
|
+
throw new Error(`--api-key does not apply to \`profile ${sub}\`, which uses the credential the profile already holds.${instead ? ` ${instead}` : ""}`);
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* What kind of credential a file holds, for display.
|
|
136
|
+
*
|
|
137
|
+
* A file that declares a kind we cannot read is reported as `invalid` with the reason logged,
|
|
138
|
+
* rather than throwing: one broken profile must not hide every other row, and the whole point
|
|
139
|
+
* of `list` is to show the state of things including the broken parts.
|
|
140
|
+
*/
|
|
141
|
+
const describeAuth = (stored, at) => {
|
|
142
|
+
if (stored === null)
|
|
143
|
+
return "-";
|
|
144
|
+
try {
|
|
145
|
+
// `interpretCredentials`, not `credentialKind`: the kind is a declaration, and a file
|
|
146
|
+
// declaring `api_key` with no key satisfies it. Reporting that as a working API-key
|
|
147
|
+
// profile is the wrong answer for the one command run to find out what is broken.
|
|
148
|
+
return interpretCredentials(stored, at).kind === API_KEY
|
|
149
|
+
? "api key"
|
|
150
|
+
: "oauth";
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
log.warning(err instanceof Error ? err.message : String(err));
|
|
154
|
+
return "invalid";
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* One row's view of a credentials file.
|
|
159
|
+
*
|
|
160
|
+
* Reports a damaged file as `invalid` and carries on, because one broken profile must not take
|
|
161
|
+
* the whole listing with it — and unlike the authenticating path, listing has nothing to
|
|
162
|
+
* recover, so the row itself is the report.
|
|
163
|
+
*
|
|
164
|
+
* One column answers "can this be used". A file whose JSON parses but whose `type` is
|
|
165
|
+
* unreadable used to show `File: ok` beside `Auth: invalid`, so the column a user scans to find
|
|
166
|
+
* the broken profile pointed at a different row than the one that was broken.
|
|
167
|
+
*/
|
|
168
|
+
const inspectForListing = (at) => {
|
|
169
|
+
const read = inspectCredentials(at.path);
|
|
170
|
+
if (read.kind === "unusable") {
|
|
171
|
+
log.warning(read.reason);
|
|
172
|
+
return { stored: null, auth: "-", file: "invalid" };
|
|
173
|
+
}
|
|
174
|
+
if (read.kind === "absent")
|
|
175
|
+
return { stored: null, auth: "-", file: "missing" };
|
|
176
|
+
const auth = describeAuth(read.credentials, at);
|
|
177
|
+
return {
|
|
178
|
+
stored: read.credentials,
|
|
179
|
+
auth,
|
|
180
|
+
file: auth === "invalid" ? "invalid" : "ok",
|
|
181
|
+
};
|
|
182
|
+
};
|
|
31
183
|
const list = async (props) => {
|
|
184
|
+
// Reading files on disk; a key would authenticate nothing here.
|
|
185
|
+
rejectApiKeyFlag("list");
|
|
186
|
+
// `list` is the one subcommand where `--profile` means something: it marks the active row.
|
|
187
|
+
// It still has to be a profile that exists, or the marker silently lands on nothing.
|
|
32
188
|
const active = selectProfileName(props.profile);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
189
|
+
if (props.profile !== undefined && props.profile.trim() !== "") {
|
|
190
|
+
resolveProfile(props.configDir, active);
|
|
191
|
+
}
|
|
192
|
+
const rows = listProfiles(props.configDir).map((p) => {
|
|
193
|
+
const { stored, auth, file } = inspectForListing({
|
|
194
|
+
path: p.credentialsPath,
|
|
195
|
+
profile: p.name,
|
|
196
|
+
});
|
|
197
|
+
const storedUserId = typeof stored?.user_id === "string" ? stored.user_id : undefined;
|
|
198
|
+
return {
|
|
199
|
+
active: p.name === active ? "*" : "",
|
|
200
|
+
name: p.name,
|
|
201
|
+
account: p.label ?? p.userId ?? storedUserId ?? "-",
|
|
202
|
+
auth,
|
|
203
|
+
// Only a key carries a scope. An OAuth session reaches whatever its account does.
|
|
204
|
+
scope: stored !== null && auth === "api key"
|
|
205
|
+
? describeScope(scopeOf(stored))
|
|
206
|
+
: "-",
|
|
207
|
+
// Names what was actually checked. The column this replaced was called "available",
|
|
208
|
+
// which claims the credential is ready to use — a thing reading a file cannot show,
|
|
209
|
+
// and the wrong answer for the dead key someone runs this to diagnose.
|
|
210
|
+
file,
|
|
211
|
+
// The basename in the table, because `cli-table` neither wraps nor truncates and a
|
|
212
|
+
// real path pushes the row past most terminals. Structured output keeps the path,
|
|
213
|
+
// which is what a script actually wants.
|
|
214
|
+
...(props.output === "table"
|
|
215
|
+
? { credentials: basename(p.credentialsPath) }
|
|
216
|
+
: { credentials: p.credentialsPath }),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
40
219
|
writer(props).end(rows, {
|
|
41
220
|
title: "Profiles",
|
|
42
|
-
fields: [
|
|
221
|
+
fields: [
|
|
222
|
+
"active",
|
|
223
|
+
"name",
|
|
224
|
+
"account",
|
|
225
|
+
"auth",
|
|
226
|
+
"scope",
|
|
227
|
+
"file",
|
|
228
|
+
"credentials",
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The credential a command is about to replace or delete.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately tolerant where {@link readCredentials} is fatal. Using a damaged credential must
|
|
236
|
+
* fail loudly, but *replacing* one must not: `readCredentials` throwing here made the repair its
|
|
237
|
+
* own error message recommends impossible, and left a malformed file unremovable through the
|
|
238
|
+
* CLI. There is nothing to revoke in a file we cannot parse, so this says so and moves on.
|
|
239
|
+
*/
|
|
240
|
+
const readOutgoingCredential = (at) => {
|
|
241
|
+
const read = inspectCredentials(at.path);
|
|
242
|
+
const unusable = (reason) => {
|
|
243
|
+
// `reason` comes from several sources, some of which end in a period. Trim it so the
|
|
244
|
+
// sentence does not run "…cannot be read.. Nothing in it…".
|
|
245
|
+
log.warning("%s. Nothing in it could be revoked, so it is only being replaced locally.", reason.replace(/\.$/, ""));
|
|
246
|
+
return null;
|
|
247
|
+
};
|
|
248
|
+
if (read.kind === "unusable")
|
|
249
|
+
return unusable(read.reason);
|
|
250
|
+
if (read.kind === "absent")
|
|
251
|
+
return null;
|
|
252
|
+
// Interpreted, not merely classified. A file declaring `api_key` with no key satisfies
|
|
253
|
+
// `credentialKind` and would then reach `getApiClient` with `undefined` behind a cast — a
|
|
254
|
+
// revoke request authenticated by nothing, reported as a failed revocation. There is
|
|
255
|
+
// genuinely nothing to revoke here, which is what `unusable` says; and saying it rather
|
|
256
|
+
// than throwing is what keeps the file removable.
|
|
257
|
+
try {
|
|
258
|
+
const credential = interpretCredentials(read.credentials, at);
|
|
259
|
+
if (credential.kind === OAUTH) {
|
|
260
|
+
return { kind: OAUTH, tokens: read.credentials };
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
kind: API_KEY,
|
|
264
|
+
apiKey: credential.apiKey,
|
|
265
|
+
...(typeof read.credentials.key_id === "number"
|
|
266
|
+
? { keyId: read.credentials.key_id }
|
|
267
|
+
: {}),
|
|
268
|
+
scope: scopeOf(read.credentials),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return unusable(err instanceof Error ? err.message : String(err));
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const credentialsPathFor = (configDir, name) => {
|
|
276
|
+
// Metadata we cannot read makes this a guess: `credentials.<name>.json` is a convention,
|
|
277
|
+
// and the entry that would say whether it is this account's file is exactly what is
|
|
278
|
+
// unreadable. Guessing here is what let a replacement overwrite a file and revoke its key
|
|
279
|
+
// before `upsertProfile` refused the same metadata.
|
|
280
|
+
assertProfilesUsable(configDir, name);
|
|
281
|
+
if (readProfiles(configDir)?.profiles[name] || name === DEFAULT_PROFILE) {
|
|
282
|
+
return credentialsPathForName(configDir, name);
|
|
283
|
+
}
|
|
284
|
+
return newProfileCredentialsPath(configDir, name);
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Refuse to overwrite an existing profile unless asked to, and say what `--force` destroys.
|
|
288
|
+
*
|
|
289
|
+
* Naming the consequence is the point. `--force` does not merely point the profile somewhere
|
|
290
|
+
* else: {@link retirePreviousCredential} revokes the credential being replaced, so a key this
|
|
291
|
+
* CLI minted dies upstream — including the copy the user pasted into CI or another machine,
|
|
292
|
+
* which is not recoverable, only re-mintable. A message promising "replace its credential"
|
|
293
|
+
* described a local edit and made the irreversible half a surprise.
|
|
294
|
+
*/
|
|
295
|
+
const assertReplaceable = (props) => {
|
|
296
|
+
const { name, configDir, force } = props;
|
|
297
|
+
if (force)
|
|
298
|
+
return;
|
|
299
|
+
const declared = readProfiles(configDir)?.profiles[name];
|
|
300
|
+
const path = credentialsPathFor(configDir, name);
|
|
301
|
+
if (!declared && !(name === DEFAULT_PROFILE && existsSync(path)))
|
|
302
|
+
return;
|
|
303
|
+
const existing = inspectCredentials(path);
|
|
304
|
+
const stored = existing.kind === "ok" ? existing.credentials : null;
|
|
305
|
+
// Only offer `rotate-key` when it would actually work: it refuses anything that is not
|
|
306
|
+
// already an API-key profile, and `DEFAULT` is an OAuth profile on nearly every install.
|
|
307
|
+
const holdsKey = stored !== null &&
|
|
308
|
+
credentialKind(stored, { path, profile: name }) === API_KEY;
|
|
309
|
+
const keyId = typeof stored?.key_id === "number" ? stored.key_id : undefined;
|
|
310
|
+
if (holdsKey) {
|
|
311
|
+
throw new Error(keyId !== undefined
|
|
312
|
+
? `Profile "${name}" already exists and holds an API key minted here (id ${keyId}). Pass --force to replace it — the key is revoked, wherever else it is in use. To keep the profile and swap the key instead: \`neon profile rotate-key ${name}\`.`
|
|
313
|
+
: `Profile "${name}" already exists and holds an API key you supplied, which stays live on the account either way — only keys minted here record an id to revoke. Pass --force to replace it locally, or \`neon profile rotate-key ${name}\` to mint one at the same scope.`);
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Profile "${name}" already exists and holds a browser sign-in. Pass --force to replace it — the session is signed out as part of the replacement.`);
|
|
316
|
+
};
|
|
317
|
+
/** `--api-key -` means "read it from stdin", the usual convention for a piped value. */
|
|
318
|
+
const STDIN = "-";
|
|
319
|
+
/**
|
|
320
|
+
* The key to store.
|
|
321
|
+
*
|
|
322
|
+
* Read from the *flag* and never from `NEON_API_KEY`. Everywhere else those are
|
|
323
|
+
* interchangeable, but "store this credential permanently" is not something an exported
|
|
324
|
+
* environment variable should be able to answer — a `create` that silently wrote whatever
|
|
325
|
+
* happened to be in the shell would be storing a key the user never named.
|
|
326
|
+
*
|
|
327
|
+
* One flag, deliberately. Earlier revisions added `--api-key-file`, `--api-key-stdin` and
|
|
328
|
+
* `--api-key-prompt` to keep the secret out of argv, which is four surfaces to document and
|
|
329
|
+
* validate against each other for things the shell already does — `--api-key "$(cat file)"`
|
|
330
|
+
* reads a file, `--api-key "$KEY"` takes a variable. Piping is the one case the shell cannot
|
|
331
|
+
* express through an argument, so it gets the usual convention instead of a flag: `-`.
|
|
332
|
+
*/
|
|
333
|
+
const resolveKeyToStore = (props) => {
|
|
334
|
+
const fromFlag = credentialInputs().apiKeyFlag.trim();
|
|
335
|
+
if (fromFlag === STDIN)
|
|
336
|
+
return readKeyFromStdin();
|
|
337
|
+
if (fromFlag !== "")
|
|
338
|
+
return fromFlag;
|
|
339
|
+
throw new Error(`Nothing to store for profile "${props.name}". Pass --api-key (or --api-key - to pipe it), --mint to have one minted, or no flags at all to sign in with the browser.`);
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* Read the key from stdin, for `--api-key -`.
|
|
343
|
+
*
|
|
344
|
+
* A terminal is refused rather than read: `readFileSync(0)` on a tty waits for EOF with no
|
|
345
|
+
* prompt and nothing echoed, which is indistinguishable from a hang — and this spelling exists
|
|
346
|
+
* for pipes, so a terminal means the pipe is missing.
|
|
347
|
+
*/
|
|
348
|
+
const readKeyFromStdin = () => {
|
|
349
|
+
if (process.stdin.isTTY) {
|
|
350
|
+
throw new Error('`--api-key -` reads the key from stdin, but stdin is a terminal. Pipe it in: echo "$KEY" | neon profile create <name> --api-key -');
|
|
351
|
+
}
|
|
352
|
+
const piped = readFileSync(0, "utf8").trim();
|
|
353
|
+
if (piped === "") {
|
|
354
|
+
throw new Error("Nothing arrived on stdin, so there is no API key to store.");
|
|
355
|
+
}
|
|
356
|
+
return piped;
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Confirm a key works and find out whose it is, before it is written anywhere.
|
|
360
|
+
*
|
|
361
|
+
* `getAuthDetails` rather than `GET /users/me`: an organization key gets `404 not allowed for
|
|
362
|
+
* organization API keys` from the latter, so asking would fail a perfectly good key.
|
|
363
|
+
*/
|
|
364
|
+
const verifyKey = async (props, apiKey,
|
|
365
|
+
/** Where the key came from, so a rejection can say what to do about it. */
|
|
366
|
+
origin = "supplied") => {
|
|
367
|
+
const apiClient = getApiClient({ apiKey, apiHost: props.apiHost });
|
|
368
|
+
const { data: details } = await apiClient
|
|
369
|
+
.getAuthDetails()
|
|
370
|
+
.catch((err) => {
|
|
371
|
+
// `profile` skips `ensureAuth`, so nothing recorded how this call authenticated and
|
|
372
|
+
// the top-level handler falls back to "Check --api-key or NEON_API_KEY" — while
|
|
373
|
+
// this command's own help says it ignores `NEON_API_KEY`. An agent reads that and
|
|
374
|
+
// exports the variable to no effect.
|
|
375
|
+
if (isNeonApiError(err) && err.status === 401) {
|
|
376
|
+
throw new Error(origin === "minted"
|
|
377
|
+
? "Neon rejected the key it had just minted. Nothing was stored; the key is being revoked."
|
|
378
|
+
: "The Neon API rejected the key passed to --api-key. Check the value, or use --mint to have one minted for this profile.");
|
|
379
|
+
}
|
|
380
|
+
throw err;
|
|
381
|
+
});
|
|
382
|
+
if (!isApiKeyMethod(details.auth_method)) {
|
|
383
|
+
throw new Error(notAnApiKeyMessage(details.auth_method));
|
|
384
|
+
}
|
|
385
|
+
// Only a user key has a user to look up: `GET /users/me` answers 404 for an organization
|
|
386
|
+
// key, so asking anyway would fail a `create` that is working perfectly.
|
|
387
|
+
const email = details.auth_method === "api_key_user"
|
|
388
|
+
? (await apiClient.getCurrentUserInfo()).data.email
|
|
389
|
+
: undefined;
|
|
390
|
+
return {
|
|
391
|
+
...identityFromAuthDetails(details, email),
|
|
392
|
+
...(details.auth_method === "api_key_org"
|
|
393
|
+
? { orgId: details.account_id }
|
|
394
|
+
: {}),
|
|
395
|
+
apiClient,
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
const create = async (props) => {
|
|
399
|
+
const { name } = props;
|
|
400
|
+
rejectProfileFlag(props, "create");
|
|
401
|
+
assertValidProfileName(name);
|
|
402
|
+
// Before the key is read from stdin, verified against the API, or minted in a browser.
|
|
403
|
+
assertProfilesUsable(props.configDir, name);
|
|
404
|
+
assertReplaceable(props);
|
|
405
|
+
const suppliedKey = credentialInputs().apiKeyFlag.trim() !== "";
|
|
406
|
+
if (props.mint) {
|
|
407
|
+
// Silently preferring one over the other would store a credential the user did not
|
|
408
|
+
// choose — and the two disagree about which account the profile ends up as.
|
|
409
|
+
if (suppliedKey) {
|
|
410
|
+
throw new Error("--mint creates a new key, so it cannot be combined with --api-key. Drop one.");
|
|
411
|
+
}
|
|
412
|
+
await createByMinting(props);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if ((props.orgId ?? props.projectId) !== undefined) {
|
|
416
|
+
throw new Error("--org-id and --project-id only apply with --mint: they choose what a key we mint can reach, and a key you supply already has a scope.");
|
|
417
|
+
}
|
|
418
|
+
// No key and no --mint means a browser sign-in, which is exactly `neon auth --profile`.
|
|
419
|
+
// Delegating rather than reimplementing keeps one OAuth path in the CLI.
|
|
420
|
+
if (!suppliedKey) {
|
|
421
|
+
// The no-flag form is the one an agent reaches for first, and `authFlow` answers it
|
|
422
|
+
// with a bare "Cannot run interactive auth in CI" — true, and no way forward. Say the
|
|
423
|
+
// same thing `--mint` says, since the two ways out are the same.
|
|
424
|
+
if (isCi() && props.forceAuth !== true) {
|
|
425
|
+
throw new Error(`\`neon profile create ${name}\` with no key signs in through the browser, which cannot happen in CI. Pass a key instead: \`neon profile create ${name} --api-key "$KEY"\`, or pipe it with \`echo "$KEY" | neon profile create ${name} --api-key -\`.`);
|
|
426
|
+
}
|
|
427
|
+
const credentialsPath = credentialsPathFor(props.configDir, name);
|
|
428
|
+
const previous = readOutgoingCredential({
|
|
429
|
+
path: credentialsPath,
|
|
430
|
+
profile: name,
|
|
431
|
+
});
|
|
432
|
+
// `authFlow` reports its own failure and returns an empty token rather than throwing,
|
|
433
|
+
// so claiming success here would leave the user with no profile and no error.
|
|
434
|
+
if ((await authFlow({ ...props, _: ["auth"], profile: name })) === "") {
|
|
435
|
+
throw new Error(`Could not save credentials for profile "${name}".`);
|
|
436
|
+
}
|
|
437
|
+
await retirePreviousCredential(props, name, previous);
|
|
438
|
+
const signedIn = readProfiles(props.configDir, log.warning)?.profiles[name];
|
|
439
|
+
report(props, {
|
|
440
|
+
name,
|
|
441
|
+
account: signedIn?.label ?? signedIn?.userId ?? "unknown account",
|
|
442
|
+
auth: "oauth",
|
|
443
|
+
scope: "-",
|
|
444
|
+
credentials: credentialsPath,
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const apiKey = resolveKeyToStore(props);
|
|
449
|
+
const identity = await verifyKey(props, apiKey);
|
|
450
|
+
const credentialsPath = credentialsPathFor(props.configDir, name);
|
|
451
|
+
const previous = readOutgoingCredential({
|
|
452
|
+
path: credentialsPath,
|
|
453
|
+
profile: name,
|
|
454
|
+
});
|
|
455
|
+
writeCredentials(credentialsPath, apiKeyCredentials({
|
|
456
|
+
apiKey,
|
|
457
|
+
...(identity.userId !== undefined
|
|
458
|
+
? { userId: identity.userId }
|
|
459
|
+
: {}),
|
|
460
|
+
// A supplied organization key does not say which project it reaches, but the API
|
|
461
|
+
// does say which organization — recording it keeps `list` from claiming "account",
|
|
462
|
+
// and lets a later rotation refuse on the right grounds.
|
|
463
|
+
...(identity.orgId !== undefined
|
|
464
|
+
? { scope: { orgId: identity.orgId } }
|
|
465
|
+
: {}),
|
|
466
|
+
}));
|
|
467
|
+
await retirePreviousCredential(props, name, previous, apiKey);
|
|
468
|
+
recordProfile(props, name, credentialsPath, identity);
|
|
469
|
+
report(props, {
|
|
470
|
+
name,
|
|
471
|
+
account: identity.label ?? "unknown account",
|
|
472
|
+
auth: "api key",
|
|
473
|
+
scope: describeScope(identity.orgId !== undefined ? { orgId: identity.orgId } : {}),
|
|
474
|
+
credentials: credentialsPath,
|
|
475
|
+
});
|
|
476
|
+
// A key we did not mint has no discoverable id: `GET /api_keys` exposes no prefix, so a
|
|
477
|
+
// stored secret cannot be matched back to a listing entry. A warning rather than a note,
|
|
478
|
+
// because it is the same caveat `api-keys create` raises at that level.
|
|
479
|
+
log.warning("A key you supplied cannot be revoked by `rotate-key` or `remove` — only keys minted here record an id.");
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* What a minted key can reach, in the same words `neon api-keys create` uses for the same key.
|
|
483
|
+
*
|
|
484
|
+
* Saying nothing here would be worse than there: this is the command aimed at agents and shared
|
|
485
|
+
* machines, which is exactly where an over-broad credential does the most damage.
|
|
486
|
+
*/
|
|
487
|
+
const warnAboutReach = (scope) => {
|
|
488
|
+
if (scope.projectId !== undefined) {
|
|
489
|
+
log.info("Limited to %s: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.", scope.projectId);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (scope.orgId !== undefined) {
|
|
493
|
+
log.warning("This key reaches every project in %s, including ones created later. Pass --project-id instead to restrict it to one.", scope.orgId);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
log.warning("This key reaches everything your account can, in every organization. Pass --org-id or --project-id to narrow it.");
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* Report a profile that was just written.
|
|
500
|
+
*
|
|
501
|
+
* Through the writer rather than `log`, so `--output json` yields the record an agent needs
|
|
502
|
+
* instead of nothing — it had to follow up with `profile list` to learn what it just created.
|
|
503
|
+
*/
|
|
504
|
+
const report = (props, record) => {
|
|
505
|
+
const out = writer(props);
|
|
506
|
+
if (props.output === "table") {
|
|
507
|
+
log.info('Profile "%s" now holds %s for %s (%s), in %s', record.name, record.auth, record.account, record.scope, record.credentials);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
out.end(record, {
|
|
511
|
+
fields: [
|
|
512
|
+
"name",
|
|
513
|
+
"account",
|
|
514
|
+
"auth",
|
|
515
|
+
"scope",
|
|
516
|
+
...(record.keyId !== undefined ? ["keyId"] : []),
|
|
517
|
+
"credentials",
|
|
518
|
+
],
|
|
519
|
+
title: "Profile",
|
|
520
|
+
});
|
|
521
|
+
};
|
|
522
|
+
const recordProfile = (props, name, credentialsPath, identity) => {
|
|
523
|
+
upsertProfile(props.configDir, name, {
|
|
524
|
+
credentials: credentialsPath,
|
|
525
|
+
...(identity.label ? { label: identity.label } : {}),
|
|
526
|
+
...(identity.userId ? { userId: identity.userId } : {}),
|
|
43
527
|
});
|
|
44
528
|
};
|
|
529
|
+
/**
|
|
530
|
+
* Sign in once, mint a key, keep only the key.
|
|
531
|
+
*
|
|
532
|
+
* This is the reason to prefer a key-backed profile at all: after it, nothing about the profile
|
|
533
|
+
* can trigger a browser. The OAuth session is a means to an end and is revoked at the end of
|
|
534
|
+
* it, so no half-forgotten login is left behind — which also means the file holds exactly one
|
|
535
|
+
* credential, for exactly one account.
|
|
536
|
+
*/
|
|
537
|
+
const createByMinting = async (props) => {
|
|
538
|
+
const { name } = props;
|
|
539
|
+
// `authFlow` refuses to open a browser in CI; minting calls `auth` directly and so has to
|
|
540
|
+
// make the same check itself, or this would sit waiting for a login nobody can complete.
|
|
541
|
+
if (isCi() && props.forceAuth !== true) {
|
|
542
|
+
throw new Error(`--mint needs a browser sign-in, which cannot happen in CI. Mint the key with \`neon api-keys create --name ${name}\` and pipe it in: echo "$KEY" | neon profile create ${name} --api-key -`);
|
|
543
|
+
}
|
|
544
|
+
const oauthProps = {
|
|
545
|
+
oauthHost: props.oauthHost,
|
|
546
|
+
clientId: props.clientId,
|
|
547
|
+
...(props.allowUnsafeTls
|
|
548
|
+
? { allowUnsafeTls: props.allowUnsafeTls }
|
|
549
|
+
: {}),
|
|
550
|
+
};
|
|
551
|
+
// Sign in before anything else that needs an API call. `profile` commands skip
|
|
552
|
+
// `ensureAuth`, so this invocation has no API client of its own — the session minted here
|
|
553
|
+
// is the only credential available, and `--project-id` needs one to find the owning org.
|
|
554
|
+
const tokenSet = await auth(oauthProps);
|
|
555
|
+
const session = getApiClient({
|
|
556
|
+
apiKey: tokenSet.access_token ?? "",
|
|
557
|
+
apiHost: props.apiHost,
|
|
558
|
+
});
|
|
559
|
+
// Whatever happens after this point, the session was a means to an end. Revoking it in a
|
|
560
|
+
// `finally` is what makes "stores only the key" true even when the command fails partway.
|
|
561
|
+
let minted;
|
|
562
|
+
// The scope actually minted at, not the flags: `--project-id` resolves an organization that
|
|
563
|
+
// the revoke endpoint needs, and rebuilding it from flags would withdraw a project key
|
|
564
|
+
// through the account endpoint and leave it live.
|
|
565
|
+
let mintedScope = {};
|
|
566
|
+
let keyIsReachable = false;
|
|
567
|
+
try {
|
|
568
|
+
const scope = await resolveMintScope(props, session);
|
|
569
|
+
mintedScope = scope;
|
|
570
|
+
minted = await mintKey(session, name, scope);
|
|
571
|
+
const identity = await verifyKey(props, minted.key, "minted");
|
|
572
|
+
const credentialsPath = credentialsPathFor(props.configDir, name);
|
|
573
|
+
const previous = readOutgoingCredential({
|
|
574
|
+
path: credentialsPath,
|
|
575
|
+
profile: name,
|
|
576
|
+
});
|
|
577
|
+
writeCredentials(credentialsPath, apiKeyCredentials({
|
|
578
|
+
apiKey: minted.key,
|
|
579
|
+
keyId: minted.id,
|
|
580
|
+
...(identity.userId !== undefined
|
|
581
|
+
? { userId: identity.userId }
|
|
582
|
+
: {}),
|
|
583
|
+
scope,
|
|
584
|
+
}));
|
|
585
|
+
// From here the key is on disk and usable, so cleanup must not take it away — a
|
|
586
|
+
// failure writing `profiles.json` costs a profile entry, not the credential.
|
|
587
|
+
keyIsReachable = true;
|
|
588
|
+
await retirePreviousCredential(props, name, previous, minted.key);
|
|
589
|
+
recordProfile(props, name, credentialsPath, identity);
|
|
590
|
+
report(props, {
|
|
591
|
+
name,
|
|
592
|
+
account: identity.label ?? "unknown account",
|
|
593
|
+
auth: "api key",
|
|
594
|
+
scope: describeScope(scope),
|
|
595
|
+
keyId: minted.id,
|
|
596
|
+
credentials: credentialsPath,
|
|
597
|
+
});
|
|
598
|
+
warnAboutReach(scope);
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
// A key minted but never written is unreachable, so it must not be left live.
|
|
602
|
+
if (minted !== undefined && !keyIsReachable) {
|
|
603
|
+
log.info((await withdrawKey(session, mintedScope, minted.id))
|
|
604
|
+
? `Revoked the key that was minted but not stored (id ${minted.id}).`
|
|
605
|
+
: `Minted key ${minted.id} could be neither stored nor revoked, and may still be live. Remove it with: neon api-keys revoke ${minted.id}${mintedScope.orgId ? ` --org-id ${mintedScope.orgId}` : ""}`);
|
|
606
|
+
}
|
|
607
|
+
const revoked = await revokeToken(oauthProps, tokenSet);
|
|
608
|
+
log.info(revoked
|
|
609
|
+
? "Signed the browser session back out, so nothing but the key remains."
|
|
610
|
+
: "Could not sign the browser session back out; it will expire on its own.");
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* The scope to mint at, resolving a project to the organization that owns it.
|
|
615
|
+
*
|
|
616
|
+
* A project-scoped key exists only on the organization endpoint, so the org has to be looked up
|
|
617
|
+
* rather than asked for — `--project-id` alone would otherwise fail for a reason invisible from
|
|
618
|
+
* the command line.
|
|
619
|
+
*/
|
|
620
|
+
const resolveMintScope = async (props, client) => {
|
|
621
|
+
if (props.projectId === undefined) {
|
|
622
|
+
return props.orgId !== undefined ? { orgId: props.orgId } : {};
|
|
623
|
+
}
|
|
624
|
+
// `api-keys create`'s lookup, not a second one. It already answers the mistake this flag
|
|
625
|
+
// invites — an organization id typed into the project slot — and a local copy meant the
|
|
626
|
+
// same typo got a written explanation there and a raw 404 here.
|
|
627
|
+
return {
|
|
628
|
+
orgId: await orgIdForProject(client, props.projectId),
|
|
629
|
+
projectId: props.projectId,
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
/**
|
|
633
|
+
* Revoke a credential a profile has just stopped using.
|
|
634
|
+
*
|
|
635
|
+
* Called with the credential read *before* the overwrite, and only *after* the replacement is
|
|
636
|
+
* durable. Both halves matter. Reading it first is the only chance: a minted key's `key_id` and
|
|
637
|
+
* an OAuth refresh token are gone the moment the file is rewritten, so nothing could revoke
|
|
638
|
+
* them afterwards. Revoking last is what stops a cancelled sign-in or a failed write from
|
|
639
|
+
* leaving the profile holding a credential that has already been killed.
|
|
640
|
+
*/
|
|
641
|
+
const retirePreviousCredential = async (props, name, existing,
|
|
642
|
+
/** The key now stored. Retiring this would kill the credential we just committed to. */
|
|
643
|
+
replacementKey) => {
|
|
644
|
+
if (existing === null)
|
|
645
|
+
return;
|
|
646
|
+
if (existing.kind === API_KEY) {
|
|
647
|
+
// Re-storing the key a profile already holds is a no-op, not a replacement. Revoking
|
|
648
|
+
// here would leave the profile pointing at a credential this command just committed to.
|
|
649
|
+
if (isSameCredential(existing.apiKey, replacementKey)) {
|
|
650
|
+
log.debug("The replacement is the credential already stored; nothing to retire.");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (existing.keyId === undefined) {
|
|
654
|
+
log.warning('Profile "%s" held an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.', name);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const client = getApiClient({
|
|
658
|
+
apiKey: existing.apiKey,
|
|
659
|
+
apiHost: props.apiHost,
|
|
660
|
+
});
|
|
661
|
+
log.info((await withdrawKey(client, existing.scope, existing.keyId))
|
|
662
|
+
? `Revoked the key it replaces (id ${existing.keyId})`
|
|
663
|
+
: `Could not revoke the key it replaces (id ${existing.keyId}); it may still be live. Remove it with: neon api-keys revoke ${existing.keyId}`);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const revoked = await revokeTokenSet(existing.tokens, props);
|
|
667
|
+
log.info(revoked
|
|
668
|
+
? "Signed out the session it replaced"
|
|
669
|
+
: "Could not sign out the session it replaced; it will expire on its own");
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* Mint a key at the given scope and refuse anything that came back different.
|
|
673
|
+
*
|
|
674
|
+
* A 2xx is not enough. A response with no `key` leaves a live credential the user can never
|
|
675
|
+
* see, and a `project_id` that does not match would mean storing a credential whose reach we
|
|
676
|
+
* announced wrongly. Both are withdrawn before throwing.
|
|
677
|
+
*/
|
|
678
|
+
const mintKey = async (client, profile, scope) => {
|
|
679
|
+
const keyName = mintedKeyName(profile);
|
|
680
|
+
const { data } = scope.orgId
|
|
681
|
+
? await client.createOrgApiKey(scope.orgId, {
|
|
682
|
+
key_name: keyName,
|
|
683
|
+
...(scope.projectId ? { project_id: scope.projectId } : {}),
|
|
684
|
+
})
|
|
685
|
+
: await client.createApiKey({ key_name: keyName });
|
|
686
|
+
// Only the organization endpoint's response carries `project_id`, so narrow rather than
|
|
687
|
+
// assert: an account key legitimately has no such field, and a *missing* one is exactly
|
|
688
|
+
// what "unscoped" looks like.
|
|
689
|
+
const issuedProject = "project_id" in data && typeof data.project_id === "string"
|
|
690
|
+
? data.project_id
|
|
691
|
+
: undefined;
|
|
692
|
+
const problem = typeof data.key !== "string" || data.key.trim() === ""
|
|
693
|
+
? "Neon returned no key."
|
|
694
|
+
: issuedProject !== scope.projectId
|
|
695
|
+
? `Neon returned a key scoped to ${issuedProject ?? "nothing"} rather than ${scope.projectId ?? "the whole organization"}.`
|
|
696
|
+
: null;
|
|
697
|
+
if (problem === null) {
|
|
698
|
+
return { id: data.id, key: data.key, name: data.name };
|
|
699
|
+
}
|
|
700
|
+
const withdrawn = await withdrawKey(client, scope, data.id);
|
|
701
|
+
throw new Error(`${problem} ${withdrawn
|
|
702
|
+
? "The key has been revoked; nothing was stored."
|
|
703
|
+
: `The key could NOT be revoked and may still be live. Remove it with \`neon api-keys revoke ${data.id}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}\`.`}`);
|
|
704
|
+
};
|
|
705
|
+
/** Best-effort withdrawal of a key we are refusing to store. Never throws. */
|
|
706
|
+
const withdrawKey = async (client, scope, keyId) => {
|
|
707
|
+
if (!Number.isSafeInteger(keyId) || keyId <= 0)
|
|
708
|
+
return false;
|
|
709
|
+
try {
|
|
710
|
+
const { data } = scope.orgId
|
|
711
|
+
? await client.revokeOrgApiKey(scope.orgId, keyId)
|
|
712
|
+
: await client.revokeApiKey(keyId);
|
|
713
|
+
// Check which key the response names: a `revoked: true` for some other id is not
|
|
714
|
+
// evidence that the one we issued is gone.
|
|
715
|
+
return data.revoked === true && data.id === keyId;
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
const rotateKey = async (props) => {
|
|
723
|
+
const { name } = props;
|
|
724
|
+
rejectProfileFlag(props, "rotate-key");
|
|
725
|
+
rejectApiKeyFlag("rotate-key", `To store a key you already have, use \`neon profile create ${name} --api-key - --force\`.`);
|
|
726
|
+
// Resolve first: an unknown profile must fail having minted nothing.
|
|
727
|
+
const profile = resolveProfile(props.configDir, name);
|
|
728
|
+
const at = { path: profile.credentialsPath, profile: name };
|
|
729
|
+
const credential = await usableCredential(props, at);
|
|
730
|
+
if (credential === null) {
|
|
731
|
+
throw new Error(`Profile "${name}" has no usable credential to mint with. Replace it with \`neon profile create ${name} --mint --force\`.`);
|
|
732
|
+
}
|
|
733
|
+
// Rotating a *key* is what this command is for. An OAuth profile would otherwise be
|
|
734
|
+
// converted into a key profile as a side effect, discarding a session it never revoked.
|
|
735
|
+
if (credential.kind !== API_KEY) {
|
|
736
|
+
throw new Error(`Profile "${name}" holds a browser sign-in, not an API key, so there is no key to rotate. Turn it into a key profile with \`neon profile create ${name} --mint --force\`.`);
|
|
737
|
+
}
|
|
738
|
+
const stored = readCredentials(at);
|
|
739
|
+
const scope = stored !== null ? scopeOf(stored) : {};
|
|
740
|
+
const previousKeyId = typeof stored?.key_id === "number" ? stored.key_id : undefined;
|
|
741
|
+
const minting = getApiClient({
|
|
742
|
+
apiKey: credential.apiKey,
|
|
743
|
+
apiHost: props.apiHost,
|
|
744
|
+
});
|
|
745
|
+
// `profile` commands skip `ensureAuth`, so nothing has recorded how this call authenticated.
|
|
746
|
+
// Without it a 401 here advises checking `--api-key` or `NEON_API_KEY`, neither of which the
|
|
747
|
+
// user passed.
|
|
748
|
+
setAuthContext({
|
|
749
|
+
source: "profile-api-key",
|
|
750
|
+
configDir: props.configDir,
|
|
751
|
+
profile: name,
|
|
752
|
+
credentialsPath: profile.credentialsPath,
|
|
753
|
+
});
|
|
754
|
+
// Neon only lets a *personal* credential mint organization keys, so an organization key
|
|
755
|
+
// cannot mint its own replacement. Asked of the credential itself rather than of the
|
|
756
|
+
// recorded scope: a key that was supplied rather than minted has no scope on record, and
|
|
757
|
+
// would otherwise sail past this and get "This endpoint requires a personal API key" from
|
|
758
|
+
// a command where the user named nothing but a profile.
|
|
759
|
+
const { data: details } = await minting.getAuthDetails();
|
|
760
|
+
if (details.auth_method === "api_key_org") {
|
|
761
|
+
// Only advise a scope we know. `api_key_org` covers organization *and* project keys, so
|
|
762
|
+
// for a key we did not mint — which records no scope — suggesting `--org-id` could hand
|
|
763
|
+
// the profile more reach than it had.
|
|
764
|
+
const advice = scope.projectId !== undefined
|
|
765
|
+
? `\`neon profile create ${name} --mint --project-id ${scope.projectId} --force\``
|
|
766
|
+
: scope.orgId !== undefined && previousKeyId !== undefined
|
|
767
|
+
? `\`neon profile create ${name} --mint --org-id ${scope.orgId} --force\``
|
|
768
|
+
: `\`neon profile create ${name} --mint --org-id ${details.account_id} --force\` — but check \`neon api-keys list --org-id ${details.account_id}\` first, because a key you supplied may have been narrowed to a single project and an organization key would reach more`;
|
|
769
|
+
throw new Error(`Profile "${name}" holds an organization key, and only a personal credential can mint organization keys — so it cannot mint its own replacement. Sign in and mint one with ${advice}.`);
|
|
770
|
+
}
|
|
771
|
+
// Mint at the recorded scope. Minting an account key to replace an org or project one
|
|
772
|
+
// would quietly widen everything the profile can reach.
|
|
773
|
+
const created = await mintKey(minting, name, scope);
|
|
774
|
+
log.info("Minted %s (id %d, %s)", created.name, created.id, describeScope(scope));
|
|
775
|
+
// Store before revoking. If the write fails the old key is still valid, so the profile
|
|
776
|
+
// keeps working — and the new key is named in the error rather than silently orphaned on
|
|
777
|
+
// the account with no way to find it again.
|
|
778
|
+
try {
|
|
779
|
+
writeCredentials(profile.credentialsPath, apiKeyCredentials({
|
|
780
|
+
apiKey: created.key,
|
|
781
|
+
keyId: created.id,
|
|
782
|
+
...(typeof stored?.user_id === "string"
|
|
783
|
+
? { userId: stored.user_id }
|
|
784
|
+
: {}),
|
|
785
|
+
scope,
|
|
786
|
+
}));
|
|
787
|
+
}
|
|
788
|
+
catch (err) {
|
|
789
|
+
log.error("Minted key %d but could not write %s. Revoke it with: neon api-keys revoke %d%s", created.id, profile.credentialsPath, created.id, scope.orgId ? ` --org-id ${scope.orgId}` : "");
|
|
790
|
+
throw err;
|
|
791
|
+
}
|
|
792
|
+
report(props, {
|
|
793
|
+
name,
|
|
794
|
+
account: profile.label ?? profile.userId ?? "unknown account",
|
|
795
|
+
auth: "api key",
|
|
796
|
+
scope: describeScope(scope),
|
|
797
|
+
keyId: created.id,
|
|
798
|
+
credentials: profile.credentialsPath,
|
|
799
|
+
});
|
|
800
|
+
if (previousKeyId === undefined) {
|
|
801
|
+
log.warning("No previous key id was recorded, so the key this replaces is still live. It was supplied rather than minted here, and `GET /api_keys` exposes no prefix to match it by — find it with `neon api-keys list`.");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// Revoke with the new key. The old one may be exactly what stopped working, which is the
|
|
805
|
+
// usual reason to rotate in the first place.
|
|
806
|
+
const rotated = getApiClient({
|
|
807
|
+
apiKey: created.key,
|
|
808
|
+
apiHost: props.apiHost,
|
|
809
|
+
});
|
|
810
|
+
if (await withdrawKey(rotated, scope, previousKeyId)) {
|
|
811
|
+
log.info("Revoked the previous key (id %d)", previousKeyId);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
log.warning("Stored the new key, but could not revoke the previous one (id %d) — it may still be live. Revoke it with: neon api-keys revoke %d%s", previousKeyId, previousKeyId, scope.orgId ? ` --org-id ${scope.orgId}` : "");
|
|
815
|
+
};
|
|
45
816
|
const remove = async (props) => {
|
|
46
817
|
const { name } = props;
|
|
818
|
+
rejectProfileFlag(props, "remove");
|
|
819
|
+
rejectApiKeyFlag("remove", "It revokes the credential stored for that profile, which is the only one it can revoke.");
|
|
47
820
|
// Resolve before touching anything: an unknown name must fail having deleted nothing.
|
|
48
821
|
const profile = resolveProfile(props.configDir, name);
|
|
49
822
|
if (!props.yes) {
|
|
50
|
-
|
|
51
|
-
|
|
823
|
+
// Both halves, as every other prompt in the CLI checks. `isCi()` alone left the shape
|
|
824
|
+
// a pipeline actually produces — stdin held by a pipe, `CI` unset — where `prompts`
|
|
825
|
+
// draws a question nobody can answer: with stdin open it waits for input that never
|
|
826
|
+
// comes, and at EOF it resolves to nothing, the event loop drains, and the process
|
|
827
|
+
// exits 0 having removed nothing. Exiting 0 is the worse half: an agent reads success.
|
|
828
|
+
if (isCi() || !process.stdin.isTTY) {
|
|
829
|
+
throw new Error("Refusing to remove a profile without confirmation when stdin is not a terminal. Pass --yes.");
|
|
52
830
|
}
|
|
53
831
|
const who = profile.label ?? profile.userId ?? "unknown account";
|
|
54
832
|
const { ok } = await prompts({
|
|
@@ -57,17 +835,40 @@ const remove = async (props) => {
|
|
|
57
835
|
message: `Remove profile "${name}" (${who})?`,
|
|
58
836
|
initial: false,
|
|
59
837
|
});
|
|
838
|
+
// Non-zero, because nothing was removed. A caller that scripts this reads the exit
|
|
839
|
+
// code, and answering "no" is not the same outcome as removing the profile.
|
|
60
840
|
if (!ok) {
|
|
61
|
-
|
|
62
|
-
return;
|
|
841
|
+
throw new Error(`Cancelled — profile "${name}" was not removed.`);
|
|
63
842
|
}
|
|
64
843
|
}
|
|
65
|
-
// 1. Revoke upstream, so the
|
|
66
|
-
// Best-effort: a profile is often removed precisely because its
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
844
|
+
// 1. Revoke upstream where we can, so the credential dies rather than merely becoming
|
|
845
|
+
// unreachable by us. Best-effort: a profile is often removed precisely because its
|
|
846
|
+
// access already broke.
|
|
847
|
+
const at = { path: profile.credentialsPath, profile: name };
|
|
848
|
+
const stored = readOutgoingCredential(at);
|
|
849
|
+
if (stored?.kind === API_KEY) {
|
|
850
|
+
const { keyId, scope } = stored;
|
|
851
|
+
if (keyId === undefined) {
|
|
852
|
+
// A key we did not mint has no id we can match it by, so it outlives its profile.
|
|
853
|
+
// Deleting the file quietly would imply the credential had been destroyed.
|
|
854
|
+
log.warning('Profile "%s" holds an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.', name);
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
const client = getApiClient({
|
|
858
|
+
apiKey: stored.apiKey,
|
|
859
|
+
apiHost: props.apiHost,
|
|
860
|
+
});
|
|
861
|
+
log.info((await withdrawKey(client, scope, keyId))
|
|
862
|
+
? `Revoked the API key (id ${keyId})`
|
|
863
|
+
: `Could not revoke the API key (id ${keyId}) — it may still be live. Remove it with: neon api-keys revoke ${keyId}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else if (stored !== null) {
|
|
867
|
+
const revoked = await revokeTokenSet(stored.tokens, props);
|
|
868
|
+
log.info(revoked
|
|
869
|
+
? "Revoked the OAuth token"
|
|
870
|
+
: "Could not revoke the OAuth token — removing locally anyway");
|
|
871
|
+
}
|
|
71
872
|
// 2. Delete the credentials file only if we created it. A profile pointing outside the
|
|
72
873
|
// config directory was adopted from elsewhere; unlink it and say so, because the
|
|
73
874
|
// secret is still on disk and silence would imply otherwise.
|
|
@@ -83,7 +884,7 @@ const remove = async (props) => {
|
|
|
83
884
|
// 3. Drop the entry, and the file once nothing but DEFAULT is left — the mirror image
|
|
84
885
|
// of creating it lazily, so a single-account install ends up with no profiles.json.
|
|
85
886
|
const path = profilesFilePath(props.configDir);
|
|
86
|
-
const file = readProfiles(props.configDir);
|
|
887
|
+
const file = readProfiles(props.configDir, log.warning);
|
|
87
888
|
if (file?.profiles[name]) {
|
|
88
889
|
delete file.profiles[name];
|
|
89
890
|
if (onlyDefaultRemains(file)) {
|
|
@@ -91,9 +892,7 @@ const remove = async (props) => {
|
|
|
91
892
|
log.info('Removed "%s" — no profiles left, deleted %s', name, path);
|
|
92
893
|
}
|
|
93
894
|
else {
|
|
94
|
-
|
|
95
|
-
mode: 0o600,
|
|
96
|
-
});
|
|
895
|
+
writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
97
896
|
log.info('Removed "%s" from %s', name, path);
|
|
98
897
|
}
|
|
99
898
|
}
|
|
@@ -101,16 +900,9 @@ const remove = async (props) => {
|
|
|
101
900
|
log.info("Signed out of DEFAULT");
|
|
102
901
|
}
|
|
103
902
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let tokenSet;
|
|
108
|
-
try {
|
|
109
|
-
tokenSet = JSON.parse(readFileSync(credentialsPath, "utf8"));
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
903
|
+
/** Revoke the OAuth refresh token in a credential we already have in hand. */
|
|
904
|
+
const revokeTokenSet = async (credentials, props) => {
|
|
905
|
+
const tokenSet = credentials;
|
|
114
906
|
return await revokeToken({
|
|
115
907
|
oauthHost: props.oauthHost,
|
|
116
908
|
clientId: props.clientId,
|
|
@@ -119,5 +911,3 @@ const revokeStoredToken = async (credentialsPath, props) => {
|
|
|
119
911
|
: {}),
|
|
120
912
|
}, tokenSet);
|
|
121
913
|
};
|
|
122
|
-
/** Whether a credentials file is one the CLI created, rather than an adopted path. */
|
|
123
|
-
const isInsideConfigDir = (configDir, file) => `${resolve(file)}/`.startsWith(`${resolve(configDir)}/`);
|