diff --git a/README.md b/README.md index 84c6c20..f785284 100644 --- a/README.md +++ b/README.md @@ -784,20 +784,24 @@ jc users list --org staging # One-off profile override ### Authentication Methods -**API Key** (default): +**Service Account (OAuth 2.0) — recommended**: ```bash -jc auth login # Interactive, stores in OS keychain -export JC_API_KEY=your-key # Or set via environment +jc auth login --service-account # Interactive client ID + secret entry ``` -**Service Account (OAuth 2.0)**: +Service accounts issue short-lived bearer tokens that refresh automatically against an upstream identity. They are easier to rotate, revoke, and scope than personal API keys, so this is the recommended path for new deployments. The setup wizard (`jc setup`) presents service accounts as the default option. + +**API Key** (alternative; legacy): ```bash -jc auth login --service-account # Interactive client ID + secret entry +jc auth login # Interactive, stores in OS keychain +export JC_API_KEY=your-key # Or set via environment ``` -API keys are stored in the OS keychain (macOS Keychain / Linux secret-tool) by default. The config file stores only a `keychain://jc/` reference, never the plaintext key. If the keychain is unavailable, login will fail rather than silently storing credentials as plaintext — use `--allow-plaintext` to explicitly opt in to config file storage. +API keys are long-lived bearer secrets. They still work for backwards compatibility, but new deployments should prefer service accounts. + +Both methods store credentials in the OS keychain (macOS Keychain / Linux secret-tool) by default. The config file stores only a `keychain://jc/` reference, never the plaintext credential. If the keychain is unavailable, login fails rather than silently storing credentials as plaintext — pass `--allow-plaintext` to explicitly opt in to file storage. **Plaintext storage is a security risk** (backups, sync clients, and other process readers all recover the credential); prefer fixing the keychain. For the full authentication and authorization model — credential storage, MCP server trust model, audit log shape and redaction, threat model, and what jc does *not* enforce — see [`docs/AUTH.md`](docs/AUTH.md). diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 0bc33f8..8323a11 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -11,10 +11,13 @@ chmod +x jc && sudo mv jc /usr/local/bin/ git clone https://github.com/TheJumpCloud/jc-cli.git && cd jc-cli && make install # Run the setup wizard (walks you through auth, org, output prefs) +# The wizard recommends service-account (OAuth 2.0) auth — short-lived +# tokens, easy to rotate. API key auth is the fallback. jc setup -# Or authenticate manually -jc auth login +# Or authenticate manually: +jc auth login --service-account # recommended for new deployments +jc auth login # API key (legacy, still works) ``` Verify: `jc users list -t` should show your org's users. diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 67b2754..76af220 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -90,7 +90,7 @@ func newAuthCmd() *cobra.Command { Long: "Login, logout, and check authentication status for JumpCloud.", } - cmd.PersistentFlags().Bool("allow-plaintext", false, "Allow storing credentials as plaintext in config when keychain is unavailable") + cmd.PersistentFlags().Bool("allow-plaintext", false, "Allow storing credentials as plaintext in the config file when the OS keychain is unavailable. SECURITY RISK: anyone reading ~/.config/jc/config.yaml (backups, sync clients, malware) recovers the credential. Prefer fixing the keychain or running on a host where it works.") cmd.AddCommand(newAuthLoginCmd()) cmd.AddCommand(newAuthStatusCmd()) @@ -107,16 +107,20 @@ func newAuthLoginCmd() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Authenticate with JumpCloud", - Long: `Authenticate with JumpCloud by providing an API key or service account credentials. + Long: `Authenticate with JumpCloud by providing service account credentials or an API key. -For API key authentication (default): - The API key is validated, stored in the OS keychain, and a reference is saved - to the config file. - -For service account authentication (--service-account): +Recommended — service account (--service-account): Prompts for client ID and client secret (OAuth 2.0 client credentials). - The client secret is stored in the OS keychain. A bearer token is obtained - from the OAuth token endpoint and used for subsequent API calls.`, + The client secret is stored in the OS keychain. A short-lived bearer + token is obtained from the OAuth token endpoint and refreshed + automatically. Service accounts are easier to rotate, revoke, and + scope than personal API keys, so this is the recommended path for new + deployments. + +Alternative — API key (default for backwards compatibility): + The API key is validated, stored in the OS keychain, and a reference + is saved to the config file. API keys are long-lived bearer secrets; + prefer service account auth for production use.`, RunE: func(cmd *cobra.Command, args []string) error { if serviceAccountFlag { return runAuthLoginServiceAccount(cmd, profileFlag, defaultInput) @@ -151,6 +155,14 @@ func runAuthLogin(cmd *cobra.Command, profileFlag string, input InputReader) err return fmt.Errorf("auth login requires interactive input. Remove --non-interactive or set JC_API_KEY") } + // One-line nudge toward the recommended path. Operators who explicitly + // want API key auth see it once and continue; new operators learn + // service account exists. Printed to stderr so it doesn't pollute any + // stdout-piping setup. + fmt.Fprintln(cmd.ErrOrStderr(), + "Tip: service account auth (jc auth login --service-account) is recommended over personal API keys. "+ + "See docs/AUTH.md for details.") + // Prompt for API key with masked input. fmt.Fprint(cmd.ErrOrStderr(), "Enter JumpCloud API key: ") apiKey, err := input.ReadAPIKey() @@ -193,7 +205,11 @@ func runAuthLogin(cmd *cobra.Command, profileFlag string, input InputReader) err if err := config.SetProfileField(profile, "api_key", apiKey); err != nil { return fmt.Errorf("failed to save API key to config: %w", err) } - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: API key stored as plaintext in config file\n") + fmt.Fprintf(cmd.ErrOrStderr(), + "Warning: API key stored as plaintext in %s. "+ + "Anyone with read access to that file recovers the credential. "+ + "Fix your keychain setup and re-run 'jc auth login' as soon as possible.\n", + config.ConfigPath()) } else { // Write keychain reference to config. ref := keychain.URI(profile) @@ -205,7 +221,11 @@ func runAuthLogin(cmd *cobra.Command, profileFlag string, input InputReader) err if !allowPlaintext { return fmt.Errorf("OS keychain unavailable. Use --allow-plaintext to store credentials in config file, or fix your keychain setup") } - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: OS keychain unavailable. Storing API key as plaintext in config\n") + fmt.Fprintf(cmd.ErrOrStderr(), + "Warning: OS keychain unavailable. Storing API key as plaintext in %s. "+ + "Anyone with read access to that file recovers the credential. "+ + "Fix your keychain setup and re-run 'jc auth login' as soon as possible.\n", + config.ConfigPath()) if err := config.SetProfileField(profile, "api_key", apiKey); err != nil { return fmt.Errorf("failed to save API key to config: %w", err) } diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 0678209..9ca9901 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -189,10 +189,13 @@ func (wiz *setupWizard) stepAuth(profile string) (*api.Organization, error) { } } - // Choose auth method. + // Choose auth method. Service Account is presented first and as the + // default because it issues short-lived bearer tokens that refresh + // against a service-account identity — easier to rotate, revoke, and + // scope than a personal API key. fmt.Fprintln(wiz.w, "Authentication method:") - fmt.Fprintln(wiz.w, " 1) API Key") - fmt.Fprintln(wiz.w, " 2) Service Account (OAuth 2.0)") + fmt.Fprintln(wiz.w, " 1) Service Account (OAuth 2.0) — recommended") + fmt.Fprintln(wiz.w, " 2) API Key") fmt.Fprintf(wiz.w, "Select [1]: ") choice, err := wiz.input.ReadLine() @@ -202,9 +205,9 @@ func (wiz *setupWizard) stepAuth(profile string) (*api.Organization, error) { choice = strings.TrimSpace(choice) if choice == "" || choice == "1" { - return wiz.authAPIKey(profile) - } else if choice == "2" { return wiz.authServiceAccount(profile) + } else if choice == "2" { + return wiz.authAPIKey(profile) } return nil, fmt.Errorf("invalid auth method selection: %s", choice) } diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index e21f4c8..3e297e4 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -65,13 +65,13 @@ profiles: // Wizard steps: // 1. Profile: Enter (keep "default") - // 2. Auth method: "1" (API key) + // 2. Auth method: "2" (API key — option 2 since the menu now leads with Service Account) // 3. Org ID: Enter (keep auto-detected) // 4. Output format: "table" // 5. Color: Enter (keep yes) // 6. Limit: "50" input := &wizardInput{ - lines: []string{"", "1", "", "table", "", "50"}, + lines: []string{"", "2", "", "table", "", "50"}, masked: []string{"test-setup-key-1234"}, } overrideSetupInput(t, input) @@ -179,9 +179,9 @@ profiles: defer ts.Close() overrideAPIClient(t, ts.URL) - // Steps: profile(keep), keep-creds(no), auth-method(1), org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(keep), keep-creds(no), auth-method(2 → API Key), org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "n", "1", "", "", "", ""}, + lines: []string{"", "n", "2", "", "", "", ""}, masked: []string{"new-api-key-9999"}, } overrideSetupInput(t, input) @@ -232,9 +232,9 @@ profiles: overrideOAuthURL(t, oauthURL) overrideOAuthClient(t, jcURL) - // Steps: profile(keep), auth-method(2), org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(keep), auth-method(1 → Service Account, the new default), org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "2", "test-sa-client-id", "", "", "", ""}, + lines: []string{"", "1", "test-sa-client-id", "", "", "", ""}, masked: []string{"test-sa-secret"}, } overrideSetupInput(t, input) @@ -279,9 +279,9 @@ profiles: defer ts.Close() overrideAPIClient(t, ts.URL) - // Steps: profile(keep), auth-method(1) + // Steps: profile(keep), auth-method(2 → API Key) input := &wizardInput{ - lines: []string{"", "1"}, + lines: []string{"", "2"}, masked: []string{"bad-key-invalid"}, } overrideSetupInput(t, input) @@ -321,9 +321,9 @@ profiles: defer ts.Close() overrideAPIClient(t, ts.URL) - // Steps: profile(production), auth-method(1), org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(production), auth-method(2 → API Key), org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"production", "1", "", "", "", ""}, + lines: []string{"production", "2", "", "", "", ""}, masked: []string{"production-key-1234"}, } overrideSetupInput(t, input) @@ -408,9 +408,9 @@ profiles: defer ts.Close() overrideAPIClient(t, ts.URL) - // Steps: profile(keep), auth-method(1), org("custom-org-999"), output(keep), color(keep), limit(keep) + // Steps: profile(keep), auth-method(2 → API Key), org("custom-org-999"), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "1", "custom-org-999", "", "", ""}, + lines: []string{"", "2", "custom-org-999", "", "", ""}, masked: []string{"custom-org-key"}, } overrideSetupInput(t, input) @@ -493,9 +493,9 @@ profiles: defer ts.Close() overrideAPIClient(t, ts.URL) - // Steps: profile(keep), [validation fails], auth-method(1), org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(keep), [validation fails], auth-method(2 → API Key), org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "1", "", "", "", ""}, + lines: []string{"", "2", "", "", "", ""}, masked: []string{"fresh-key-1234"}, } overrideSetupInput(t, input) @@ -622,9 +622,9 @@ profiles: t.Cleanup(func() { keychainIsAvailable = orig }) keychainIsAvailable = func() bool { return false } - // Steps: profile(keep), auth-method(1) + // Steps: profile(keep), auth-method(2 → API Key) input := &wizardInput{ - lines: []string{"", "1"}, + lines: []string{"", "2"}, masked: []string{"test-setup-key-1234"}, } overrideSetupInput(t, input) @@ -673,9 +673,9 @@ profiles: t.Cleanup(func() { keychainIsAvailable = orig }) keychainIsAvailable = func() bool { return false } - // Steps: profile(keep), auth-method(1), org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(keep), auth-method(2 → API Key), org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "1", "", "", "", ""}, + lines: []string{"", "2", "", "", "", ""}, masked: []string{"test-setup-key-1234"}, } overrideSetupInput(t, input) @@ -737,9 +737,9 @@ profiles: defer jcServer.Close() overrideOAuthClient(t, jcServer.URL) - // Steps: profile(keep), auth-method(2), client-id, org(keep), output(keep), color(keep), limit(keep) + // Steps: profile(keep), auth-method(1 → Service Account, the new default), client-id, org(keep), output(keep), color(keep), limit(keep) input := &wizardInput{ - lines: []string{"", "2", "test-sa-client-id", "", "", "", ""}, + lines: []string{"", "1", "test-sa-client-id", "", "", "", ""}, masked: []string{"test-sa-secret"}, } overrideSetupInput(t, input)