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
55 changes: 32 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ The CLI can be configured with a JSON file at `~/.config/kognic/environments.jso
"auth_server": "https://auth.app.kognic.com",
"credentials": "~/.config/kognic/credentials-prod.json"
},
"demo": {
"host": "demo.kognic.com",
"auth_server": "https://auth.demo.kognic.com",
"credentials": "~/.config/kognic/credentials-demo.json"
"example": {
"host": "example.kognic.com",
"auth_server": "https://auth.example.kognic.com",
"credentials": "~/.config/kognic/credentials-example.json"
}
}
}
Expand Down Expand Up @@ -96,39 +96,48 @@ kognic-auth get-access-token
kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json

# Using a named environment
kognic-auth get-access-token --env demo
kognic-auth get-access-token --env example

# Using an environment but overriding the server
kognic-auth get-access-token --env demo --server https://custom.server
kognic-auth get-access-token --env example --server https://custom.server
```

### credentials

Manage credentials stored in the system keyring. This is the recommended way to store credentials on a developer machine — more secure than a credentials file and no environment variables needed.

```bash
kognic-auth credentials load FILE [--env ENV]
kognic-auth credentials clear [--env ENV]
kognic-auth credentials put FILE [--env ENV]
kognic-auth credentials get [--env ENV]
kognic-auth credentials delete [--env ENV]
```

**`load`** — reads a Kognic credentials JSON file and stores the `client_id` and `client_secret` in the system keyring.
**`put`** — reads a Kognic credentials JSON file and stores it in the system keyring.

- `FILE` - Path to a Kognic credentials JSON file (the same format accepted by `--credentials`)
- `--env` - Profile name to store under (default: `default`). Use the environment name from `environments.json` to link the credentials to that environment.

**`clear`** — removes credentials from the keyring for the given profile.
**`get`** — prints credentials stored in the keyring as JSON.

- `--env` - Profile name to read (default: `default`).

**`delete`** — removes credentials from the keyring for the given profile.

**Examples:**
```bash
# Store credentials under the default profile (used as fallback when no credentials are configured)
kognic-auth credentials load ~/Downloads/credentials.json
kognic-auth credentials put ~/Downloads/credentials.json

# Store per-environment credentials
kognic-auth credentials load ~/Downloads/prod-creds.json --env production
kognic-auth credentials load ~/Downloads/demo-creds.json --env demo
kognic-auth credentials put ~/Downloads/prod-creds.json --env production
kognic-auth credentials put ~/Downloads/example-creds.json --env example

# Read stored credentials
kognic-auth credentials get
kognic-auth credentials get --env production

# Remove credentials
kognic-auth credentials clear --env demo
kognic-auth credentials delete --env example
```

### Storing credentials in the keyring
Expand All @@ -137,14 +146,14 @@ The system keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager, e

**Single-environment setup** — store once, works everywhere:
```bash
kognic-auth credentials load ~/Downloads/credentials.json
kognic-auth credentials put ~/Downloads/credentials.json
# All CLI commands and the SDK will now find credentials automatically
```

**Multi-environment setup** — store per-environment credentials and reference them in `environments.json`:
```bash
kognic-auth credentials load ~/Downloads/prod-creds.json --env production
kognic-auth credentials load ~/Downloads/demo-creds.json --env demo
kognic-auth credentials put ~/Downloads/prod-creds.json --env production
kognic-auth credentials put ~/Downloads/example-creds.json --env example
```

Then in `~/.config/kognic/environments.json`, reference the keyring profiles with `keyring://`:
Expand All @@ -157,16 +166,16 @@ Then in `~/.config/kognic/environments.json`, reference the keyring profiles wit
"auth_server": "https://auth.app.kognic.com",
"credentials": "keyring://production"
},
"demo": {
"host": "demo.kognic.com",
"auth_server": "https://auth.demo.kognic.com",
"credentials": "keyring://demo"
"example": {
"host": "example.kognic.com",
"auth_server": "https://auth.example.kognic.com",
"credentials": "keyring://example"
}
}
}
```

Now `kog get https://app.kognic.com/v1/projects` automatically picks up the `production` keyring credentials, and `kog get https://demo.kognic.com/v1/projects` picks up `demo`.
Now `kog get https://app.kognic.com/v1/projects` automatically picks up the `production` keyring credentials, and `kog get https://example.kognic.com/v1/projects` picks up `example`.

