Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<profile>` 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/<profile>` 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).

Expand Down
7 changes: 5 additions & 2 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 31 additions & 11 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
13 changes: 8 additions & 5 deletions internal/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down
40 changes: 20 additions & 20 deletions internal/cmd/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading