neon 2.42.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 +181 -12
- 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/api.js +98 -10
- package/dist/auth_context.js +53 -8
- package/dist/commands/api_keys.js +349 -0
- package/dist/commands/auth.js +131 -59
- package/dist/commands/bootstrap.js +16 -3
- package/dist/commands/index.js +2 -0
- package/dist/commands/init.js +17 -0
- package/dist/commands/profile.js +831 -41
- package/dist/config.js +1 -22
- package/dist/context.js +19 -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 +13 -13
package/README.md
CHANGED
|
@@ -644,12 +644,12 @@ The CLI holds one Neon account by default. A profile adds another, and is nothin
|
|
|
644
644
|
```
|
|
645
645
|
~/.config/neon/
|
|
646
646
|
├── credentials.json # this IS the DEFAULT profile
|
|
647
|
-
├── credentials.work.json # created by `neon
|
|
647
|
+
├── credentials.work.json # created by `neon profile create work`
|
|
648
648
|
└── profiles.json # created only once a second profile exists
|
|
649
649
|
```
|
|
650
650
|
|
|
651
651
|
```bash
|
|
652
|
-
neon
|
|
652
|
+
neon profile create work # a browser sign-in, or an API key — see below
|
|
653
653
|
neon profile list
|
|
654
654
|
neon profile remove work
|
|
655
655
|
```
|
|
@@ -657,14 +657,21 @@ neon profile remove work
|
|
|
657
657
|
```console
|
|
658
658
|
$ neon profile list
|
|
659
659
|
Profiles
|
|
660
|
-
|
|
661
|
-
│ Active │ Name │ Account
|
|
662
|
-
|
|
663
|
-
│ * │ DEFAULT │ me@example.com
|
|
664
|
-
|
|
665
|
-
│ │ work │ me@
|
|
666
|
-
|
|
667
|
-
|
|
660
|
+
┌────────┬─────────┬───────────────────────┬─────────┬────────────────┬──────┬────────────────────────┐
|
|
661
|
+
│ Active │ Name │ Account │ Auth │ Scope │ File │ Credentials │
|
|
662
|
+
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
|
|
663
|
+
│ * │ DEFAULT │ me@example.com │ oauth │ - │ ok │ credentials.json │
|
|
664
|
+
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
|
|
665
|
+
│ │ work │ me@example.com │ api key │ account │ ok │ credentials.work.json │
|
|
666
|
+
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
|
|
667
|
+
│ │ ci │ org-old-flower-827148 │ api key │ project proj-1 │ ok │ credentials.ci.json │
|
|
668
|
+
└────────┴─────────┴───────────────────────┴─────────┴────────────────┴──────┴────────────────────────┘
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
`Scope` is what a key can reach; an OAuth session has none of its own, so it shows `-`. `File`
|
|
672
|
+
says whether the credentials file can be read and understood — `ok`, `invalid` or `missing` —
|
|
673
|
+
which is not the same as the credential still working; only using it shows that. The table shows
|
|
674
|
+
the file name, `--output json` the full path.
|
|
668
675
|
|
|
669
676
|
Select one per invocation with `--profile`, or per shell with `NEON_PROFILE`. There is no `profile use` command and nothing is stored about which profile is "current", so what you type is always what runs.
|
|
670
677
|
|
|
@@ -680,14 +687,176 @@ Entries in `profiles.json` are paths, and a path may point anywhere — which is
|
|
|
680
687
|
}
|
|
681
688
|
```
|
|
682
689
|
|
|
683
|
-
`neon profile remove` revokes
|
|
690
|
+
`neon profile remove` revokes what the profile holds — an OAuth refresh token at the
|
|
691
|
+
authorization server, or an API key this CLI minted — rather than only forgetting it locally. A
|
|
692
|
+
key you supplied is the exception and stays live, because nothing records its id; the command
|
|
693
|
+
says so. It asks for confirmation first, which `--yes` skips; without a terminal on stdin, in
|
|
694
|
+
CI or behind a pipe, it refuses rather than prompting into the void. It deletes the credentials
|
|
695
|
+
file only when the CLI created it: an adopted path like the one above is unlinked and left on
|
|
696
|
+
disk, and the command says so. Removing the last named profile deletes `profiles.json`,
|
|
697
|
+
returning you to the single-account layout. `neon profile remove DEFAULT` signs you out.
|
|
698
|
+
|
|
699
|
+
### A profile holds either a sign-in or an API key
|
|
700
|
+
|
|
701
|
+
`neon profile create` makes a profile, and how you call it decides which kind of credential it holds. A key-backed profile is what you want for an agent, a shared machine, or anything that must never be interrupted by a browser:
|
|
702
|
+
|
|
703
|
+
```bash
|
|
704
|
+
neon profile create work # sign in with the browser, like `neon auth`
|
|
705
|
+
neon profile create work --api-key "$KEY" # store a key you already have
|
|
706
|
+
echo "$KEY" | neon profile create work --api-key - # or pipe it, keeping it out of argv
|
|
707
|
+
neon profile create ci --mint # sign in once, keep only a minted key
|
|
708
|
+
neon profile create ci --mint --org-id org-abc-123 # minted for an organization
|
|
709
|
+
neon profile create ci --mint --project-id proj-1 # minted for one project only
|
|
710
|
+
neon profile create work --force # replace it, revoking what it holds now
|
|
711
|
+
neon profile rotate-key work # mint a replacement, revoke the old one
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
`--force` is not only a local edit: replacing a profile revokes the credential it held, so a key
|
|
715
|
+
this CLI minted stops working everywhere it was pasted, and an OAuth session is signed out.
|
|
716
|
+
Without `--force`, `create` refuses and names what would be revoked. To keep a working profile
|
|
717
|
+
and swap only its key, use `rotate-key`.
|
|
718
|
+
|
|
719
|
+
`create` and `rotate-key` print the profile they wrote, so an agent needn't follow up with
|
|
720
|
+
`list`. Under `--output json` that is a record, and it never carries the secret:
|
|
721
|
+
|
|
722
|
+
```console
|
|
723
|
+
$ neon profile create ci --mint --org-id org-abc-123 --output json
|
|
724
|
+
{"name":"ci","account":"org-abc-123","auth":"api key","scope":"org org-abc-123","keyId":3239771,"credentials":"/home/me/.config/neon/credentials.ci.json"}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
One flag takes the key, because the shell already covers the variations: `--api-key "$(cat
|
|
728
|
+
~/keys/work)"` reads a file and `--api-key "$KEY"` takes a variable. Those put the key in the
|
|
729
|
+
process arguments, where `ps` and shell history can see it, so `--api-key -` reads it from stdin
|
|
730
|
+
instead — the usual convention for a piped value. `--mint` avoids the question entirely, because
|
|
731
|
+
the key never leaves the CLI.
|
|
732
|
+
|
|
733
|
+
**A profile is one kind or the other, never both.** `type` in the credentials file states which:
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
// oauth: what a plain `create` (or `neon auth --profile`) writes. An absent `type` means this.
|
|
737
|
+
{ "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
|
|
738
|
+
|
|
739
|
+
// api_key: what `--api-key` writes
|
|
740
|
+
{ "type": "api_key", "api_key": "napi_…", "user_id": "…" }
|
|
741
|
+
|
|
742
|
+
// api_key from `--mint --org-id`, which records the scope it was issued at
|
|
743
|
+
{ "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Nothing is carried over when a profile is replaced, so one profile can never hold two credentials — or two different accounts. The secret stays in that file and never goes into `profiles.json`, so listing profiles cannot leak one. Both files are written owner-only through a temporary file and a rename, which also repairs the permissions of a file created too permissively.
|
|
747
|
+
|
|
748
|
+
`--mint` is the one to reach for. It signs you in through the browser once, mints a key with that session, stores only the key, and signs the session back out — so afterwards nothing about the profile can open a browser, and no half-forgotten login is left behind. `--org-id` and `--project-id` narrow what the minted key can reach, exactly as they do on [`neon api-keys create`](#api-keys); a project-scoped key cannot create projects, mint keys, or read any other project.
|
|
749
|
+
|
|
750
|
+
Every key is verified against the API before it is stored, and the account it belongs to is recorded so `profile list` can show it. Only a real API key is accepted: an OAuth access token authenticates today and then expires with nothing to refresh it.
|
|
751
|
+
|
|
752
|
+
`rotate-key` mints at the scope the profile already has — replacing an org key with an account key would quietly widen everything it reaches — and stores the new key before revoking the old one, so a failed write leaves the old key working.
|
|
753
|
+
|
|
754
|
+
One thing it cannot do: **an organization key cannot mint its own replacement.** Neon only accepts a personal credential when creating organization keys, so rotating an org- or project-scoped profile means signing in again — `neon profile create ci --mint --org-id org-abc-123 --force`. `rotate-key` checks this before minting and says so, rather than letting the API answer with a rule you had no reason to expect.
|
|
755
|
+
|
|
756
|
+
Two things the CLI cannot do for a key you supplied rather than minted. It cannot revoke it, because `GET /api_keys` exposes no prefix and a stored secret cannot be matched to a listing entry, so both `rotate-key` and `profile remove` say the old key is still live and point you at `neon api-keys list`. For a key you supplied it records the organization the API reports, but cannot know whether that key was narrowed to a single project — so `rotate-key` will not suggest an organization-wide replacement without telling you to check `neon api-keys list` first.
|
|
757
|
+
|
|
758
|
+
If a stored key stops working there is nothing to refresh, so recovery is one browser sign-in: `neon profile create work --mint --force`.
|
|
759
|
+
|
|
760
|
+
### Which credential an invocation uses
|
|
761
|
+
|
|
762
|
+
An explicit flag always beats an environment variable:
|
|
763
|
+
|
|
764
|
+
| Given | What runs |
|
|
765
|
+
| --- | --- |
|
|
766
|
+
| `--api-key` and `--profile` | neither — contradictory, so the command fails |
|
|
767
|
+
| `--api-key` and `NEON_PROFILE` | the flag's key |
|
|
768
|
+
| `--profile` and `NEON_API_KEY` | the profile |
|
|
769
|
+
| `NEON_API_KEY` and `NEON_PROFILE` | the key, and the ignored profile is named in a warning |
|
|
770
|
+
| `--profile` or `NEON_PROFILE` alone | that profile |
|
|
771
|
+
| nothing | `DEFAULT` |
|
|
772
|
+
|
|
773
|
+
Passing both flags fails rather than picking a winner: `--api-key` supplies a credential and `--profile` selects a stored one, so there is no reading of the command that makes both true.
|
|
774
|
+
|
|
775
|
+
When both are only environment variables the key wins, which keeps a CI pipeline that injects `NEON_API_KEY` working even if a `NEON_PROFILE` leaks into the environment — but the disregarded profile is named on stderr rather than passed over silently.
|
|
776
|
+
|
|
777
|
+
`neon auth` and the `profile` subcommands are outside all of this, because they read the same flags to mean something else: `neon auth --profile work` names where to write a credential, and `neon profile create work --api-key …` names one to store.
|
|
778
|
+
|
|
779
|
+
`neon init` does not support `--profile` yet. It hands its whole auth flow to `neon-init`, which reads the default credentials directly, so passing the flag fails instead of quietly running as the default account.
|
|
780
|
+
|
|
781
|
+
## API keys (`api-keys`)
|
|
782
|
+
|
|
783
|
+
```bash
|
|
784
|
+
neon api-keys list # your account's keys
|
|
785
|
+
neon api-keys list --org-id org-… # an organization's, with scope shown
|
|
786
|
+
|
|
787
|
+
neon api-keys create --name ci # account key
|
|
788
|
+
neon api-keys create --name ci --org-id org-… # organization key
|
|
789
|
+
neon api-keys create --name agent --project-id frosty-… # can access only that project
|
|
790
|
+
|
|
791
|
+
neon api-keys revoke <id> [--org-id org-…]
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
The key is returned once, on create, and cannot be retrieved again. It prints on its own line below the table, so it can be selected in one gesture regardless of terminal width — and `… | tail -1` on stdout yields exactly the key, since both notices go to stderr.
|
|
795
|
+
|
|
796
|
+
```console
|
|
797
|
+
$ neon api-keys create --name agent --project-id proj-in-org
|
|
798
|
+
API key
|
|
799
|
+
┌─────┬───────┬─────────────┐
|
|
800
|
+
│ Id │ Name │ Project │
|
|
801
|
+
├─────┼───────┼─────────────┤
|
|
802
|
+
│ 303 │ agent │ proj-in-org │
|
|
803
|
+
└─────┴───────┴─────────────┘
|
|
804
|
+
|
|
805
|
+
napi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
806
|
+
WARNING: Store this key now: it is not shown again.
|
|
807
|
+
INFO: Limited to proj-in-org: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
`--org-id` and `--project-id` are mutually exclusive. A project-scoped key *is* an organization key, and its organization is looked up from the project rather than chosen separately. With neither flag you get an account key.
|
|
811
|
+
|
|
812
|
+
### Project-scoped keys
|
|
813
|
+
|
|
814
|
+
A key created with `--project-id` bounds what it can reach to one project. Verified against a real scoped key:
|
|
815
|
+
|
|
816
|
+
| Attempt | Result |
|
|
817
|
+
| --- | --- |
|
|
818
|
+
| Read and write its own project | works |
|
|
819
|
+
| Read any other project | `project not found` — not even an existence oracle |
|
|
820
|
+
| `neon projects create` | `project-scoped keys are not allowed to create projects` |
|
|
821
|
+
| `neon projects list` | refused |
|
|
822
|
+
| `neon api-keys create` / `list` | refused (true of any organization key, not only scoped ones) |
|
|
823
|
+
| `neon orgs list` | **works** — it can see the id, name and handle of the organization it belongs to |
|
|
824
|
+
| Anything else about that organization (`GET /organizations/{id}`, members) | refused |
|
|
825
|
+
|
|
826
|
+
It is **not** read-only. Inside its one project it can do everything the API allows, including deleting branches and the project itself — `neon deploy` working at all is proof of that. What it bounds is *reach*, which is what lets you hand it to an agent or a CI job without handing over your account:
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
neon link --project-id frosty-… # once, as yourself — writes .neon
|
|
830
|
+
NEON_API_KEY=napi_… neon deploy # then the agent, reaching only that project
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
`neon link` needs `--project-id` explicitly when using a scoped key: the interactive picker lists your projects, which a scoped key cannot do.
|
|
834
|
+
|
|
835
|
+
`api-keys` deliberately ignores the `.neon` context file, unlike every other project command. Otherwise `neon api-keys create --name ci` inside a linked directory would silently mint a key scoped to that project instead of the account key you asked for. How far a credential reaches comes only from a flag you typed.
|
|
836
|
+
|
|
837
|
+
### Seeing what is scoped
|
|
838
|
+
|
|
839
|
+
```console
|
|
840
|
+
$ neon api-keys list --org-id org-7
|
|
841
|
+
API keys in org-7
|
|
842
|
+
┌─────┬──────────┬────────────────┬──────────────────────┬──────────────────────┬─────────────────────┐
|
|
843
|
+
│ Id │ Name │ Project │ Created At │ Last Used At │ Last Used From Addr │
|
|
844
|
+
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
|
|
845
|
+
│ 301 │ scoped │ proj-in-org │ 2026-01-02T00:00:00Z │ │ │
|
|
846
|
+
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
|
|
847
|
+
│ 302 │ org-wide │ (all projects) │ 2026-01-03T00:00:00Z │ 2026-02-03T00:00:00Z │ 203.0.113.9 │
|
|
848
|
+
└─────┴──────────┴────────────────┴──────────────────────┴──────────────────────┴─────────────────────┘
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
`last_used_at` and `last_used_from_addr` are how you spot a key worth revoking.
|
|
684
852
|
|
|
685
853
|
## Commands
|
|
686
854
|
|
|
687
855
|
| Command | Subcommands | Description |
|
|
688
856
|
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
|
689
857
|
| [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
|
|
690
|
-
| profile | `list`, `remove`
|
|
858
|
+
| profile | `list`, `create`, `rotate-key`, `remove` | Manage named sets of credentials |
|
|
859
|
+
| api-keys | `list`, `create`, `revoke` | Manage API keys |
|
|
691
860
|
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
|
|
692
861
|
| [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
|
|
693
862
|
| [me](https://neon.com/docs/reference/cli-me) | | Show current user |
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Which credential an invocation authenticates with
|
|
3
|
+
*
|
|
4
|
+
* Four inputs can each answer "who am I": the `--api-key` flag, `NEON_API_KEY`, the
|
|
5
|
+
* `--profile` flag, and `NEON_PROFILE`. This module decides between them, and it is pure so
|
|
6
|
+
* the decision can be tested without a filesystem, a network, or a config directory.
|
|
7
|
+
*
|
|
8
|
+
* ## The rule
|
|
9
|
+
*
|
|
10
|
+
* **An explicit flag beats an ambient environment variable.** That single rule fixes the bug
|
|
11
|
+
* this module exists for: before it, any API key — including one merely exported into the
|
|
12
|
+
* shell — silently voided `--profile`, so `neon --profile work …` would quietly run as
|
|
13
|
+
* whoever `NEON_API_KEY` belonged to and say nothing about it.
|
|
14
|
+
*
|
|
15
|
+
* | Given | What runs |
|
|
16
|
+
* | --- | --- |
|
|
17
|
+
* | `--api-key` and `--profile` | neither: contradictory explicit flags, so this throws |
|
|
18
|
+
* | `--api-key` and `NEON_PROFILE` | the flag's key |
|
|
19
|
+
* | `--profile` and `NEON_API_KEY` | the profile |
|
|
20
|
+
* | `NEON_API_KEY` and `NEON_PROFILE` | the key, and the ignored profile is named in a warning |
|
|
21
|
+
* | `--profile` or `NEON_PROFILE` alone | that profile |
|
|
22
|
+
* | nothing | `DEFAULT` |
|
|
23
|
+
*
|
|
24
|
+
* Two explicit flags throw rather than picking a winner. They express different intents —
|
|
25
|
+
* `--api-key` supplies a credential, `--profile` selects a stored one — so there is no
|
|
26
|
+
* reading of the command that makes both true, and guessing is how the original bug behaved.
|
|
27
|
+
*
|
|
28
|
+
* When both are merely ambient, the key wins. That keeps CI exactly as it was: a pipeline
|
|
29
|
+
* that injects `NEON_API_KEY` must not change behaviour because a `NEON_PROFILE` leaked into
|
|
30
|
+
* the environment. It warns instead of staying silent, because a disregarded account
|
|
31
|
+
* selection is precisely what nobody noticed last time.
|
|
32
|
+
*
|
|
33
|
+
* `auth` and the `profile` subcommands do not use any of this. They read the same flags with
|
|
34
|
+
* different meanings — `neon auth --profile work` names where to *write* a credential, and
|
|
35
|
+
* `neon profile create work --api-key …` names one to *store* — so their callers skip
|
|
36
|
+
* selection entirely rather than passing exemptions down here.
|
|
37
|
+
*/
|
|
38
|
+
import { DEFAULT_PROFILE } from "./profiles.js";
|
|
39
|
+
const NO_INPUTS = {
|
|
40
|
+
apiKeyFlag: "",
|
|
41
|
+
apiKeyEnv: "",
|
|
42
|
+
profileEnv: "",
|
|
43
|
+
};
|
|
44
|
+
let inputs = NO_INPUTS;
|
|
45
|
+
export const recordCredentialInputs = (recorded) => {
|
|
46
|
+
inputs = recorded;
|
|
47
|
+
};
|
|
48
|
+
export const credentialInputs = () => inputs;
|
|
49
|
+
export const selectCredential = ({ apiKeyFlag, profileFlag, apiKeyEnv, profileEnv, }) => {
|
|
50
|
+
const flagKey = nonEmpty(apiKeyFlag);
|
|
51
|
+
const flagProfile = nonEmpty(profileFlag);
|
|
52
|
+
if (flagKey !== undefined && flagProfile !== undefined) {
|
|
53
|
+
throw new Error("Pass either --api-key or --profile, not both. --api-key supplies a credential directly; --profile selects a stored one.");
|
|
54
|
+
}
|
|
55
|
+
if (flagKey !== undefined) {
|
|
56
|
+
return { source: "explicit-api-key", apiKey: flagKey };
|
|
57
|
+
}
|
|
58
|
+
if (flagProfile !== undefined) {
|
|
59
|
+
return { source: "profile", profile: flagProfile, explicit: true };
|
|
60
|
+
}
|
|
61
|
+
const envKey = nonEmpty(apiKeyEnv);
|
|
62
|
+
const envProfile = nonEmpty(profileEnv);
|
|
63
|
+
if (envKey !== undefined) {
|
|
64
|
+
return {
|
|
65
|
+
source: "ambient-api-key",
|
|
66
|
+
apiKey: envKey,
|
|
67
|
+
...(envProfile !== undefined ? { ignoredProfile: envProfile } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
source: "profile",
|
|
72
|
+
profile: envProfile ?? DEFAULT_PROFILE,
|
|
73
|
+
explicit: envProfile !== undefined,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/** The warning for an ambient key that displaced an ambient profile, or `null`. */
|
|
77
|
+
export const displacedProfileWarning = (selection) => selection.source === "ambient-api-key" &&
|
|
78
|
+
selection.ignoredProfile !== undefined
|
|
79
|
+
? `NEON_API_KEY is set, so profile "${selection.ignoredProfile}" from NEON_PROFILE was ignored. Pass --profile ${selection.ignoredProfile} to use it instead.`
|
|
80
|
+
: null;
|
|
81
|
+
function nonEmpty(value) {
|
|
82
|
+
if (typeof value !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
return trimmed === "" ? undefined : trimmed;
|
|
86
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Stored credentials — one file per account, two kinds
|
|
3
|
+
*
|
|
4
|
+
* A profile points at exactly one credentials file (see `./profiles.ts`), and that file says
|
|
5
|
+
* what kind of credential it holds. Adding API-key support this way rather than adding a
|
|
6
|
+
* second pointer to `profiles.json` keeps a profile what it already was — one name, one path
|
|
7
|
+
* — and means `profiles.json` needs no schema change at all.
|
|
8
|
+
*
|
|
9
|
+
* ```json
|
|
10
|
+
* // oauth: every file written before this existed. An absent `type` means this.
|
|
11
|
+
* { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
|
|
12
|
+
*
|
|
13
|
+
* // api_key, stored by `neon profile create --api-key`
|
|
14
|
+
* { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
|
|
15
|
+
*
|
|
16
|
+
* // api_key minted by `--mint --org-id`, which records the scope it was issued at
|
|
17
|
+
* { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## One profile, one kind
|
|
21
|
+
*
|
|
22
|
+
* A credentials file holds an API key or an OAuth session, never both, and `type` states
|
|
23
|
+
* which. An earlier draft let the two coexist — the idea being that a key could keep the
|
|
24
|
+
* session it was minted from and so rotate without a browser. It did not survive review, for
|
|
25
|
+
* two reasons that are worth recording so nobody rebuilds it:
|
|
26
|
+
*
|
|
27
|
+
* 1. **It never worked.** The resolver returned the key without testing it, so a revoked key
|
|
28
|
+
* failed to mint and never fell back to the session sitting beside it.
|
|
29
|
+
* 2. **It could mix accounts.** Nothing compared the identity of the credential being written
|
|
30
|
+
* with the one already there, so a profile could hold one account's session and another's
|
|
31
|
+
* key, told apart only by a single string. Flip or lose `type` and the profile silently
|
|
32
|
+
* becomes a different person.
|
|
33
|
+
*
|
|
34
|
+
* Recovery from a dead key is therefore one browser login — `neon profile create <name>
|
|
35
|
+
* --mint --force` — which is what the retained session was supposed to save and never did.
|
|
36
|
+
*
|
|
37
|
+
* ## Older releases
|
|
38
|
+
*
|
|
39
|
+
* A CLI predating this reads the pointer, finds no `type` it understands, ignores it, and
|
|
40
|
+
* looks for `access_token`. An `api_key` profile has none, so an older release falls through
|
|
41
|
+
* to its browser login rather than crashing. That it does not crash is why `credentials`
|
|
42
|
+
* stays a required pointer: an entry without one makes 2.41 and 2.42 throw
|
|
43
|
+
* `ERR_INVALID_ARG_TYPE` from `resolveEntryPath`.
|
|
44
|
+
*/
|
|
45
|
+
import { readFileSync } from "node:fs";
|
|
46
|
+
import { writeSecretFile } from "./secure_file.js";
|
|
47
|
+
export const OAUTH = "oauth";
|
|
48
|
+
export const API_KEY = "api_key";
|
|
49
|
+
/**
|
|
50
|
+
* Which credential in this file authenticates, by declaration alone.
|
|
51
|
+
*
|
|
52
|
+
* An unrecognised `type` throws rather than falling back to `oauth`. A file we cannot
|
|
53
|
+
* interpret is a misconfiguration the user has to see: treating it as OAuth would send them
|
|
54
|
+
* to a browser login that silently replaces a credential they meant to keep, and treating it
|
|
55
|
+
* as an API key would authenticate with whatever `api_key` happened to be there.
|
|
56
|
+
*
|
|
57
|
+
* This deliberately does not check that an `api_key` file has a key — `neon profile list`
|
|
58
|
+
* needs the kind of a file it is not about to authenticate with, and must be able to report a
|
|
59
|
+
* broken one rather than throwing halfway through a table.
|
|
60
|
+
*/
|
|
61
|
+
export const credentialKind = (credentials, at) => {
|
|
62
|
+
const declared = credentials.type;
|
|
63
|
+
if (declared === undefined || declared === OAUTH)
|
|
64
|
+
return OAUTH;
|
|
65
|
+
if (declared === API_KEY)
|
|
66
|
+
return API_KEY;
|
|
67
|
+
// The value is not quoted back. Everything in this file is secret material, and a
|
|
68
|
+
// corrupted or hand-edited file can put a key anywhere in it — including here. Naming the
|
|
69
|
+
// file is enough to act on, and it cannot leak what the file holds.
|
|
70
|
+
throw new Error(`${at.path} declares a "type" this version does not understand. Expected "${OAUTH}" or "${API_KEY}". ${repair(at)}`);
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The way out of a credentials file that cannot be read.
|
|
74
|
+
*
|
|
75
|
+
* One sentence, shared by every such error, because they all have the same two answers: write
|
|
76
|
+
* a new credential over it, or delete it and start again.
|
|
77
|
+
*/
|
|
78
|
+
const repair = (at) => `Replace it deliberately with \`neon profile create ${at.profile} --force\`, or delete the file.`;
|
|
79
|
+
/**
|
|
80
|
+
* Resolve what to authenticate with, validating that the declared kind is actually usable.
|
|
81
|
+
*
|
|
82
|
+
* An `api_key` file with no key is a hard error rather than a fall-through to OAuth: the user
|
|
83
|
+
* asked for a key, and quietly opening a browser instead would replace the credential they
|
|
84
|
+
* were trying to fix.
|
|
85
|
+
*/
|
|
86
|
+
export const interpretCredentials = (credentials, at) => {
|
|
87
|
+
if (credentialKind(credentials, at) === OAUTH)
|
|
88
|
+
return { kind: OAUTH };
|
|
89
|
+
const apiKey = nonEmpty(credentials.api_key);
|
|
90
|
+
if (apiKey === undefined) {
|
|
91
|
+
throw new Error(`${at.path} declares "type": "${API_KEY}" but has no "api_key" value. ${repair(at)}`);
|
|
92
|
+
}
|
|
93
|
+
return { kind: API_KEY, apiKey };
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Read and classify a credentials file, without deciding what to do about it.
|
|
97
|
+
*
|
|
98
|
+
* A permission or I/O error still throws: there may be a perfectly good credential here that
|
|
99
|
+
* we cannot see, and treating that as absent would send the user to a browser login that
|
|
100
|
+
* overwrites it.
|
|
101
|
+
*/
|
|
102
|
+
export const inspectCredentials = (path) => {
|
|
103
|
+
let contents;
|
|
104
|
+
try {
|
|
105
|
+
contents = readFileSync(path, "utf8");
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err.code === "ENOENT")
|
|
109
|
+
return { kind: "absent" };
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(contents);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// The parser's message is deliberately discarded. V8 quotes a window of the input
|
|
118
|
+
// around the syntax error — on Node 24 a truncated credentials file produced
|
|
119
|
+
// `Unexpected token 'a', ..."api_key":napi_SUPERS"... is not valid JSON` — and this
|
|
120
|
+
// reason is printed by `profile list` and by every failed authentication. A malformed
|
|
121
|
+
// secret file is exactly when a diagnostic must say less, not more.
|
|
122
|
+
return {
|
|
123
|
+
kind: "unusable",
|
|
124
|
+
reason: `${path} is not valid JSON, so the credential in it cannot be read`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (parsed === null ||
|
|
128
|
+
typeof parsed !== "object" ||
|
|
129
|
+
Array.isArray(parsed)) {
|
|
130
|
+
return {
|
|
131
|
+
kind: "unusable",
|
|
132
|
+
reason: `${path} does not contain a credentials object`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { kind: "ok", credentials: parsed };
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* The credential at `path`, or `null` when the file is not there.
|
|
139
|
+
*
|
|
140
|
+
* A damaged file is an error, not an absence. Treating it as absent — which is what this used to
|
|
141
|
+
* do — meant any read-only command could repair it by starting a browser sign-in and overwriting
|
|
142
|
+
* it, **possibly as a different account**, with the user never having asked for a repair and no
|
|
143
|
+
* way back to whatever was in the file. Failing here costs one deliberate command; the message
|
|
144
|
+
* names it.
|
|
145
|
+
*
|
|
146
|
+
* `profile list` and telemetry use {@link inspectCredentials} instead, because describing a
|
|
147
|
+
* broken credential is not the same as using one.
|
|
148
|
+
*/
|
|
149
|
+
export const readCredentials = (at) => {
|
|
150
|
+
const read = inspectCredentials(at.path);
|
|
151
|
+
if (read.kind === "unusable") {
|
|
152
|
+
throw new Error(`${read.reason}. ${repair(at)}`);
|
|
153
|
+
}
|
|
154
|
+
return read.kind === "ok" ? read.credentials : null;
|
|
155
|
+
};
|
|
156
|
+
export const writeCredentials = (path, credentials) => {
|
|
157
|
+
writeSecretFile(path, JSON.stringify(credentials));
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Build an `api_key` credentials object. Nothing from a previous credential is carried over.
|
|
161
|
+
*
|
|
162
|
+
* The scope is stored because it is not recoverable from the secret: `rotate-key` has to mint
|
|
163
|
+
* the replacement on the same endpoint, and an org or project key minted as an account key
|
|
164
|
+
* would silently widen what the profile reaches.
|
|
165
|
+
*/
|
|
166
|
+
export const apiKeyCredentials = ({ apiKey, keyId, userId, scope, }) => ({
|
|
167
|
+
type: API_KEY,
|
|
168
|
+
api_key: apiKey,
|
|
169
|
+
...(keyId !== undefined ? { key_id: keyId } : {}),
|
|
170
|
+
...(userId !== undefined ? { user_id: userId } : {}),
|
|
171
|
+
...(scope?.orgId !== undefined ? { org_id: scope.orgId } : {}),
|
|
172
|
+
...(scope?.projectId !== undefined ? { project_id: scope.projectId } : {}),
|
|
173
|
+
});
|
|
174
|
+
/** The scope recorded on a stored credential. */
|
|
175
|
+
export const scopeOf = (credentials) => ({
|
|
176
|
+
...(typeof credentials.org_id === "string"
|
|
177
|
+
? { orgId: credentials.org_id }
|
|
178
|
+
: {}),
|
|
179
|
+
...(typeof credentials.project_id === "string"
|
|
180
|
+
? { projectId: credentials.project_id }
|
|
181
|
+
: {}),
|
|
182
|
+
});
|
|
183
|
+
/** How to describe a scope in output. */
|
|
184
|
+
export const describeScope = (scope) => {
|
|
185
|
+
if (scope.projectId !== undefined)
|
|
186
|
+
return `project ${scope.projectId}`;
|
|
187
|
+
if (scope.orgId !== undefined)
|
|
188
|
+
return `org ${scope.orgId}`;
|
|
189
|
+
return "account";
|
|
190
|
+
};
|
|
191
|
+
function nonEmpty(value) {
|
|
192
|
+
if (typeof value !== "string")
|
|
193
|
+
return undefined;
|
|
194
|
+
const trimmed = value.trim();
|
|
195
|
+
return trimmed === "" ? undefined : trimmed;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether a stored credential is the same secret as the one about to replace it.
|
|
199
|
+
*
|
|
200
|
+
* Re-storing the key a profile already holds is a no-op, not a replacement — and retiring it
|
|
201
|
+
* would revoke the credential the command has just committed to. Trimmed on both sides, because
|
|
202
|
+
* a key read from a file or a pipe arrives with a trailing newline.
|
|
203
|
+
*/
|
|
204
|
+
export const isSameCredential = (existingKey, replacementKey) => {
|
|
205
|
+
if (existingKey === undefined || replacementKey === undefined)
|
|
206
|
+
return false;
|
|
207
|
+
const trimmed = existingKey.trim();
|
|
208
|
+
return trimmed !== "" && trimmed === replacementKey.trim();
|
|
209
|
+
};
|