The `keyring://` URI also works in the `auth` parameter of API clients:
```python
Expand Down Expand Up @@ -203,7 +212,7 @@ When `--env` is not provided, the environment is automatically resolved by match
kog get https://app.kognic.com/v1/projects

# Explicit environment
kog get https://demo.kognic.com/v1/projects --env demo
kog get https://example.kognic.com/v1/projects --env example

# POST with JSON body
kog post https://app.kognic.com/v1/projects -d '{"name": "test"}'
Expand Down
58 changes: 47 additions & 11 deletions src/kognic/auth/cli/credentials.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from __future__ import annotations

import argparse
import json
import sys

from kognic.auth.credentials_parser import parse_credentials
from kognic.auth.internal.credentials_store import DEFAULT_PROFILE, clear_credentials, save_credentials
from kognic.auth.internal.credentials_store import (
DEFAULT_PROFILE,
clear_credentials,
load_credentials,
save_credentials,
)

COMMAND = "credentials"

Expand All @@ -16,9 +22,9 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument
)
subs = parser.add_subparsers(dest="credentials_action")

load_p = subs.add_parser("load", help="Load credentials from a JSON file into the system keyring")
load_p.add_argument("file", metavar="FILE", help="Path to credentials JSON file")
load_p.add_argument(
put_p = subs.add_parser("put", help="Put credentials from a JSON file into the system keyring")
put_p.add_argument("file", metavar="FILE", help="Path to credentials JSON file")
put_p.add_argument(
"--env",
default=DEFAULT_PROFILE,
metavar="ENV",
Expand All @@ -27,7 +33,15 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument
"(e.g. --env production → use 'keyring://production' in your config).",
)

clear_p = subs.add_parser("clear", help="Remove stored credentials from the system keyring")
get_p = subs.add_parser("get", help="Get stored credentials from the system keyring")
get_p.add_argument(
"--env",
default=DEFAULT_PROFILE,
metavar="ENV",
help=f"Keyring profile name to read credentials from (default: {DEFAULT_PROFILE}).",
)

clear_p = subs.add_parser("delete", help="Delete stored credentials from the system keyring")
clear_p.add_argument(
"--env",
default=DEFAULT_PROFILE,
Expand All @@ -39,14 +53,16 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument


def run(parsed: argparse.Namespace) -> int:
if parsed.credentials_action == "load":
return _run_load(parsed)
if parsed.credentials_action == "clear":
return _run_clear(parsed)
if parsed.credentials_action == "put":
return _run_put(parsed)
if parsed.credentials_action == "get":
return _run_get(parsed)
if parsed.credentials_action == "delete":
return _run_delete(parsed)
return 0


def _run_load(parsed: argparse.Namespace) -> int:
def _run_put(parsed: argparse.Namespace) -> int:
try:
creds = parse_credentials(parsed.file)
save_credentials(creds, parsed.env)
Expand All @@ -63,7 +79,27 @@ def _run_load(parsed: argparse.Namespace) -> int:
return 1


def _run_clear(parsed: argparse.Namespace) -> int:
def _run_get(parsed: argparse.Namespace) -> int:
try:
creds = load_credentials(parsed.env)
if creds is None:
print(f"No credentials found in keyring (profile={parsed.env!r})", file=sys.stderr)
return 1
data = {
"clientId": creds.client_id,
"clientSecret": creds.client_secret,
"email": creds.email,
"userId": creds.user_id,
"issuer": creds.issuer,
}
print(json.dumps(data, indent=2))
Comment thread Dismissed
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1


def _run_delete(parsed: argparse.Namespace) -> int:
try:
clear_credentials(parsed.env)
print(f"Credentials cleared from keyring (profile={parsed.env!r})")
Expand Down
2 changes: 1 addition & 1 deletion src/kognic/auth/credentials_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def resolve_credentials(
else:
raise ValueError(
f"No credentials found in keyring for profile '{profile}'. "
f"Run 'kognic-auth credentials load <file> --env {profile}' to store them."
f"Run 'kognic-auth credentials put <file> --env {profile}' to store them."
)
elif not path.endswith(".json"):
raise ValueError(f"Bad auth credentials file, must be json: {path}")
Expand Down
61 changes: 51 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1119,7 +1119,7 @@ def test_kog_uses_cached_token(


class CredentialsCommandTest(unittest.TestCase):
def test_load_stores_credentials(self):
def test_put_stores_credentials(self):
import json
import tempfile

Expand All @@ -1136,7 +1136,7 @@ def test_load_stores_credentials(self):

try:
with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save:
result = main(["credentials", "load", path])
result = main(["credentials", "put", path])
self.assertEqual(result, 0)
args, kwargs = mock_save.call_args
self.assertEqual(args[0].client_id, "test-client-id")
Expand All @@ -1145,7 +1145,7 @@ def test_load_stores_credentials(self):
finally:
Path(path).unlink()

def test_load_custom_profile(self):
def test_put_custom_profile(self):
import json
import tempfile

Expand All @@ -1162,7 +1162,7 @@ def test_load_custom_profile(self):

try:
with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save:
result = main(["credentials", "load", path, "--env", "demo"])
result = main(["credentials", "put", path, "--env", "demo"])
self.assertEqual(result, 0)
args, kwargs = mock_save.call_args
self.assertEqual(args[0].client_id, "id")
Expand All @@ -1171,19 +1171,60 @@ def test_load_custom_profile(self):
finally:
Path(path).unlink()

def test_load_missing_file_returns_error(self):
result = main(["credentials", "load", "/nonexistent/creds.json"])
def test_put_missing_file_returns_error(self):
result = main(["credentials", "put", "/nonexistent/creds.json"])
self.assertEqual(result, 1)

def test_clear_removes_credentials(self):
def test_get_returns_credentials(self):
from kognic.auth.credentials_parser import ApiCredentials

fake_creds = ApiCredentials(
client_id="my-id",
client_secret="my-secret",
email="user@kognic.com",
user_id=42,
issuer="auth.kognic.com",
)
with mock.patch("kognic.auth.cli.credentials.load_credentials", return_value=fake_creds):
with mock.patch("builtins.print") as mock_print:
result = main(["credentials", "get"])
self.assertEqual(result, 0)
output = json.loads(mock_print.call_args[0][0])
self.assertEqual(output["clientId"], "my-id")
self.assertEqual(output["clientSecret"], "my-secret")
self.assertEqual(output["email"], "user@kognic.com")
self.assertEqual(output["userId"], 42)
self.assertEqual(output["issuer"], "auth.kognic.com")

def test_get_custom_profile(self):
from kognic.auth.credentials_parser import ApiCredentials

fake_creds = ApiCredentials(
client_id="demo-id",
client_secret="demo-secret",
email="demo@kognic.com",
user_id=1,
issuer="auth.kognic.com",
)
with mock.patch("kognic.auth.cli.credentials.load_credentials", return_value=fake_creds) as mock_load:
result = main(["credentials", "get", "--env", "demo"])
self.assertEqual(result, 0)
mock_load.assert_called_once_with("demo")

def test_get_not_found_returns_error(self):
with mock.patch("kognic.auth.cli.credentials.load_credentials", return_value=None):
result = main(["credentials", "get"])
self.assertEqual(result, 1)

def test_delete_removes_credentials(self):
with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear:
result = main(["credentials", "clear"])
result = main(["credentials", "delete"])
self.assertEqual(result, 0)
mock_clear.assert_called_once_with("default")

def test_clear_custom_profile(self):
def test_delete_custom_profile(self):
with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear:
result = main(["credentials", "clear", "--env", "demo"])
result = main(["credentials", "delete", "--env", "demo"])
self.assertEqual(result, 0)
mock_clear.assert_called_once_with("demo")

Expand Down