Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d0f780c
Add keyring-based token caching for CLI commands
claude Feb 19, 2026
9cd2a23
token cache
MichelEdkrantz Feb 19, 2026
73efaa4
token cache
MichelEdkrantz Feb 19, 2026
ce918ae
token cache
MichelEdkrantz Feb 19, 2026
001dfcb
Add shared token provider pool and bearer auth to BaseApiClient
MichelEdkrantz Feb 19, 2026
7831ce9
Fix AuthBase import: import from requests.auth, not requests
MichelEdkrantz Feb 19, 2026
b6b1aae
Move KognicBearerAuth to its own module
MichelEdkrantz Feb 19, 2026
ccb1280
Move TokenCache to internal package, wire into BaseApiClient
MichelEdkrantz Feb 19, 2026
c48ada2
Split token_cache module into a package
MichelEdkrantz Feb 19, 2026
f26a0b3
Fix CLI imports to use kognic.auth.internal.token_cache directly
MichelEdkrantz Feb 19, 2026
47c242e
Add provider pool tests for BaseApiClient
MichelEdkrantz Feb 19, 2026
f5abe6e
Remove stale token_cache.py after conversion to package
MichelEdkrantz Feb 19, 2026
e541953
Extract make_token_provider helper, simplify CLI commands
MichelEdkrantz Feb 19, 2026
de42745
refactor constructor
MichelEdkrantz Feb 19, 2026
576c6b4
refactor credentials_parser.py
MichelEdkrantz Feb 20, 2026
d3cf23c
Add keyring-based credential storage and keyring:// URI support
MichelEdkrantz Feb 20, 2026
7355eae
Merge branch 'master' into credentials-keyring
MichelEdkrantz Feb 20, 2026
b7f892a
fix imports
MichelEdkrantz Feb 20, 2026
b7e8864
fix imports
MichelEdkrantz Feb 20, 2026
2e3a32a
Fix README --profile flag, circular import, and rename credential_store
MichelEdkrantz Feb 20, 2026
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
86 changes: 83 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ There are a few ways to set your credentials in `auth`.
1. Set the environment variable `KOGNIC_CREDENTIALS` to point to your Api Credentials file.
The credentials will contain the Client Id and Client Secret.
2. Set to the credentials file path like `auth="~/.config/kognic/credentials.json"`
3. Set environment variables `KOGNIC_CLIENT_ID` and`KOGNIC_CLIENT_SECRET`
3. Set environment variables `KOGNIC_CLIENT_ID` and `KOGNIC_CLIENT_SECRET`
4. Set to credentials tuple `auth=(client_id, client_secret)`
5. Store credentials in the system keyring (see [Storing credentials in the keyring](#storing-credentials-in-the-keyring))

API clients such as the `InputApiClient` accept this `auth` parameter.

Expand Down Expand Up @@ -61,9 +62,12 @@ The CLI can be configured with a JSON file at `~/.config/kognic/environments.jso
```

Each environment has the following fields:
- `host` - The API hostname, used by `call` to automatically match an environment based on the request URL.
- `host` - The API hostname, used by `kog` to automatically match an environment based on the request URL.
- `auth_server` - The OAuth server URL used to fetch tokens.
- `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables.
- `credentials` *(optional)* - Where to load credentials from. Three formats are supported:
- A file path: `"~/.config/kognic/credentials-prod.json"` (tilde `~` is expanded)
- A keyring reference: `"keyring://production"` (loads from the system keyring under the named profile)
- Omit entirely: credentials are read from environment variables or the keyring `default` profile

`default_environment` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found.

Expand Down Expand Up @@ -98,6 +102,82 @@ kognic-auth get-access-token --env demo
kognic-auth get-access-token --env demo --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]
```

**`load`** — reads a Kognic credentials JSON file and stores the `client_id` and `client_secret` 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.

**Examples:**
```bash
# Store credentials under the default profile (used as fallback when no credentials are configured)
kognic-auth credentials load ~/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

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

### Storing credentials in the keyring

The system keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager, etc.) is the recommended place to keep credentials on a developer machine. No credentials files on disk, no environment variables in shell profiles.

**Single-environment setup** — store once, works everywhere:
```bash
kognic-auth credentials load ~/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
```

Then in `~/.config/kognic/environments.json`, reference the keyring profiles with `keyring://`:
```json
{
"default_environment": "production",
"environments": {
"production": {
"host": "app.kognic.com",
"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"
}
}
}
```

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`.

The `keyring://` URI also works in the `auth` parameter of API clients:
```python
client = BaseApiClient(auth="keyring://production")
```

**Credential resolution order** — when no explicit `auth` is provided, the SDK tries sources in this order:
1. `KOGNIC_CREDENTIALS` environment variable (path to credentials JSON file)
2. `KOGNIC_CLIENT_ID` + `KOGNIC_CLIENT_SECRET` environment variables
3. System keyring, `default` profile

### kog

Make an authenticated HTTP request to a Kognic API.
Expand Down
4 changes: 2 additions & 2 deletions src/kognic/auth/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import sys
from types import ModuleType

from kognic.auth.cli import get_access_token
from kognic.auth.cli import credentials, get_access_token

_SUBCOMMANDS: list[ModuleType] = [get_access_token]
_SUBCOMMANDS: list[ModuleType] = [get_access_token, credentials]


def create_parser() -> argparse.ArgumentParser:
Expand Down
73 changes: 73 additions & 0 deletions src/kognic/auth/cli/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

import argparse
import sys

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

COMMAND = "credentials"


def register_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
parser = subparsers.add_parser(
COMMAND,
help="Manage stored credentials in the system keyring",
)
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(
"--env",
default=DEFAULT_PROFILE,
metavar="ENV",
help=f"Keyring profile name to store credentials under (default: {DEFAULT_PROFILE}). "
"Use the environment name from environments.json to link credentials to that environment "
"(e.g. --env production → use 'keyring://production' in your config).",
)

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

return parser


def run(parsed: argparse.Namespace) -> int:
if parsed.credentials_action == "load":
return _run_load(parsed)
if parsed.credentials_action == "clear":
return _run_clear(parsed)
return 0


def _run_load(parsed: argparse.Namespace) -> int:
try:
creds = parse_credentials(parsed.file)
save_credentials(creds, parsed.env)
print(f"Credentials for client_id={creds.client_id!r} stored in keyring (profile={parsed.env!r})")
return 0
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except (KeyError, ValueError, RuntimeError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1


def _run_clear(parsed: argparse.Namespace) -> int:
try:
clear_credentials(parsed.env)
print(f"Credentials cleared from keyring (profile={parsed.env!r})")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
4 changes: 3 additions & 1 deletion src/kognic/auth/cli/get_access_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
COMMAND = "get-access-token"


def register_parser(subparsers: argparse._SubParsersAction) -> None:
def register_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
token_parser = subparsers.add_parser(
COMMAND,
help="Generate an access token for Kognic API authentication",
Expand Down Expand Up @@ -44,6 +44,8 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None:
"Auto will use keyring if available, otherwise file-based caching.",
)

return token_parser


def run(parsed: argparse.Namespace) -> int:
try:
Expand Down
29 changes: 25 additions & 4 deletions src/kognic/auth/credentials_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from pathlib import Path
from typing import Optional, Union

from kognic.auth.internal import credentials_store

ANY_AUTH_TYPE = Union[str, os.PathLike, tuple, "ApiCredentials", dict, None]

REQUIRED_CREDENTIALS_FILE_KEYS = [
Expand Down Expand Up @@ -58,6 +60,13 @@ def get_credentials_from_env() -> tuple[Optional[str], Optional[str]]:
client_id = os.getenv("KOGNIC_CLIENT_ID")
client_secret = os.getenv("KOGNIC_CLIENT_SECRET")

if client_id and client_secret:
return client_id, client_secret

keyring_creds = credentials_store.load_credentials()
if keyring_creds:
return keyring_creds.client_id, keyring_creds.client_secret

return client_id, client_secret


Expand All @@ -83,12 +92,24 @@ def resolve_credentials(
client_secret = creds.client_secret
elif isinstance(auth, (str, os.PathLike)):
path = str(auth)
if not path.endswith(".json"):
if path.startswith("keyring://"):
profile = path[len("keyring://") :]
keyring_creds = credentials_store.load_credentials(profile)
if keyring_creds:
client_id, client_secret = keyring_creds.client_id, keyring_creds.client_secret
else:
raise ValueError(
f"No credentials found in keyring for profile '{profile}'. "
f"Run 'kognic-auth credentials load <file> --env {profile}' to store them."
)
elif not path.endswith(".json"):
raise ValueError(f"Bad auth credentials file, must be json: {path}")
creds = parse_credentials(auth)
client_id = creds.client_id
client_secret = creds.client_secret
else:
creds = parse_credentials(auth)
client_id = creds.client_id
client_secret = creds.client_secret
elif auth is not None:
# unreasonable type, but we want to be defensive in case of user error
raise ValueError(f"Unsupported auth type: {type(auth)}")

if not client_id and not client_secret:
Expand Down
2 changes: 1 addition & 1 deletion src/kognic/auth/env_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def load_kognic_env_config(path: Union[str, os.PathLike] = DEFAULT_ENV_CONFIG_FI
environments = {}
for name, env_data in data.get("environments", {}).items():
credentials = env_data.get("credentials")
if credentials:
if credentials and not credentials.startswith("keyring://"):
credentials = str(Path(credentials).expanduser())
environments[name] = Environment(
name=name,
Expand Down
77 changes: 77 additions & 0 deletions src/kognic/auth/internal/credentials_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Keyring-based storage for Kognic API client credentials (full credentials file)."""

from __future__ import annotations

import json
import logging
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from kognic.auth.credentials_parser import ApiCredentials

SERVICE_NAME = "kognic-credentials"
DEFAULT_PROFILE = "default"

log = logging.getLogger(__name__)


def _get_keyring():
"""Return the keyring module if a usable backend is available, else None."""
try:
import keyring

backend = keyring.get_keyring()
if "fail" in type(backend).__name__.lower():
return None
return keyring
except Exception:
return None


def load_credentials(profile: str = DEFAULT_PROFILE) -> Optional[ApiCredentials]:
"""Load full credentials from keyring, or None if not found."""
kr = _get_keyring()
if kr is None:
return None
try:
stored = kr.get_password(SERVICE_NAME, profile)
if stored is None:
return None

from kognic.auth.credentials_parser import parse_credentials

return parse_credentials(json.loads(stored))
except Exception:
log.debug("Failed to load credentials from keyring", exc_info=True)
return None


def save_credentials(creds: ApiCredentials, profile: str = DEFAULT_PROFILE) -> None:
"""Store full credentials in keyring. Raises RuntimeError if keyring is unavailable."""
kr = _get_keyring()
if kr is None:
raise RuntimeError(
"No usable keyring backend available. "
"Install a keyring backend (e.g. 'pip install keyring') or use environment variables instead."
)
data = {
"clientId": creds.client_id,
"clientSecret": creds.client_secret,
"email": creds.email,
"userId": creds.user_id,
"issuer": creds.issuer,
}
kr.set_password(SERVICE_NAME, profile, json.dumps(data))
log.debug("Saved credentials to keyring for profile=%s", profile)


def clear_credentials(profile: str = DEFAULT_PROFILE) -> None:
"""Remove credentials from keyring. Silently does nothing if not found or keyring unavailable."""
kr = _get_keyring()
if kr is None:
return
try:
kr.delete_password(SERVICE_NAME, profile)
log.debug("Cleared credentials from keyring for profile=%s", profile)
except Exception:
log.debug("Failed to clear credentials from keyring", exc_info=True)
Loading