From d0f780c9eb4f83ae87aa319db51704b3ee6b5395 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 07:35:44 +0000 Subject: [PATCH 01/19] Add keyring-based token caching for CLI commands Store OAuth tokens in the system keyring so subsequent CLI invocations (kognic-auth get-access-token, kog) can skip the token fetch when a valid cached token exists. Tokens are scoped per auth_server:client_id. - New module: cli/token_cache.py with load/save/clear functions - Add --no-cache flag to both CLI commands to bypass caching - keyring is an optional dependency (graceful degradation if absent) - Library API (RequestsAuthSession, BaseApiClient, etc.) is unaffected https://claude.ai/code/session_015vu8mYThdaZcoJXS4xWvDQ --- pyproject.toml | 7 +- src/kognic/auth/cli/api_request.py | 60 ++++- src/kognic/auth/cli/get_access_token.py | 37 ++- src/kognic/auth/cli/token_cache.py | 95 +++++++ tests/test_cli.py | 336 +++++++++++++++++++----- tests/test_token_cache.py | 176 +++++++++++++ 6 files changed, 637 insertions(+), 74 deletions(-) create mode 100644 src/kognic/auth/cli/token_cache.py create mode 100644 tests/test_token_cache.py diff --git a/pyproject.toml b/pyproject.toml index 914c0e8..fe215d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,13 @@ httpx = [ requests = [ "requests>=2.20,<3" ] +keyring = [ + "keyring>=23.0" +] full = [ "httpx>=0.20,<1", - "requests>=2.20,<3" + "requests>=2.20,<3", + "keyring>=23.0" ] [dependency-groups] dev = [ @@ -53,6 +57,7 @@ dev = [ "requests>=2.20,<3", "pydantic>=2", "pytest", + "keyring>=23.0", ] [tool.ruff] diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index 38b2da4..fc4a903 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -8,7 +8,6 @@ from typing import Any from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment -from kognic.auth.requests.base_client import create_session METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] @@ -42,6 +41,12 @@ def _create_parser() -> argparse.ArgumentParser: default="json", help="Output format: json (default), jsonl (one JSON object per line), csv, tsv, table (markdown)", ) + parser.add_argument( + "--no-cache", + action="store_true", + default=False, + help="Skip reading/writing cached tokens from the system keyring", + ) return parser @@ -154,6 +159,53 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: print(response.text) +def _create_authenticated_session(*, auth, auth_host, use_cache=True): + """Create an authenticated session, optionally using cached tokens from the keyring. + + Constructs RequestsAuthSession directly (instead of via create_session) so that + a cached token can be injected before the session property triggers a network fetch. + """ + import requests + from requests.adapters import HTTPAdapter + + from kognic.auth._user_agent import get_user_agent + from kognic.auth.requests.auth_session import RequestsAuthSession + from kognic.auth.requests.base_client import DEFAULT_RETRY + + auth_session = RequestsAuthSession(auth=auth, host=auth_host) + had_token = False + + if use_cache: + from kognic.auth.cli.token_cache import load_cached_token + + client_id = auth_session.oauth_session.client_id + if client_id: + cached = load_cached_token(auth_host, client_id) + if cached: + auth_session.oauth_session.token = cached + had_token = True + + # Access .session — skips network fetch if token was injected above + session = auth_session.session + + # Apply session enhancements (retry + user-agent) + session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", None) + session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) + session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) + + # Save freshly fetched token to keyring + if use_cache and not had_token: + token = auth_session.token + if isinstance(token, dict): + from kognic.auth.cli.token_cache import save_token + + client_id = auth_session.oauth_session.client_id + if client_id: + save_token(auth_host, client_id, token) + + return session + + def run(parsed: argparse.Namespace) -> int: try: headers = _parse_headers(parsed.headers) or {} @@ -162,9 +214,10 @@ def run(parsed: argparse.Namespace) -> int: config = load_kognic_env_config(parsed.env_config_file_path) env = resolve_environment(config, parsed.url, parsed.env_name) - session = create_session( + session = _create_authenticated_session( auth=env.credentials, auth_host=env.auth_server, + use_cache=not parsed.no_cache, ) response = session.request( @@ -174,6 +227,9 @@ def run(parsed: argparse.Namespace) -> int: headers=headers if headers else None, ) + from kognic.auth.requests.base_client import _check_response + + _check_response(response) _print_response(response, output_format=parsed.output_format) return 0 if response.ok else 1 diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 46f9007..ef4409a 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -35,6 +35,12 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: dest="env_name", help="Use a specific environment from the config file", ) + token_parser.add_argument( + "--no-cache", + action="store_true", + default=False, + help="Skip reading/writing cached tokens from the system keyring", + ) def run(parsed: argparse.Namespace) -> int: @@ -53,12 +59,41 @@ def run(parsed: argparse.Namespace) -> int: if credentials is None: credentials = ctx.credentials + auth_host = host or DEFAULT_HOST + + # Try loading a cached token from the keyring + if not parsed.no_cache: + from kognic.auth.cli.token_cache import load_cached_token + from kognic.auth.credentials_parser import resolve_credentials + + try: + client_id, _ = resolve_credentials(credentials) + except Exception: + client_id = None + + if client_id: + cached = load_cached_token(auth_host, client_id) + if cached: + print(cached["access_token"]) + return 0 + session = RequestsAuthSession( auth=credentials, - host=host or DEFAULT_HOST, + host=auth_host, ) # Access .session to trigger token fetch _ = session.session + + # Save the freshly fetched token to keyring + if not parsed.no_cache: + token = session.token + if isinstance(token, dict): + from kognic.auth.cli.token_cache import save_token + + cid = session.oauth_session.client_id + if cid: + save_token(auth_host, cid, token) + print(session.access_token) return 0 except FileNotFoundError as e: diff --git a/src/kognic/auth/cli/token_cache.py b/src/kognic/auth/cli/token_cache.py new file mode 100644 index 0000000..ede526d --- /dev/null +++ b/src/kognic/auth/cli/token_cache.py @@ -0,0 +1,95 @@ +"""CLI token cache using the system keyring. + +This module is used ONLY by the CLI commands, not the library API. +All keyring imports are lazy so the module works when keyring is not installed. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Optional + +log = logging.getLogger(__name__) + +SERVICE_NAME = "kognic-auth" + +# Tokens are considered expired this many seconds before their actual expiry, +# to avoid using a token that expires mid-request. +EXPIRY_MARGIN_SECONDS = 30 + + +def _keyring_available() -> bool: + """Check if the keyring package is installed and a usable backend exists.""" + try: + import keyring + + backend = keyring.get_keyring() + # The "fail" backend is not usable + if "fail" in type(backend).__name__.lower(): + return False + return True + except Exception: + return False + + +def _make_key(auth_server: str, client_id: str) -> str: + return f"{auth_server}:{client_id}" + + +def load_cached_token(auth_server: str, client_id: str) -> Optional[dict]: + """Load a non-expired token from the keyring. + + Returns the token dict if found and still valid, otherwise None. + """ + if not _keyring_available(): + return None + try: + import keyring + + key = _make_key(auth_server, client_id) + stored = keyring.get_password(SERVICE_NAME, key) + if stored is None: + return None + token = json.loads(stored) + expires_at = token.get("expires_at") + if expires_at is None: + log.debug("Cached token has no expires_at, discarding") + return None + if time.time() >= (expires_at - EXPIRY_MARGIN_SECONDS): + log.debug("Cached token expired, discarding") + return None + log.debug("Using cached token from keyring (expires_at=%s)", expires_at) + return token + except Exception: + log.debug("Failed to load token from keyring", exc_info=True) + return None + + +def save_token(auth_server: str, client_id: str, token: dict) -> None: + """Save a token dict to the keyring. Silently ignores errors.""" + if not _keyring_available(): + return + try: + import keyring + + key = _make_key(auth_server, client_id) + keyring.set_password(SERVICE_NAME, key, json.dumps(token)) + log.debug("Saved token to keyring for key=%s", key) + except Exception: + log.debug("Failed to save token to keyring", exc_info=True) + + +def clear_token(auth_server: str, client_id: str) -> None: + """Remove a cached token from the keyring. Silently ignores errors.""" + if not _keyring_available(): + return + try: + import keyring + + key = _make_key(auth_server, client_id) + keyring.delete_password(SERVICE_NAME, key) + log.debug("Cleared cached token from keyring for key=%s", key) + except Exception: + log.debug("Failed to clear token from keyring", exc_info=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 340ae3a..c67819c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +import time import unittest from unittest import mock @@ -45,6 +46,16 @@ def test_no_command_shows_help(self): result = main([]) self.assertEqual(result, 0) + def test_no_cache_flag(self): + parser = create_parser() + args = parser.parse_args(["get-access-token", "--no-cache"]) + self.assertTrue(args.no_cache) + + def test_no_cache_default_false(self): + parser = create_parser() + args = parser.parse_args(["get-access-token"]) + self.assertFalse(args.no_cache) + class CliMainTest(unittest.TestCase): @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") @@ -54,7 +65,7 @@ def test_main_prints_token(self, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token"]) + result = main(["get-access-token", "--no-cache"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("test-access-token-123") @@ -67,7 +78,7 @@ def test_main_with_credentials_file(self, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--credentials", "/path/to/creds.json"]) + result = main(["get-access-token", "--credentials", "/path/to/creds.json", "--no-cache"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("token-from-file") @@ -80,7 +91,7 @@ def test_main_with_custom_server(self, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--server", "https://custom.server"]) + result = main(["get-access-token", "--server", "https://custom.server", "--no-cache"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("custom-server-token") @@ -93,7 +104,9 @@ def test_main_with_all_options(self, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print"): - result = main(["get-access-token", "--server", "https://my.server", "--credentials", "creds.json"]) + result = main( + ["get-access-token", "--server", "https://my.server", "--credentials", "creds.json", "--no-cache"] + ) self.assertEqual(result, 0) mock_session_class.assert_called_once_with(auth="creds.json", host="https://my.server") @@ -103,7 +116,7 @@ def test_main_file_not_found(self, mock_session_class): mock_session_class.side_effect = FileNotFoundError("Could not find Api Credentials file at /bad/path.json") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--credentials", "/bad/path.json"]) + result = main(["get-access-token", "--credentials", "/bad/path.json", "--no-cache"]) self.assertEqual(result, 1) mock_print.assert_called_once() @@ -114,7 +127,7 @@ def test_main_value_error(self, mock_session_class): mock_session_class.side_effect = ValueError("Bad auth credentials") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token"]) + result = main(["get-access-token", "--no-cache"]) self.assertEqual(result, 1) mock_print.assert_called_once() @@ -125,7 +138,7 @@ def test_main_generic_exception(self, mock_session_class): mock_session_class.side_effect = Exception("Network error") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token"]) + result = main(["get-access-token", "--no-cache"]) self.assertEqual(result, 1) mock_print.assert_called_once() @@ -151,7 +164,7 @@ def test_main_with_context(self, mock_load_config, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--env", "demo"]) + result = main(["get-access-token", "--env", "demo", "--no-cache"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("demo-token") @@ -180,7 +193,9 @@ def test_main_with_context_server_override(self, mock_load_config, mock_session_ mock_session_class.return_value = mock_session with mock.patch("builtins.print"): - result = main(["get-access-token", "--env", "demo", "--server", "https://custom.server"]) + result = main( + ["get-access-token", "--env", "demo", "--server", "https://custom.server", "--no-cache"] + ) self.assertEqual(result, 0) mock_session_class.assert_called_once_with( @@ -202,6 +217,86 @@ def test_main_with_unknown_context(self): self.assertIn("Unknown environment", mock_print.call_args[0][0]) +class CliCacheTest(unittest.TestCase): + """Tests for keyring token caching in get-access-token.""" + + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.token_cache.load_cached_token") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + @mock.patch("kognic.auth.credentials_parser.resolve_credentials", return_value=("client-1", "secret")) + def test_cache_hit_returns_cached_token(self, mock_resolve, mock_kr, mock_load, mock_session_class): + mock_load.return_value = { + "access_token": "cached-token-abc", + "expires_at": time.time() + 3600, + "expires_in": 3600, + "token_type": "bearer", + } + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("cached-token-abc") + mock_session_class.assert_not_called() + + @mock.patch("kognic.auth.cli.token_cache.save_token") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.token_cache.load_cached_token", return_value=None) + @mock.patch("kognic.auth.credentials_parser.resolve_credentials", return_value=("client-1", "secret")) + def test_cache_miss_saves_token(self, mock_resolve, mock_load, mock_session_class, mock_kr, mock_save): + token_dict = { + "access_token": "fresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + "token_type": "bearer", + } + mock_session = mock.MagicMock() + mock_session.access_token = "fresh-token" + mock_session.token = token_dict + mock_session.oauth_session.client_id = "client-1" + mock_session_class.return_value = mock_session + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("fresh-token") + mock_save.assert_called_once_with(DEFAULT_HOST, "client-1", token_dict) + + @mock.patch("kognic.auth.cli.token_cache.save_token") + @mock.patch("kognic.auth.cli.token_cache.load_cached_token") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + def test_no_cache_skips_keyring(self, mock_session_class, mock_load, mock_save): + mock_session = mock.MagicMock() + mock_session.access_token = "no-cache-token" + mock_session_class.return_value = mock_session + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--no-cache"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("no-cache-token") + mock_load.assert_not_called() + mock_save.assert_not_called() + + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.token_cache.load_cached_token", return_value=None) + @mock.patch("kognic.auth.credentials_parser.resolve_credentials", side_effect=FileNotFoundError("no file")) + def test_cache_credential_resolve_failure_falls_through(self, mock_resolve, mock_load, mock_session_class): + """If resolve_credentials fails during cache check, fall through to normal auth flow.""" + mock_session = mock.MagicMock() + mock_session.access_token = "fallback-token" + mock_session_class.return_value = mock_session + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("fallback-token") + mock_load.assert_not_called() + + class KogParserTest(unittest.TestCase): def test_kog_basic(self): parser = create_kog_parser() @@ -249,6 +344,16 @@ def test_kog_with_config(self): ) self.assertEqual(args.env_config_file_path, "/custom/config.json") + def test_kog_no_cache_flag(self): + parser = create_kog_parser() + args = parser.parse_args(["get", "https://app.kognic.com/v1/projects", "--no-cache"]) + self.assertTrue(args.no_cache) + + def test_kog_no_cache_default_false(self): + parser = create_kog_parser() + args = parser.parse_args(["get", "https://app.kognic.com/v1/projects"]) + self.assertFalse(args.no_cache) + class CallApiTest(unittest.TestCase): def _make_parsed( @@ -259,6 +364,7 @@ def _make_parsed( headers=None, env_config_file_path="/nonexistent/config.json", env_name=None, + no_cache=True, ): parser = create_kog_parser() args = [method, url] @@ -270,12 +376,14 @@ def _make_parsed( args.extend(["--env-config-file-path", env_config_file_path]) if env_name: args.extend(["--env", env_name]) + if no_cache: + args.append("--no-cache") return parser.parse_args(args) @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_get_success(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -289,7 +397,7 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"projects": []} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() with mock.patch("builtins.print") as mock_print: @@ -306,8 +414,8 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_post_with_data(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_post_with_data(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -321,7 +429,7 @@ def test_call_api_post_with_data(self, mock_session_class, mock_load_config, moc mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"id": 1} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed(method="post", data='{"name": "test"}') with mock.patch("builtins.print"): @@ -337,8 +445,8 @@ def test_call_api_post_with_data(self, mock_session_class, mock_load_config, moc @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_with_custom_headers(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -352,7 +460,7 @@ def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config mock_response.headers = {"Content-Type": "text/plain"} mock_response.text = "OK" mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed(headers=["Accept: text/plain", "X-Custom: value"]) with mock.patch("builtins.print"): @@ -368,8 +476,8 @@ def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_error_status(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -383,7 +491,7 @@ def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_ mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"error": "not found"} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() with mock.patch("builtins.print"): @@ -411,8 +519,8 @@ def test_call_api_invalid_header_format(self): @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_plain_text_response(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_plain_text_response(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -426,7 +534,7 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config mock_response.headers = {"Content-Type": "text/plain"} mock_response.text = "Hello World" mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() with mock.patch("builtins.print") as mock_print: @@ -437,8 +545,8 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_jsonl_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -452,7 +560,7 @@ def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, m mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "jsonl" @@ -466,8 +574,8 @@ def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, m @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_jsonl_single_key_non_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response has a single key holding a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -482,7 +590,7 @@ def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_ mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"projects": [{"id": 1}, {"id": 2}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "jsonl" @@ -496,8 +604,8 @@ def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_ @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_jsonl_multiple_keys(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used but response has multiple keys, pretty-print as usual.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -512,7 +620,7 @@ def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1}], "total": 1} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "jsonl" @@ -524,8 +632,8 @@ def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_jsonl_top_level_list(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response body is a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -540,7 +648,7 @@ def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_confi mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = [{"id": 1}, {"id": 2}, {"id": 3}] mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "jsonl" @@ -555,8 +663,8 @@ def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_confi @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_jsonl_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and data is an empty list, nothing is printed.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -571,7 +679,7 @@ def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, m mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "jsonl" @@ -583,8 +691,8 @@ def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, m @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -598,7 +706,7 @@ def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, moc mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -614,8 +722,8 @@ def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, moc @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_tsv_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -629,7 +737,7 @@ def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, moc mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "tsv" @@ -645,8 +753,8 @@ def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, moc @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_table_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_table_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -660,7 +768,7 @@ def test_call_api_table_data_array(self, mock_session_class, mock_load_config, m mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "alice"}, {"id": 2, "name": "b"}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "table" @@ -676,8 +784,8 @@ def test_call_api_table_data_array(self, mock_session_class, mock_load_config, m @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_table_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """Table with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -692,7 +800,7 @@ def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, m mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "table" @@ -704,8 +812,8 @@ def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, m @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_nested_values(self, mock_create_session, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in CSV output.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -720,7 +828,7 @@ def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"], "meta": {"key": "val"}}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -735,8 +843,8 @@ def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_table_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_table_nested_values(self, mock_create_session, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in table output.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -751,7 +859,7 @@ def test_call_api_table_nested_values(self, mock_session_class, mock_load_config mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"]}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "table" @@ -766,8 +874,8 @@ def test_call_api_table_nested_values(self, mock_session_class, mock_load_config @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_top_level_list(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -781,7 +889,7 @@ def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = [{"x": 10, "y": 20}, {"x": 30, "y": 40}] mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -797,8 +905,8 @@ def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_sparse_keys(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV output includes all keys across all rows, with blanks for missing values.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -813,7 +921,7 @@ def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mo mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "extra": "z"}]} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -829,8 +937,8 @@ def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mo @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -845,7 +953,7 @@ def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, moc mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -857,8 +965,8 @@ def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, moc @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request.create_session") - def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config, mock_resolve_environment): + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_call_api_csv_not_flattenable(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV with non-flattenable response falls back to pretty JSON.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -873,7 +981,7 @@ def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"a": 1, "b": 2} mock_session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_create_session.return_value = mock_session parsed = self._make_parsed() parsed.output_format = "csv" @@ -894,7 +1002,9 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ credentials="/path/to/demo-creds.json", ) - with mock.patch("kognic.auth.cli.api_request.create_session") as mock_create_session: + with mock.patch( + "kognic.auth.cli.api_request._create_authenticated_session" + ) as mock_create_session: mock_session = mock.MagicMock() mock_response = mock.MagicMock() mock_response.ok = True @@ -910,8 +1020,94 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ mock_create_session.assert_called_once_with( auth="/path/to/demo-creds.json", auth_host="https://auth.demo.kognic.com", + use_cache=False, ) +class KogCacheTest(unittest.TestCase): + """Tests for keyring token caching in kog command.""" + + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.token_cache.load_cached_token") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_kog_uses_cached_token( + self, mock_kr, mock_load, mock_session_class, mock_load_config, mock_resolve_environment + ): + """When a cached token is available, kog injects it and skips the network fetch.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_environment.return_value = Environment( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials="/path/to/creds.json", + ) + + cached_token = { + "access_token": "cached-kog-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + "token_type": "bearer", + } + mock_load.return_value = cached_token + + mock_auth_session = mock.MagicMock() + mock_auth_session.oauth_session.client_id = "client-1" + mock_auth_session.token = cached_token + + mock_raw_session = mock.MagicMock() + mock_raw_session.headers = {} + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"result": "ok"} + mock_raw_session.request.return_value = mock_response + mock_auth_session.session = mock_raw_session + + mock_session_class.return_value = mock_auth_session + + parser = create_kog_parser() + parsed = parser.parse_args(["get", "https://app.kognic.com/v1/projects", + "--env-config-file-path", "/nonexistent/config.json"]) + with mock.patch("builtins.print"): + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_load.assert_called_once_with("https://auth.app.kognic.com", "client-1") + + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + def test_kog_no_cache_passes_flag(self, mock_create_session, mock_load_config, mock_resolve_environment): + """When --no-cache is passed, use_cache=False is forwarded.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_environment.return_value = Environment( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "text/plain"} + mock_response.text = "ok" + mock_session.request.return_value = mock_response + mock_create_session.return_value = mock_session + + parser = create_kog_parser() + parsed = parser.parse_args(["get", "https://app.kognic.com/v1/projects", + "--env-config-file-path", "/nonexistent/config.json", "--no-cache"]) + with mock.patch("builtins.print"): + call_run(parsed) + + mock_create_session.assert_called_once_with( + auth=None, + auth_host="https://auth.app.kognic.com", + use_cache=False, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py new file mode 100644 index 0000000..8fbebba --- /dev/null +++ b/tests/test_token_cache.py @@ -0,0 +1,176 @@ +import json +import time +import unittest +from unittest import mock + +from kognic.auth.cli.token_cache import ( + EXPIRY_MARGIN_SECONDS, + SERVICE_NAME, + _make_key, + clear_token, + load_cached_token, + save_token, +) + + +def _make_token(*, expires_in=3600, extra=None): + """Create a realistic token dict.""" + now = time.time() + token = { + "access_token": "eyJ.test.token", + "token_type": "bearer", + "expires_in": expires_in, + "expires_at": now + expires_in, + } + if extra: + token.update(extra) + return token + + +class MakeKeyTest(unittest.TestCase): + def test_format(self): + key = _make_key("https://auth.app.kognic.com", "my-client-id") + self.assertEqual(key, "https://auth.app.kognic.com:my-client-id") + + def test_different_servers_produce_different_keys(self): + key1 = _make_key("https://auth.app.kognic.com", "client-1") + key2 = _make_key("https://auth.demo.kognic.com", "client-1") + self.assertNotEqual(key1, key2) + + +class KeyringAvailableTest(unittest.TestCase): + @mock.patch("keyring.get_keyring") + def test_keyring_available(self, mock_get_keyring): + from kognic.auth.cli.token_cache import _keyring_available + + mock_get_keyring.return_value = mock.MagicMock(__class__=type("SecretService", (), {})) + self.assertTrue(_keyring_available()) + + @mock.patch("keyring.get_keyring") + def test_keyring_fail_backend(self, mock_get_keyring): + from kognic.auth.cli.token_cache import _keyring_available + + class FailKeyring: + pass + + mock_get_keyring.return_value = FailKeyring() + self.assertFalse(_keyring_available()) + + +class LoadCachedTokenTest(unittest.TestCase): + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) + def test_keyring_not_available(self, _): + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password", return_value=None) + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_not_found(self, _, mock_get): + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + mock_get.assert_called_once_with(SERVICE_NAME, "https://auth.app.kognic.com:client-1") + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_valid_token(self, _, mock_get): + token = _make_token(expires_in=3600) + mock_get.return_value = json.dumps(token) + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNotNone(result) + self.assertEqual(result["access_token"], "eyJ.test.token") + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_expired_token(self, _, mock_get): + token = _make_token(expires_in=-100) + mock_get.return_value = json.dumps(token) + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_token_within_margin(self, _, mock_get): + token = _make_token(expires_in=EXPIRY_MARGIN_SECONDS - 1) + mock_get.return_value = json.dumps(token) + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_no_expires_at(self, _, mock_get): + token = {"access_token": "eyJ.test.token", "token_type": "bearer"} + mock_get.return_value = json.dumps(token) + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_corrupt_json(self, _, mock_get): + mock_get.return_value = "not valid json!!!" + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password", side_effect=Exception("keyring error")) + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_keyring_error(self, _, mock_get): + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNone(result) + + @mock.patch("keyring.get_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_includes_refresh_token(self, _, mock_get): + token = _make_token(extra={"refresh_token": "refresh-abc"}) + mock_get.return_value = json.dumps(token) + result = load_cached_token("https://auth.app.kognic.com", "client-1") + self.assertIsNotNone(result) + self.assertEqual(result["refresh_token"], "refresh-abc") + + +class SaveTokenTest(unittest.TestCase): + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) + def test_keyring_not_available(self, _): + # Should not raise + save_token("https://auth.app.kognic.com", "client-1", _make_token()) + + @mock.patch("keyring.set_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_saves_to_keyring(self, _, mock_set): + token = _make_token() + save_token("https://auth.app.kognic.com", "client-1", token) + mock_set.assert_called_once_with( + SERVICE_NAME, + "https://auth.app.kognic.com:client-1", + json.dumps(token), + ) + + @mock.patch("keyring.set_password", side_effect=Exception("write failed")) + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_save_error_silenced(self, _, mock_set): + # Should not raise + save_token("https://auth.app.kognic.com", "client-1", _make_token()) + + +class ClearTokenTest(unittest.TestCase): + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) + def test_keyring_not_available(self, _): + # Should not raise + clear_token("https://auth.app.kognic.com", "client-1") + + @mock.patch("keyring.delete_password") + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_clears_from_keyring(self, _, mock_delete): + clear_token("https://auth.app.kognic.com", "client-1") + mock_delete.assert_called_once_with( + SERVICE_NAME, + "https://auth.app.kognic.com:client-1", + ) + + @mock.patch("keyring.delete_password", side_effect=Exception("delete failed")) + @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) + def test_clear_error_silenced(self, _, mock_delete): + # Should not raise + clear_token("https://auth.app.kognic.com", "client-1") + + +if __name__ == "__main__": + unittest.main() From 9cd2a2355339ee6c91723cae3a79dfa0f4ca856d Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 13:11:51 +0100 Subject: [PATCH 02/19] token cache --- src/kognic/auth/cli/get_access_token.py | 27 ++- src/kognic/auth/cli/token_cache.py | 225 +++++++++++++++++------- src/kognic/auth/env_config.py | 6 +- uv.lock | 154 +++++++++++++++- 4 files changed, 334 insertions(+), 78 deletions(-) diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index ef4409a..d7a11f6 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -4,6 +4,8 @@ import sys from kognic.auth import DEFAULT_HOST +from kognic.auth.cli.token_cache import make_cache +from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession @@ -36,10 +38,10 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: help="Use a specific environment from the config file", ) token_parser.add_argument( - "--no-cache", - action="store_true", - default=False, - help="Skip reading/writing cached tokens from the system keyring", + "--token-cache", + choices=["auto", "keyring", "file", "none"], + default="auto", + help="Token cache backend: auto (default), keyring, file, or none", ) @@ -61,18 +63,15 @@ def run(parsed: argparse.Namespace) -> int: auth_host = host or DEFAULT_HOST - # Try loading a cached token from the keyring - if not parsed.no_cache: - from kognic.auth.cli.token_cache import load_cached_token - from kognic.auth.credentials_parser import resolve_credentials - + cache = make_cache(parsed.token_cache) + if cache is not None: try: client_id, _ = resolve_credentials(credentials) except Exception: client_id = None if client_id: - cached = load_cached_token(auth_host, client_id) + cached = cache.load(auth_host, client_id) if cached: print(cached["access_token"]) return 0 @@ -84,15 +83,13 @@ def run(parsed: argparse.Namespace) -> int: # Access .session to trigger token fetch _ = session.session - # Save the freshly fetched token to keyring - if not parsed.no_cache: + # Save the freshly fetched token to the cache + if cache is not None: token = session.token if isinstance(token, dict): - from kognic.auth.cli.token_cache import save_token - cid = session.oauth_session.client_id if cid: - save_token(auth_host, cid, token) + cache.save(auth_host, cid, token) print(session.access_token) return 0 diff --git a/src/kognic/auth/cli/token_cache.py b/src/kognic/auth/cli/token_cache.py index ede526d..4ced1cb 100644 --- a/src/kognic/auth/cli/token_cache.py +++ b/src/kognic/auth/cli/token_cache.py @@ -1,4 +1,4 @@ -"""CLI token cache using the system keyring. +"""CLI token cache backends. This module is used ONLY by the CLI commands, not the library API. All keyring imports are lazy so the module works when keyring is not installed. @@ -9,8 +9,12 @@ import json import logging import time +from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional +from kognic.auth.env_config import _DEFAULT_CACHE_PATH + log = logging.getLogger(__name__) SERVICE_NAME = "kognic-auth" @@ -20,76 +24,175 @@ EXPIRY_MARGIN_SECONDS = 30 -def _keyring_available() -> bool: - """Check if the keyring package is installed and a usable backend exists.""" - try: - import keyring +def _make_key(auth_server: str, client_id: str) -> str: + return f"{auth_server}:{client_id}" - backend = keyring.get_keyring() - # The "fail" backend is not usable - if "fail" in type(backend).__name__.lower(): - return False - return True - except Exception: + +def _is_valid(token: dict) -> bool: + expires_at = token.get("expires_at") + if expires_at is None: return False + return time.time() < (expires_at - EXPIRY_MARGIN_SECONDS) -def _make_key(auth_server: str, client_id: str) -> str: - return f"{auth_server}:{client_id}" +class TokenCache(ABC): + """Abstract base class for CLI token caches.""" + @abstractmethod + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + """Return a non-expired token dict, or None.""" -def load_cached_token(auth_server: str, client_id: str) -> Optional[dict]: - """Load a non-expired token from the keyring. + @abstractmethod + def save(self, auth_server: str, client_id: str, token: dict) -> None: + """Persist a token dict. Silently ignores errors.""" - Returns the token dict if found and still valid, otherwise None. - """ - if not _keyring_available(): - return None - try: - import keyring + @abstractmethod + def clear(self, auth_server: str, client_id: str) -> None: + """Remove a cached token. Silently ignores errors.""" + + +_KEYRING_MISSING = object() # sentinel: import attempted but unavailable - key = _make_key(auth_server, client_id) - stored = keyring.get_password(SERVICE_NAME, key) - if stored is None: + +class KeyringTokenCache(TokenCache): + """Token cache backed by the system keyring.""" + + def __init__(self) -> None: + self._keyring_module = None # not yet resolved + + def _keyring(self): + """Return the keyring module if usable, else None. Result is cached.""" + if self._keyring_module is _KEYRING_MISSING: return None - token = json.loads(stored) - expires_at = token.get("expires_at") - if expires_at is None: - log.debug("Cached token has no expires_at, discarding") + if self._keyring_module is not None: + return self._keyring_module + try: + import keyring + + backend = keyring.get_keyring() + if "fail" in type(backend).__name__.lower(): + raise RuntimeError("unusable keyring backend") + self._keyring_module = keyring + except Exception: + self._keyring_module = _KEYRING_MISSING return None - if time.time() >= (expires_at - EXPIRY_MARGIN_SECONDS): - log.debug("Cached token expired, discarding") + return self._keyring_module + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + kr = self._keyring() + if kr is None: + return None + try: + key = _make_key(auth_server, client_id) + stored = kr.get_password(SERVICE_NAME, key) + if stored is None: + return None + token = json.loads(stored) + if not _is_valid(token): + log.debug("Cached keyring token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from keyring (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from keyring", exc_info=True) return None - log.debug("Using cached token from keyring (expires_at=%s)", expires_at) - return token - except Exception: - log.debug("Failed to load token from keyring", exc_info=True) - return None + def save(self, auth_server: str, client_id: str, token: dict) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = _make_key(auth_server, client_id) + kr.set_password(SERVICE_NAME, key, json.dumps(token)) + log.debug("Saved token to keyring for key=%s", key) + except Exception: + log.debug("Failed to save token to keyring", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = _make_key(auth_server, client_id) + kr.delete_password(SERVICE_NAME, key) + log.debug("Cleared cached token from keyring for key=%s", key) + except Exception: + log.debug("Failed to clear token from keyring", exc_info=True) + + +class FileTokenCache(TokenCache): + """Token cache backed by a JSON file on disk.""" + + def __init__(self, path: Path = _DEFAULT_CACHE_PATH) -> None: + self.path = path + + def _load_all(self) -> dict: + try: + return json.loads(self.path.read_text()) + except FileNotFoundError: + return {} + except Exception: + log.debug("Failed to read token cache file", exc_info=True) + return {} + + def _save_all(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2)) + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + try: + key = _make_key(auth_server, client_id) + token = self._load_all().get(key) + if token is None: + return None + if not _is_valid(token): + log.debug("Cached file token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from file (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from file cache", exc_info=True) + return None -def save_token(auth_server: str, client_id: str, token: dict) -> None: - """Save a token dict to the keyring. Silently ignores errors.""" - if not _keyring_available(): - return - try: - import keyring - - key = _make_key(auth_server, client_id) - keyring.set_password(SERVICE_NAME, key, json.dumps(token)) - log.debug("Saved token to keyring for key=%s", key) - except Exception: - log.debug("Failed to save token to keyring", exc_info=True) - - -def clear_token(auth_server: str, client_id: str) -> None: - """Remove a cached token from the keyring. Silently ignores errors.""" - if not _keyring_available(): - return - try: - import keyring - - key = _make_key(auth_server, client_id) - keyring.delete_password(SERVICE_NAME, key) - log.debug("Cleared cached token from keyring for key=%s", key) - except Exception: - log.debug("Failed to clear token from keyring", exc_info=True) + def save(self, auth_server: str, client_id: str, token: dict) -> None: + try: + key = _make_key(auth_server, client_id) + data = self._load_all() + data[key] = token + self._save_all(data) + log.debug("Saved token to file cache for key=%s", key) + except Exception: + log.debug("Failed to save token to file cache", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + try: + key = _make_key(auth_server, client_id) + data = self._load_all() + if key in data: + del data[key] + self._save_all(data) + log.debug("Cleared cached token from file cache for key=%s", key) + except Exception: + log.debug("Failed to clear token from file cache", exc_info=True) + + +def make_cache(mode: str) -> TokenCache | None: + """Return a TokenCache for the given mode, or None for 'none'. + + Modes: + auto – keyring if available, file otherwise (default) + keyring – system keyring only + file – file-based cache only + none – no caching + """ + if mode == "none": + return None + if mode == "file": + return FileTokenCache() + if mode == "keyring": + return KeyringTokenCache() + # auto + candidate = KeyringTokenCache() + if candidate._keyring() is not None: + return candidate + return FileTokenCache() diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 8bf6b33..14492ab 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -7,7 +7,11 @@ from kognic.auth import DEFAULT_HOST -DEFAULT_ENV_CONFIG_FILE_PATH = str(Path("~") / ".config" / "kognic" / "environments.json") +DEFAULT_ENV_CONFIG_FILE_PATH = ( + Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "kognic" / "environments.json" +) + +_DEFAULT_CACHE_PATH = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "kognic-auth" / "tokens.json" @dataclass diff --git a/uv.lock b/uv.lock index cb43126..1b727bd 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -377,6 +386,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -401,6 +422,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "kognic-auth" source = { editable = "." } @@ -411,11 +496,15 @@ dependencies = [ [package.optional-dependencies] full = [ { name = "httpx" }, + { name = "keyring" }, { name = "requests" }, ] httpx = [ { name = "httpx" }, ] +keyring = [ + { name = "keyring" }, +] requests = [ { name = "requests" }, ] @@ -423,6 +512,7 @@ requests = [ [package.dev-dependencies] dev = [ { name = "httpx" }, + { name = "keyring" }, { name = "pydantic" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10'" }, @@ -434,19 +524,31 @@ requires-dist = [ { name = "authlib", specifier = ">=0.14.1,<1.7" }, { name = "httpx", marker = "extra == 'full'", specifier = ">=0.20,<1" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.20,<1" }, + { name = "keyring", marker = "extra == 'full'", specifier = ">=23.0" }, + { name = "keyring", marker = "extra == 'keyring'", specifier = ">=23.0" }, { name = "requests", marker = "extra == 'full'", specifier = ">=2.20,<3" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.20,<3" }, ] -provides-extras = ["httpx", "requests", "full"] +provides-extras = ["httpx", "requests", "keyring", "full"] [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.20,<1" }, + { name = "keyring", specifier = ">=23.0" }, { name = "pydantic", specifier = ">=2" }, { name = "pytest" }, { name = "requests", specifier = ">=2.20,<3" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -686,6 +788,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -701,6 +812,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.kognic.io/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.10'" }, + { name = "jeepney", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.kognic.io/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version >= '3.10'" }, + { name = "jeepney", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -784,3 +927,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6 wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 73efaa450d53fc549536d3dd5a493854330d237f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 16:05:17 +0100 Subject: [PATCH 03/19] token cache --- src/kognic/auth/cli/__init__.py | 7 +- src/kognic/auth/cli/api_request.py | 77 +++++----------- src/kognic/auth/cli/get_access_token.py | 41 +++------ src/kognic/auth/requests/auth_session.py | 16 +++- src/kognic/auth/requests/base_client.py | 106 ++++++++++------------- 5 files changed, 100 insertions(+), 147 deletions(-) diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py index d595de3..fb20ca0 100644 --- a/src/kognic/auth/cli/__init__.py +++ b/src/kognic/auth/cli/__init__.py @@ -15,6 +15,7 @@ def create_parser() -> argparse.ArgumentParser: prog="kognic-auth", description="Kognic authentication CLI", ) + parser.add_argument("-v", "--verbose", action="store_true", default=False, help="Enable debug logging") subparsers = parser.add_subparsers(dest="command", help="Available commands") for subcommand in _SUBCOMMANDS: @@ -23,17 +24,17 @@ def create_parser() -> argparse.ArgumentParser: return parser -def _configure_logging() -> None: +def _configure_logging(verbose: bool = False) -> None: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logging.getLogger("kognic.auth").addHandler(handler) - logging.getLogger("kognic.auth").setLevel(logging.WARNING) + logging.getLogger("kognic.auth").setLevel(logging.DEBUG if verbose else logging.WARNING) def main(args: list[str] | None = None) -> int: - _configure_logging() parser = create_parser() parsed = parser.parse_args(args) + _configure_logging(verbose=parsed.verbose) for subcommand in _SUBCOMMANDS: if parsed.command == subcommand.COMMAND: diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index fc4a903..5988bcc 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -7,7 +7,11 @@ import sys from typing import Any +from kognic.auth.cli import _configure_logging +from kognic.auth.cli.token_cache import make_cache +from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment +from kognic.auth.requests.base_client import create_session METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] @@ -42,11 +46,13 @@ def _create_parser() -> argparse.ArgumentParser: help="Output format: json (default), jsonl (one JSON object per line), csv, tsv, table (markdown)", ) parser.add_argument( - "--no-cache", - action="store_true", - default=False, - help="Skip reading/writing cached tokens from the system keyring", + "--token-cache", + choices=["auto", "keyring", "file", "none"], + default="auto", + help="Token cache backend: auto (default), keyring, file, or none. " + "Auto will use keyring if available, otherwise file.", ) + parser.add_argument("-v", "--verbose", action="store_true", default=False, help="Enable debug logging") return parser @@ -159,51 +165,15 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: print(response.text) -def _create_authenticated_session(*, auth, auth_host, use_cache=True): - """Create an authenticated session, optionally using cached tokens from the keyring. - - Constructs RequestsAuthSession directly (instead of via create_session) so that - a cached token can be injected before the session property triggers a network fetch. - """ - import requests - from requests.adapters import HTTPAdapter - - from kognic.auth._user_agent import get_user_agent - from kognic.auth.requests.auth_session import RequestsAuthSession - from kognic.auth.requests.base_client import DEFAULT_RETRY - - auth_session = RequestsAuthSession(auth=auth, host=auth_host) - had_token = False - - if use_cache: - from kognic.auth.cli.token_cache import load_cached_token - - client_id = auth_session.oauth_session.client_id - if client_id: - cached = load_cached_token(auth_host, client_id) - if cached: - auth_session.oauth_session.token = cached - had_token = True - - # Access .session — skips network fetch if token was injected above - session = auth_session.session - - # Apply session enhancements (retry + user-agent) - session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", None) - session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) - session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) - - # Save freshly fetched token to keyring - if use_cache and not had_token: - token = auth_session.token - if isinstance(token, dict): - from kognic.auth.cli.token_cache import save_token - - client_id = auth_session.oauth_session.client_id - if client_id: - save_token(auth_host, client_id, token) - - return session +def _create_authenticated_session(*, auth, auth_host, cache_mode: str = "auto"): + cache = make_cache(cache_mode) + client_id, client_secret = resolve_credentials(auth) + return create_session( + auth=(client_id, client_secret), + auth_host=auth_host, + initial_token=cache.load(auth_host, client_id) if (cache and client_id) else None, + on_token_updated=(lambda t: cache.save(auth_host, client_id, t)) if (cache and client_id) else None, + ) def run(parsed: argparse.Namespace) -> int: @@ -217,7 +187,7 @@ def run(parsed: argparse.Namespace) -> int: session = _create_authenticated_session( auth=env.credentials, auth_host=env.auth_server, - use_cache=not parsed.no_cache, + cache_mode=parsed.token_cache, ) response = session.request( @@ -227,9 +197,6 @@ def run(parsed: argparse.Namespace) -> int: headers=headers if headers else None, ) - from kognic.auth.requests.base_client import _check_response - - _check_response(response) _print_response(response, output_format=parsed.output_format) return 0 if response.ok else 1 @@ -242,9 +209,7 @@ def run(parsed: argparse.Namespace) -> int: def main(args: list[str] | None = None) -> None: - from kognic.auth.cli import _configure_logging - - _configure_logging() parser = _create_parser() parsed = parser.parse_args(args) + _configure_logging(verbose=parsed.verbose) sys.exit(run(parsed)) diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index d7a11f6..76fcee0 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -41,7 +41,8 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: "--token-cache", choices=["auto", "keyring", "file", "none"], default="auto", - help="Token cache backend: auto (default), keyring, file, or none", + help="Token cache backend: auto (default), keyring, file, or none. " + "Auto will use keyring if available, otherwise file-based caching.", ) @@ -53,8 +54,9 @@ def run(parsed: argparse.Namespace) -> int: if parsed.env_name: config = load_kognic_env_config(parsed.env_config_file_path) if parsed.env_name not in config.environments: - print(f"Error: Unknown environment: {parsed.env_name}", file=sys.stderr) - return 1 + raise ValueError( + f"Environment '{parsed.env_name}' not found in config file '{parsed.env_config_file_path}'" + ) ctx = config.environments[parsed.env_name] if host is None: host = ctx.auth_server @@ -64,34 +66,15 @@ def run(parsed: argparse.Namespace) -> int: auth_host = host or DEFAULT_HOST cache = make_cache(parsed.token_cache) - if cache is not None: - try: - client_id, _ = resolve_credentials(credentials) - except Exception: - client_id = None - - if client_id: - cached = cache.load(auth_host, client_id) - if cached: - print(cached["access_token"]) - return 0 - - session = RequestsAuthSession( - auth=credentials, + client_id, client_secret = resolve_credentials(credentials) + auth_session = RequestsAuthSession( + auth=(client_id, client_secret), host=auth_host, + initial_token=cache.load(auth_host, client_id) if (cache and client_id) else None, + on_token_updated=(lambda t: cache.save(auth_host, client_id, t)) if (cache and client_id) else None, ) - # Access .session to trigger token fetch - _ = session.session - - # Save the freshly fetched token to the cache - if cache is not None: - token = session.token - if isinstance(token, dict): - cid = session.oauth_session.client_id - if cid: - cache.save(auth_host, cid, token) - - print(session.access_token) + _ = auth_session.session # trigger fetch if no valid initial_token + print(auth_session.access_token) return 0 except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/src/kognic/auth/requests/auth_session.py b/src/kognic/auth/requests/auth_session.py index 99e1843..fc25241 100644 --- a/src/kognic/auth/requests/auth_session.py +++ b/src/kognic/auth/requests/auth_session.py @@ -1,6 +1,6 @@ import logging import threading -from typing import Optional +from typing import Callable, Optional import requests from authlib.common.errors import AuthlibBaseError @@ -44,6 +44,8 @@ def __init__( client_secret: Optional[str] = None, host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + initial_token: Optional[dict] = None, + on_token_updated: Optional[Callable[[dict], None]] = None, **kwargs, ): """Initialize the auth session. @@ -54,19 +56,25 @@ def __init__( client_secret: Client secret for authentication host: Base url for authentication server token_endpoint: Relative path to the token endpoint + initial_token: Pre-fetched token dict to inject, skipping the initial network fetch if valid. + on_token_updated: Callback invoked with the new token dict whenever a fresh token is fetched. **kwargs: Additional params to pass into Client Constructor """ self.host = host self.token_url = f"{host}{token_endpoint}" client_id, client_secret = resolve_credentials(auth, client_id, client_secret) + self._client_id = client_id + self._on_token_updated = on_token_updated self.oauth_session = _FixedSession( client_id=client_id, client_secret=client_secret, token_endpoint_auth_method="client_secret_post", + grant_type="client_credentials", update_token=self._update_token, token_endpoint=self.token_url, + token=initial_token, **kwargs, ) self.oauth_session.register_compliance_hook("access_token_response", AuthClient.check_rate_limit) @@ -74,12 +82,18 @@ def __init__( self._lock = threading.RLock() + @property + def client_id(self) -> Optional[str]: + return self._client_id + @property def token(self): return self.oauth_session.token def _update_token(self, token, access_token=None, refresh_token=None): self._log_new_token() + if self._on_token_updated is not None: + self._on_token_updated(token) @property def session(self): diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 041147c..75d2839 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -4,9 +4,8 @@ import logging import os -from functools import lru_cache from threading import Lock -from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union if TYPE_CHECKING: from typing import Self @@ -18,7 +17,6 @@ from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent -from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession from kognic.auth.serde import serialize_body @@ -26,23 +24,6 @@ logger = logging.getLogger(__name__) -@lru_cache(maxsize=None) -def _create_cached_oauth_session( - auth_tuple: Optional[Tuple[str, str]], - auth_host: str, - auth_token_endpoint: str, -) -> Session: - """Create and cache an OAuth session by credentials. - - Caching avoids creating multiple sessions for the same credentials. - """ - return RequestsAuthSession( - auth=auth_tuple, - host=auth_host, - token_endpoint=auth_token_endpoint, - ).session - - DEFAULT_RETRY = Retry(total=3, connect=3, read=3, backoff_factor=0.5, status_forcelist=[502, 503, 504]) @@ -65,17 +46,41 @@ def _check_response(resp: requests.Response): ) from e -def _resolve_auth_tuple( - auth: Optional[Union[str, os.PathLike, tuple]], - client_id: Optional[str], - client_secret: Optional[str], -) -> Optional[Tuple[str, str]]: - """Resolve auth parameters to a (client_id, client_secret) tuple for caching.""" +def _set_session_user_agent(session: Session, client_name: Optional[str] = None): + """Set the User-Agent header for the session, including the client name if provided.""" + session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) + + +def _monkey_patch_send(session: Session, json_serializer: Callable[[Any], Any]): + """ + Monkey patch to serialize JSON and validate paths + :param session: + :param json_serializer: + :return: + """ + vanilla_prep = session.prepare_request + + def prepare_request(req, *args, **kwargs): + if req.url.startswith("/"): + raise ValueError(f"Path must not start with /, got {req.url}") + + # Accept anything jsonable as json, serialize it + if req.json is not None: + req.json = json_serializer(req.json) + + return vanilla_prep(req, *args, **kwargs) + + session.prepare_request = prepare_request + + # Monkey patch to always raise for status and handle errors + vanilla_send = session.send + + def send_request(req, *args, **kwargs): + resp = vanilla_send(req, *args, **kwargs) + _check_response(resp) + return resp - resolved_id, resolved_secret = resolve_credentials(auth, client_id, client_secret) - if resolved_id and resolved_secret: - return resolved_id, resolved_secret - return None + session.send = send_request def create_session( @@ -85,6 +90,8 @@ def create_session( auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = None, json_serializer: Callable[[Any], Any] = serialize_body, + initial_token: Optional[dict] = None, + on_token_updated: Optional[Callable[[dict], None]] = None, ) -> Session: """Create a requests session with enhancements. @@ -100,43 +107,26 @@ def create_session( auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent header json_serializer: Callable to serialize request bodies. Defaults to serialize_body. + initial_token: Pre-fetched token dict to inject, skipping the initial network fetch if valid. + on_token_updated: Callback invoked with the new token dict whenever a fresh token is fetched. Returns: Configured requests Session """ - # Resolve credentials and get cached OAuth session - auth_tuple = _resolve_auth_tuple(auth, client_id=None, client_secret=None) - session = _create_cached_oauth_session(auth_tuple, auth_host, auth_token_endpoint) + session = RequestsAuthSession( + auth=auth, + host=auth_host, + token_endpoint=auth_token_endpoint, + initial_token=initial_token, + on_token_updated=on_token_updated, + ).session - session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) + _set_session_user_agent(session, client_name) + _monkey_patch_send(session, json_serializer) session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) - # Monkey patch to serialize JSON and validate paths - vanilla_prep = session.prepare_request - - def prepare_request(req, *args, **kwargs): - if req.url.startswith("/"): - raise ValueError(f"Path must not start with /, got {req.url}") - - # Accept anything jsonable as json, serialize it - if req.json is not None: - req.json = json_serializer(req.json) - - return vanilla_prep(req, *args, **kwargs) - - session.prepare_request = prepare_request - - # Monkey patch to always raise for status and handle errors - vanilla_send = session.send - - def send_request(req, *args, **kwargs): - resp = vanilla_send(req, *args, **kwargs) - _check_response(resp) - return resp - - session.send = send_request return session From ce918ae4d5b092f4a18246312b6a65a801a48a41 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 16:20:54 +0100 Subject: [PATCH 04/19] token cache --- pyproject.toml | 8 ++++++-- src/kognic/auth/__init__.py | 9 +++++++++ src/kognic/auth/cli/token_cache.py | 4 ++-- src/kognic/auth/env_config.py | 10 ++-------- tests/test_cli.py | 24 ++++++++++++++---------- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe215d6..7fddae0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,14 +43,18 @@ httpx = [ requests = [ "requests>=2.20,<3" ] -keyring = [ - "keyring>=23.0" + +cli = [ + "keyring>=23.0", + "requests>=2.20,<3" ] + full = [ "httpx>=0.20,<1", "requests>=2.20,<3", "keyring>=23.0" ] + [dependency-groups] dev = [ "httpx>=0.20,<1", diff --git a/src/kognic/auth/__init__.py b/src/kognic/auth/__init__.py index bbe4d15..9ccc273 100644 --- a/src/kognic/auth/__init__.py +++ b/src/kognic/auth/__init__.py @@ -1,5 +1,7 @@ import logging +import os from logging import NullHandler +from pathlib import Path logging.getLogger(__name__).addHandler(NullHandler()) @@ -9,4 +11,11 @@ __version__ = "0.0.0" DEFAULT_HOST = "https://auth.app.kognic.com" +DEFAULT_KOGNIC_PLATFORM = "app.kognic.com" DEFAULT_TOKEN_ENDPOINT_RELPATH = "/v1/auth/oauth/token" + +DEFAULT_ENV_CONFIG_FILE_PATH = ( + Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "kognic" / "environments.json" +) + +DEFAULT_CACHE_PATH = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "kognic-auth" / "tokens.json" diff --git a/src/kognic/auth/cli/token_cache.py b/src/kognic/auth/cli/token_cache.py index 4ced1cb..8a5d4cb 100644 --- a/src/kognic/auth/cli/token_cache.py +++ b/src/kognic/auth/cli/token_cache.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Optional -from kognic.auth.env_config import _DEFAULT_CACHE_PATH +from kognic.auth import DEFAULT_CACHE_PATH log = logging.getLogger(__name__) @@ -123,7 +123,7 @@ def clear(self, auth_server: str, client_id: str) -> None: class FileTokenCache(TokenCache): """Token cache backed by a JSON file on disk.""" - def __init__(self, path: Path = _DEFAULT_CACHE_PATH) -> None: + def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None: self.path = path def _load_all(self) -> dict: diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 14492ab..8a446a3 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -5,13 +5,7 @@ from typing import Optional, Union from urllib.parse import urlparse -from kognic.auth import DEFAULT_HOST - -DEFAULT_ENV_CONFIG_FILE_PATH = ( - Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "kognic" / "environments.json" -) - -_DEFAULT_CACHE_PATH = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "kognic-auth" / "tokens.json" +from kognic.auth import DEFAULT_ENV_CONFIG_FILE_PATH, DEFAULT_HOST, DEFAULT_KOGNIC_PLATFORM @dataclass @@ -91,7 +85,7 @@ def resolve_environment(config: KognicEnvConfig, url: str, env_name: Optional[st # No config at all — use default auth server with env var credentials return Environment( name="default", - host="app.kognic.com", + host=DEFAULT_KOGNIC_PLATFORM, auth_server=DEFAULT_HOST, credentials=None, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c67819c..591d726 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -193,9 +193,7 @@ def test_main_with_context_server_override(self, mock_load_config, mock_session_ mock_session_class.return_value = mock_session with mock.patch("builtins.print"): - result = main( - ["get-access-token", "--env", "demo", "--server", "https://custom.server", "--no-cache"] - ) + result = main(["get-access-token", "--env", "demo", "--server", "https://custom.server", "--no-cache"]) self.assertEqual(result, 0) mock_session_class.assert_called_once_with( @@ -1002,9 +1000,7 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ credentials="/path/to/demo-creds.json", ) - with mock.patch( - "kognic.auth.cli.api_request._create_authenticated_session" - ) as mock_create_session: + with mock.patch("kognic.auth.cli.api_request._create_authenticated_session") as mock_create_session: mock_session = mock.MagicMock() mock_response = mock.MagicMock() mock_response.ok = True @@ -1068,8 +1064,9 @@ def test_kog_uses_cached_token( mock_session_class.return_value = mock_auth_session parser = create_kog_parser() - parsed = parser.parse_args(["get", "https://app.kognic.com/v1/projects", - "--env-config-file-path", "/nonexistent/config.json"]) + parsed = parser.parse_args( + ["get", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/nonexistent/config.json"] + ) with mock.patch("builtins.print"): result = call_run(parsed) @@ -1097,8 +1094,15 @@ def test_kog_no_cache_passes_flag(self, mock_create_session, mock_load_config, m mock_create_session.return_value = mock_session parser = create_kog_parser() - parsed = parser.parse_args(["get", "https://app.kognic.com/v1/projects", - "--env-config-file-path", "/nonexistent/config.json", "--no-cache"]) + parsed = parser.parse_args( + [ + "get", + "https://app.kognic.com/v1/projects", + "--env-config-file-path", + "/nonexistent/config.json", + "--no-cache", + ] + ) with mock.patch("builtins.print"): call_run(parsed) From 001dfcb0e1114736d3c881143fad3747c4802280 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 16:55:16 +0100 Subject: [PATCH 05/19] Add shared token provider pool and bearer auth to BaseApiClient Separate token management from HTTP session configuration: - RequestsAuthSession gains ensure_token() and invalidate_token() as first-class methods - _KognicBearerAuth (requests.AuthBase) injects Bearer tokens per request and handles 401 by invalidating and retrying once - create_session builds a plain requests.Session per call, eliminating monkey-patch stacking when multiple clients share credentials - Module-level WeakValueDictionary pool keyed on (client_id, auth_host, token_endpoint) ensures BaseApiClient instances with the same credentials share one token provider by default - BaseApiClient accepts explicit token_provider= for opt-in sharing or test injection Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/requests/auth_session.py | 14 +++- src/kognic/auth/requests/base_client.py | 102 ++++++++++++++++++++--- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/kognic/auth/requests/auth_session.py b/src/kognic/auth/requests/auth_session.py index fc25241..e70ec10 100644 --- a/src/kognic/auth/requests/auth_session.py +++ b/src/kognic/auth/requests/auth_session.py @@ -95,12 +95,20 @@ def _update_token(self, token, access_token=None, refresh_token=None): if self._on_token_updated is not None: self._on_token_updated(token) - @property - def session(self): + def ensure_token(self) -> dict: + """Return a valid token, fetching one if needed. Thread-safe.""" if not self.token: with self._lock: if not self.token: - # check again when coming out of the lock that the token is still not set token = self.oauth_session.fetch_access_token(url=self.token_url) self._update_token(token) + return self.token + + def invalidate_token(self) -> None: + """Clear the cached token so the next ensure_token() call fetches a fresh one.""" + self.oauth_session.token = None + + @property + def session(self): + self.ensure_token() return self.oauth_session.session diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 75d2839..e2fd60a 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -4,8 +4,10 @@ import logging import os +import threading from threading import Lock from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from weakref import WeakValueDictionary if TYPE_CHECKING: from typing import Self @@ -17,6 +19,7 @@ from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent +from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession from kognic.auth.serde import serialize_body @@ -46,6 +49,32 @@ def _check_response(resp: requests.Response): ) from e +class _KognicBearerAuth(requests.AuthBase): + """Injects a Bearer token from a RequestsAuthSession into each request. + + Handles 401 responses by invalidating the cached token and retrying once. + """ + + def __init__(self, provider: RequestsAuthSession) -> None: + self._provider = provider + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + r.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" + r.register_hook("response", self._handle_401) + return r + + def _handle_401(self, r: requests.Response, **kwargs) -> requests.Response: + if r.status_code != 401: + return r + self._provider.invalidate_token() + _ = r.content # drain socket so connection can be reused + prep = r.request.copy() + prep.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + return _r + + def _set_session_user_agent(session: Session, client_name: Optional[str] = None): """Set the User-Agent header for the session, including the client name if provided.""" session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) @@ -92,6 +121,7 @@ def create_session( json_serializer: Callable[[Any], Any] = serialize_body, initial_token: Optional[dict] = None, on_token_updated: Optional[Callable[[dict], None]] = None, + token_provider: Optional[RequestsAuthSession] = None, ) -> Session: """Create a requests session with enhancements. @@ -109,27 +139,65 @@ def create_session( json_serializer: Callable to serialize request bodies. Defaults to serialize_body. initial_token: Pre-fetched token dict to inject, skipping the initial network fetch if valid. on_token_updated: Callback invoked with the new token dict whenever a fresh token is fetched. + token_provider: Explicit token provider to use. When given, auth/initial_token/on_token_updated + are ignored. Multiple sessions sharing one provider share the same token lifecycle. Returns: Configured requests Session """ - session = RequestsAuthSession( - auth=auth, - host=auth_host, - token_endpoint=auth_token_endpoint, - initial_token=initial_token, - on_token_updated=on_token_updated, - ).session - + if token_provider is None: + token_provider = RequestsAuthSession( + auth=auth, + host=auth_host, + token_endpoint=auth_token_endpoint, + initial_token=initial_token, + on_token_updated=on_token_updated, + ) + + session = requests.Session() + session.auth = _KognicBearerAuth(token_provider) _set_session_user_agent(session, client_name) _monkey_patch_send(session, json_serializer) - session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) - return session +_provider_pool: WeakValueDictionary[tuple, RequestsAuthSession] = WeakValueDictionary() +_provider_pool_lock = threading.Lock() + + +def _get_shared_provider( + auth: Optional[Union[str, os.PathLike, tuple]], + auth_host: str, + auth_token_endpoint: str, +) -> RequestsAuthSession: + """Return a shared RequestsAuthSession for the given credentials, creating one if needed. + + Providers are keyed by (client_id, auth_host, auth_token_endpoint) and held weakly, + so they are GC'd once no BaseApiClient instances reference them. + """ + try: + client_id, client_secret = resolve_credentials(auth) + except Exception: + client_id, client_secret = None, None + + if not client_id or not client_secret: + return RequestsAuthSession(auth=auth, host=auth_host, token_endpoint=auth_token_endpoint) + + key = (client_id, auth_host, auth_token_endpoint) + with _provider_pool_lock: + provider = _provider_pool.get(key) + if provider is None: + provider = RequestsAuthSession( + auth=(client_id, client_secret), + host=auth_host, + token_endpoint=auth_token_endpoint, + ) + _provider_pool[key] = provider + return provider + + class BaseApiClient: """Base API client with OAuth2 authentication using requests. @@ -140,6 +208,9 @@ class BaseApiClient: - Sunset header handling - Enhanced error messages + Clients with the same credentials and auth host automatically share a token provider, + so only one token fetch occurs across all instances with those credentials. + The interface is consistent with requests - use session.get(), session.post(), etc. Calls return the response object. Use response.json() to get the data. @@ -157,6 +228,7 @@ def __init__( auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, + token_provider: Optional[RequestsAuthSession] = None, ): """Initialize the API client. @@ -166,12 +238,15 @@ def __init__( auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. json_serializer: Callable to serialize request bodies. Defaults to serialize_body. + token_provider: Explicit token provider to share across clients. When omitted, a shared + provider is looked up (or created) by credentials + auth_host. """ self._session: Optional[Session] = None self._auth = auth self._auth_host = auth_host self._auth_token_endpoint = auth_token_endpoint self._json_serializer = json_serializer + self._token_provider = token_provider self._lock = Lock() if client_name == "auto": @@ -187,10 +262,11 @@ def session(self) -> Session: if self._session is None: with self._lock: if self._session is None: + provider = self._token_provider or _get_shared_provider( + self._auth, self._auth_host, self._auth_token_endpoint + ) self._session = create_session( - auth=self._auth, - auth_host=self._auth_host, - auth_token_endpoint=self._auth_token_endpoint, + token_provider=provider, client_name=self._client_name, json_serializer=self._json_serializer, ) From 7831ce99c6dec3f8c33e16ed8ee2bdfb737dceb7 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 16:59:52 +0100 Subject: [PATCH 06/19] Fix AuthBase import: import from requests.auth, not requests Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/requests/base_client.py | 3 ++- uv.lock | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index e2fd60a..da0670e 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -15,6 +15,7 @@ import requests from requests import Session from requests.adapters import HTTPAdapter, Retry +from requests.auth import AuthBase from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset @@ -49,7 +50,7 @@ def _check_response(resp: requests.Response): ) from e -class _KognicBearerAuth(requests.AuthBase): +class _KognicBearerAuth(AuthBase): """Injects a Bearer token from a RequestsAuthSession into each request. Handles 401 responses by invalidating the cached token and retrying once. diff --git a/uv.lock b/uv.lock index 1b727bd..fce4b55 100644 --- a/uv.lock +++ b/uv.lock @@ -494,6 +494,10 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "keyring" }, + { name = "requests" }, +] full = [ { name = "httpx" }, { name = "keyring" }, @@ -502,9 +506,6 @@ full = [ httpx = [ { name = "httpx" }, ] -keyring = [ - { name = "keyring" }, -] requests = [ { name = "requests" }, ] @@ -524,12 +525,13 @@ requires-dist = [ { name = "authlib", specifier = ">=0.14.1,<1.7" }, { name = "httpx", marker = "extra == 'full'", specifier = ">=0.20,<1" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.20,<1" }, + { name = "keyring", marker = "extra == 'cli'", specifier = ">=23.0" }, { name = "keyring", marker = "extra == 'full'", specifier = ">=23.0" }, - { name = "keyring", marker = "extra == 'keyring'", specifier = ">=23.0" }, + { name = "requests", marker = "extra == 'cli'", specifier = ">=2.20,<3" }, { name = "requests", marker = "extra == 'full'", specifier = ">=2.20,<3" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.20,<3" }, ] -provides-extras = ["httpx", "requests", "keyring", "full"] +provides-extras = ["httpx", "requests", "cli", "full"] [package.metadata.requires-dev] dev = [ From b6b1aae780e086c1e2909eb7091648dc02594e1f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:02:10 +0100 Subject: [PATCH 07/19] Move KognicBearerAuth to its own module Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/requests/base_client.py | 30 ++------------------ src/kognic/auth/requests/bearer_auth.py | 37 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 src/kognic/auth/requests/bearer_auth.py diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index da0670e..7738d1d 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -15,7 +15,6 @@ import requests from requests import Session from requests.adapters import HTTPAdapter, Retry -from requests.auth import AuthBase from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset @@ -23,6 +22,7 @@ from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession +from kognic.auth.requests.bearer_auth import KognicBearerAuth from kognic.auth.serde import serialize_body logger = logging.getLogger(__name__) @@ -50,32 +50,6 @@ def _check_response(resp: requests.Response): ) from e -class _KognicBearerAuth(AuthBase): - """Injects a Bearer token from a RequestsAuthSession into each request. - - Handles 401 responses by invalidating the cached token and retrying once. - """ - - def __init__(self, provider: RequestsAuthSession) -> None: - self._provider = provider - - def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: - r.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" - r.register_hook("response", self._handle_401) - return r - - def _handle_401(self, r: requests.Response, **kwargs) -> requests.Response: - if r.status_code != 401: - return r - self._provider.invalidate_token() - _ = r.content # drain socket so connection can be reused - prep = r.request.copy() - prep.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" - _r = r.connection.send(prep, **kwargs) - _r.history.append(r) - return _r - - def _set_session_user_agent(session: Session, client_name: Optional[str] = None): """Set the User-Agent header for the session, including the client name if provided.""" session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) @@ -156,7 +130,7 @@ def create_session( ) session = requests.Session() - session.auth = _KognicBearerAuth(token_provider) + session.auth = KognicBearerAuth(token_provider) _set_session_user_agent(session, client_name) _monkey_patch_send(session, json_serializer) session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) diff --git a/src/kognic/auth/requests/bearer_auth.py b/src/kognic/auth/requests/bearer_auth.py new file mode 100644 index 0000000..3eee88c --- /dev/null +++ b/src/kognic/auth/requests/bearer_auth.py @@ -0,0 +1,37 @@ +"""Bearer token auth handler for requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import requests +from requests.auth import AuthBase + +if TYPE_CHECKING: + from kognic.auth.requests.auth_session import RequestsAuthSession + + +class KognicBearerAuth(AuthBase): + """Injects a Bearer token from a RequestsAuthSession into each request. + + Handles 401 responses by invalidating the cached token and retrying once. + """ + + def __init__(self, provider: RequestsAuthSession) -> None: + self._provider = provider + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + r.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" + r.register_hook("response", self._handle_401) + return r + + def _handle_401(self, r: requests.Response, **kwargs) -> requests.Response: + if r.status_code != 401: + return r + self._provider.invalidate_token() + _ = r.content # drain socket so connection can be reused + prep = r.request.copy() + prep.headers["Authorization"] = f"Bearer {self._provider.ensure_token()['access_token']}" + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + return _r From ccb128024278f8e5c642e83c5592d1a07edd4c98 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:13:16 +0100 Subject: [PATCH 08/19] Move TokenCache to internal package, wire into BaseApiClient TokenCache and its implementations (KeyringTokenCache, FileTokenCache, make_cache) move from cli/ to internal/ so they can be used by both CLI commands and library clients. BaseApiClient gains a token_cache parameter that enables cross-process token persistence. The shared provider pool key now includes the cache type so cached and uncached clients for the same credentials get separate providers. Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/cli/token_cache.py | 206 +----------------------- src/kognic/auth/internal/__init__.py | 0 src/kognic/auth/internal/token_cache.py | 197 ++++++++++++++++++++++ src/kognic/auth/requests/base_client.py | 16 +- 4 files changed, 217 insertions(+), 202 deletions(-) create mode 100644 src/kognic/auth/internal/__init__.py create mode 100644 src/kognic/auth/internal/token_cache.py diff --git a/src/kognic/auth/cli/token_cache.py b/src/kognic/auth/cli/token_cache.py index 8a5d4cb..4f12b33 100644 --- a/src/kognic/auth/cli/token_cache.py +++ b/src/kognic/auth/cli/token_cache.py @@ -1,198 +1,8 @@ -"""CLI token cache backends. - -This module is used ONLY by the CLI commands, not the library API. -All keyring imports are lazy so the module works when keyring is not installed. -""" - -from __future__ import annotations - -import json -import logging -import time -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Optional - -from kognic.auth import DEFAULT_CACHE_PATH - -log = logging.getLogger(__name__) - -SERVICE_NAME = "kognic-auth" - -# Tokens are considered expired this many seconds before their actual expiry, -# to avoid using a token that expires mid-request. -EXPIRY_MARGIN_SECONDS = 30 - - -def _make_key(auth_server: str, client_id: str) -> str: - return f"{auth_server}:{client_id}" - - -def _is_valid(token: dict) -> bool: - expires_at = token.get("expires_at") - if expires_at is None: - return False - return time.time() < (expires_at - EXPIRY_MARGIN_SECONDS) - - -class TokenCache(ABC): - """Abstract base class for CLI token caches.""" - - @abstractmethod - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - """Return a non-expired token dict, or None.""" - - @abstractmethod - def save(self, auth_server: str, client_id: str, token: dict) -> None: - """Persist a token dict. Silently ignores errors.""" - - @abstractmethod - def clear(self, auth_server: str, client_id: str) -> None: - """Remove a cached token. Silently ignores errors.""" - - -_KEYRING_MISSING = object() # sentinel: import attempted but unavailable - - -class KeyringTokenCache(TokenCache): - """Token cache backed by the system keyring.""" - - def __init__(self) -> None: - self._keyring_module = None # not yet resolved - - def _keyring(self): - """Return the keyring module if usable, else None. Result is cached.""" - if self._keyring_module is _KEYRING_MISSING: - return None - if self._keyring_module is not None: - return self._keyring_module - try: - import keyring - - backend = keyring.get_keyring() - if "fail" in type(backend).__name__.lower(): - raise RuntimeError("unusable keyring backend") - self._keyring_module = keyring - except Exception: - self._keyring_module = _KEYRING_MISSING - return None - return self._keyring_module - - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - kr = self._keyring() - if kr is None: - return None - try: - key = _make_key(auth_server, client_id) - stored = kr.get_password(SERVICE_NAME, key) - if stored is None: - return None - token = json.loads(stored) - if not _is_valid(token): - log.debug("Cached keyring token expired or missing expires_at, discarding") - return None - log.debug("Using cached token from keyring (key=%s)", key) - return token - except Exception: - log.debug("Failed to load token from keyring", exc_info=True) - return None - - def save(self, auth_server: str, client_id: str, token: dict) -> None: - kr = self._keyring() - if kr is None: - return - try: - key = _make_key(auth_server, client_id) - kr.set_password(SERVICE_NAME, key, json.dumps(token)) - log.debug("Saved token to keyring for key=%s", key) - except Exception: - log.debug("Failed to save token to keyring", exc_info=True) - - def clear(self, auth_server: str, client_id: str) -> None: - kr = self._keyring() - if kr is None: - return - try: - key = _make_key(auth_server, client_id) - kr.delete_password(SERVICE_NAME, key) - log.debug("Cleared cached token from keyring for key=%s", key) - except Exception: - log.debug("Failed to clear token from keyring", exc_info=True) - - -class FileTokenCache(TokenCache): - """Token cache backed by a JSON file on disk.""" - - def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None: - self.path = path - - def _load_all(self) -> dict: - try: - return json.loads(self.path.read_text()) - except FileNotFoundError: - return {} - except Exception: - log.debug("Failed to read token cache file", exc_info=True) - return {} - - def _save_all(self, data: dict) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text(json.dumps(data, indent=2)) - - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - try: - key = _make_key(auth_server, client_id) - token = self._load_all().get(key) - if token is None: - return None - if not _is_valid(token): - log.debug("Cached file token expired or missing expires_at, discarding") - return None - log.debug("Using cached token from file (key=%s)", key) - return token - except Exception: - log.debug("Failed to load token from file cache", exc_info=True) - return None - - def save(self, auth_server: str, client_id: str, token: dict) -> None: - try: - key = _make_key(auth_server, client_id) - data = self._load_all() - data[key] = token - self._save_all(data) - log.debug("Saved token to file cache for key=%s", key) - except Exception: - log.debug("Failed to save token to file cache", exc_info=True) - - def clear(self, auth_server: str, client_id: str) -> None: - try: - key = _make_key(auth_server, client_id) - data = self._load_all() - if key in data: - del data[key] - self._save_all(data) - log.debug("Cleared cached token from file cache for key=%s", key) - except Exception: - log.debug("Failed to clear token from file cache", exc_info=True) - - -def make_cache(mode: str) -> TokenCache | None: - """Return a TokenCache for the given mode, or None for 'none'. - - Modes: - auto – keyring if available, file otherwise (default) - keyring – system keyring only - file – file-based cache only - none – no caching - """ - if mode == "none": - return None - if mode == "file": - return FileTokenCache() - if mode == "keyring": - return KeyringTokenCache() - # auto - candidate = KeyringTokenCache() - if candidate._keyring() is not None: - return candidate - return FileTokenCache() +"""Re-exports from kognic.auth.internal.token_cache for CLI use.""" + +from kognic.auth.internal.token_cache import ( # noqa: F401 + FileTokenCache, + KeyringTokenCache, + TokenCache, + make_cache, +) diff --git a/src/kognic/auth/internal/__init__.py b/src/kognic/auth/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kognic/auth/internal/token_cache.py b/src/kognic/auth/internal/token_cache.py new file mode 100644 index 0000000..1e8824b --- /dev/null +++ b/src/kognic/auth/internal/token_cache.py @@ -0,0 +1,197 @@ +"""Token cache backends. + +All keyring imports are lazy so the module works when keyring is not installed. +""" + +from __future__ import annotations + +import json +import logging +import time +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +from kognic.auth import DEFAULT_CACHE_PATH + +log = logging.getLogger(__name__) + +SERVICE_NAME = "kognic-auth" + +# Tokens are considered expired this many seconds before their actual expiry, +# to avoid using a token that expires mid-request. +EXPIRY_MARGIN_SECONDS = 30 + + +def _make_key(auth_server: str, client_id: str) -> str: + return f"{auth_server}:{client_id}" + + +def _is_valid(token: dict) -> bool: + expires_at = token.get("expires_at") + if expires_at is None: + return False + return time.time() < (expires_at - EXPIRY_MARGIN_SECONDS) + + +class TokenCache(ABC): + """Abstract base class for token caches.""" + + @abstractmethod + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + """Return a non-expired token dict, or None.""" + + @abstractmethod + def save(self, auth_server: str, client_id: str, token: dict) -> None: + """Persist a token dict. Silently ignores errors.""" + + @abstractmethod + def clear(self, auth_server: str, client_id: str) -> None: + """Remove a cached token. Silently ignores errors.""" + + +_KEYRING_MISSING = object() # sentinel: import attempted but unavailable + + +class KeyringTokenCache(TokenCache): + """Token cache backed by the system keyring.""" + + def __init__(self) -> None: + self._keyring_module = None # not yet resolved + + def _keyring(self): + """Return the keyring module if usable, else None. Result is cached.""" + if self._keyring_module is _KEYRING_MISSING: + return None + if self._keyring_module is not None: + return self._keyring_module + try: + import keyring + + backend = keyring.get_keyring() + if "fail" in type(backend).__name__.lower(): + raise RuntimeError("unusable keyring backend") + self._keyring_module = keyring + except Exception: + self._keyring_module = _KEYRING_MISSING + return None + return self._keyring_module + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + kr = self._keyring() + if kr is None: + return None + try: + key = _make_key(auth_server, client_id) + stored = kr.get_password(SERVICE_NAME, key) + if stored is None: + return None + token = json.loads(stored) + if not _is_valid(token): + log.debug("Cached keyring token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from keyring (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from keyring", exc_info=True) + return None + + def save(self, auth_server: str, client_id: str, token: dict) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = _make_key(auth_server, client_id) + kr.set_password(SERVICE_NAME, key, json.dumps(token)) + log.debug("Saved token to keyring for key=%s", key) + except Exception: + log.debug("Failed to save token to keyring", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = _make_key(auth_server, client_id) + kr.delete_password(SERVICE_NAME, key) + log.debug("Cleared cached token from keyring for key=%s", key) + except Exception: + log.debug("Failed to clear token from keyring", exc_info=True) + + +class FileTokenCache(TokenCache): + """Token cache backed by a JSON file on disk.""" + + def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None: + self.path = path + + def _load_all(self) -> dict: + try: + return json.loads(self.path.read_text()) + except FileNotFoundError: + return {} + except Exception: + log.debug("Failed to read token cache file", exc_info=True) + return {} + + def _save_all(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2)) + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + try: + key = _make_key(auth_server, client_id) + token = self._load_all().get(key) + if token is None: + return None + if not _is_valid(token): + log.debug("Cached file token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from file (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from file cache", exc_info=True) + return None + + def save(self, auth_server: str, client_id: str, token: dict) -> None: + try: + key = _make_key(auth_server, client_id) + data = self._load_all() + data[key] = token + self._save_all(data) + log.debug("Saved token to file cache for key=%s", key) + except Exception: + log.debug("Failed to save token to file cache", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + try: + key = _make_key(auth_server, client_id) + data = self._load_all() + if key in data: + del data[key] + self._save_all(data) + log.debug("Cleared cached token from file cache for key=%s", key) + except Exception: + log.debug("Failed to clear token from file cache", exc_info=True) + + +def make_cache(mode: str) -> TokenCache | None: + """Return a TokenCache for the given mode, or None for 'none'. + + Modes: + auto – keyring if available, file otherwise (default) + keyring – system keyring only + file – file-based cache only + none – no caching + """ + if mode == "none": + return None + if mode == "file": + return FileTokenCache() + if mode == "keyring": + return KeyringTokenCache() + # auto + candidate = KeyringTokenCache() + if candidate._keyring() is not None: + return candidate + return FileTokenCache() diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 7738d1d..f7c83b5 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -21,6 +21,7 @@ from kognic.auth._user_agent import get_user_agent from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config +from kognic.auth.internal.token_cache import TokenCache from kognic.auth.requests.auth_session import RequestsAuthSession from kognic.auth.requests.bearer_auth import KognicBearerAuth from kognic.auth.serde import serialize_body @@ -146,11 +147,12 @@ def _get_shared_provider( auth: Optional[Union[str, os.PathLike, tuple]], auth_host: str, auth_token_endpoint: str, + token_cache: Optional[TokenCache] = None, ) -> RequestsAuthSession: """Return a shared RequestsAuthSession for the given credentials, creating one if needed. - Providers are keyed by (client_id, auth_host, auth_token_endpoint) and held weakly, - so they are GC'd once no BaseApiClient instances reference them. + Providers are keyed by (client_id, auth_host, auth_token_endpoint, cache_type) and held + weakly, so they are GC'd once no BaseApiClient instances reference them. """ try: client_id, client_secret = resolve_credentials(auth) @@ -160,7 +162,7 @@ def _get_shared_provider( if not client_id or not client_secret: return RequestsAuthSession(auth=auth, host=auth_host, token_endpoint=auth_token_endpoint) - key = (client_id, auth_host, auth_token_endpoint) + key = (client_id, auth_host, auth_token_endpoint, type(token_cache)) with _provider_pool_lock: provider = _provider_pool.get(key) if provider is None: @@ -168,6 +170,8 @@ def _get_shared_provider( auth=(client_id, client_secret), host=auth_host, token_endpoint=auth_token_endpoint, + initial_token=token_cache.load(auth_host, client_id) if token_cache else None, + on_token_updated=(lambda t: token_cache.save(auth_host, client_id, t)) if token_cache else None, ) _provider_pool[key] = provider return provider @@ -204,6 +208,7 @@ def __init__( client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, token_provider: Optional[RequestsAuthSession] = None, + token_cache: Optional[TokenCache] = None, ): """Initialize the API client. @@ -215,6 +220,8 @@ def __init__( json_serializer: Callable to serialize request bodies. Defaults to serialize_body. token_provider: Explicit token provider to share across clients. When omitted, a shared provider is looked up (or created) by credentials + auth_host. + token_cache: Token cache for cross-process token persistence. When given, a valid cached + token is injected on startup and new tokens are saved automatically. """ self._session: Optional[Session] = None self._auth = auth @@ -222,6 +229,7 @@ def __init__( self._auth_token_endpoint = auth_token_endpoint self._json_serializer = json_serializer self._token_provider = token_provider + self._token_cache = token_cache self._lock = Lock() if client_name == "auto": @@ -238,7 +246,7 @@ def session(self) -> Session: with self._lock: if self._session is None: provider = self._token_provider or _get_shared_provider( - self._auth, self._auth_host, self._auth_token_endpoint + self._auth, self._auth_host, self._auth_token_endpoint, self._token_cache ) self._session = create_session( token_provider=provider, From c48ada2ca7141650bdf0ccb701f9e182e309d6c9 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:24:00 +0100 Subject: [PATCH 09/19] Split token_cache module into a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _base.py – TokenCache ABC + helpers (make_key, is_valid, constants) _keyring.py – KeyringTokenCache _file.py – FileTokenCache __init__.py – re-exports + make_cache factory All existing import paths are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../auth/internal/token_cache/__init__.py | 35 +++++++++ src/kognic/auth/internal/token_cache/_base.py | 38 +++++++++ src/kognic/auth/internal/token_cache/_file.py | 67 ++++++++++++++++ .../auth/internal/token_cache/_keyring.py | 77 +++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/kognic/auth/internal/token_cache/__init__.py create mode 100644 src/kognic/auth/internal/token_cache/_base.py create mode 100644 src/kognic/auth/internal/token_cache/_file.py create mode 100644 src/kognic/auth/internal/token_cache/_keyring.py diff --git a/src/kognic/auth/internal/token_cache/__init__.py b/src/kognic/auth/internal/token_cache/__init__.py new file mode 100644 index 0000000..f82e79f --- /dev/null +++ b/src/kognic/auth/internal/token_cache/__init__.py @@ -0,0 +1,35 @@ +"""Token cache backends for cross-process token persistence. + +All keyring imports are lazy so the module works when keyring is not installed. +""" + +from __future__ import annotations + +from kognic.auth.internal.token_cache._base import TokenCache +from kognic.auth.internal.token_cache._file import FileTokenCache +from kognic.auth.internal.token_cache._keyring import KeyringTokenCache + + +def make_cache(mode: str) -> TokenCache | None: + """Return a TokenCache for the given mode, or None for 'none'. + + Modes: + auto – keyring if available, file otherwise (default) + keyring – system keyring only + file – file-based cache only + none – no caching + """ + if mode == "none": + return None + if mode == "file": + return FileTokenCache() + if mode == "keyring": + return KeyringTokenCache() + # auto + candidate = KeyringTokenCache() + if candidate._keyring() is not None: + return candidate + return FileTokenCache() + + +__all__ = ["TokenCache", "KeyringTokenCache", "FileTokenCache", "make_cache"] diff --git a/src/kognic/auth/internal/token_cache/_base.py b/src/kognic/auth/internal/token_cache/_base.py new file mode 100644 index 0000000..ae489a8 --- /dev/null +++ b/src/kognic/auth/internal/token_cache/_base.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from typing import Optional + +SERVICE_NAME = "kognic-auth" + +# Tokens are considered expired this many seconds before their actual expiry, +# to avoid using a token that expires mid-request. +EXPIRY_MARGIN_SECONDS = 30 + + +def make_key(auth_server: str, client_id: str) -> str: + return f"{auth_server}:{client_id}" + + +def is_valid(token: dict) -> bool: + expires_at = token.get("expires_at") + if expires_at is None: + return False + return time.time() < (expires_at - EXPIRY_MARGIN_SECONDS) + + +class TokenCache(ABC): + """Abstract base class for token caches.""" + + @abstractmethod + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + """Return a non-expired token dict, or None.""" + + @abstractmethod + def save(self, auth_server: str, client_id: str, token: dict) -> None: + """Persist a token dict. Silently ignores errors.""" + + @abstractmethod + def clear(self, auth_server: str, client_id: str) -> None: + """Remove a cached token. Silently ignores errors.""" diff --git a/src/kognic/auth/internal/token_cache/_file.py b/src/kognic/auth/internal/token_cache/_file.py new file mode 100644 index 0000000..d8b1fe9 --- /dev/null +++ b/src/kognic/auth/internal/token_cache/_file.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Optional + +from kognic.auth import DEFAULT_CACHE_PATH +from kognic.auth.internal.token_cache._base import TokenCache, is_valid, make_key + +log = logging.getLogger(__name__) + + +class FileTokenCache(TokenCache): + """Token cache backed by a JSON file on disk.""" + + def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None: + self.path = path + + def _load_all(self) -> dict: + try: + return json.loads(self.path.read_text()) + except FileNotFoundError: + return {} + except Exception: + log.debug("Failed to read token cache file", exc_info=True) + return {} + + def _save_all(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2)) + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + try: + key = make_key(auth_server, client_id) + token = self._load_all().get(key) + if token is None: + return None + if not is_valid(token): + log.debug("Cached file token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from file (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from file cache", exc_info=True) + return None + + def save(self, auth_server: str, client_id: str, token: dict) -> None: + try: + key = make_key(auth_server, client_id) + data = self._load_all() + data[key] = token + self._save_all(data) + log.debug("Saved token to file cache for key=%s", key) + except Exception: + log.debug("Failed to save token to file cache", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + try: + key = make_key(auth_server, client_id) + data = self._load_all() + if key in data: + del data[key] + self._save_all(data) + log.debug("Cleared cached token from file cache for key=%s", key) + except Exception: + log.debug("Failed to clear token from file cache", exc_info=True) diff --git a/src/kognic/auth/internal/token_cache/_keyring.py b/src/kognic/auth/internal/token_cache/_keyring.py new file mode 100644 index 0000000..ceebbc3 --- /dev/null +++ b/src/kognic/auth/internal/token_cache/_keyring.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +import logging +from typing import Optional + +from kognic.auth.internal.token_cache._base import SERVICE_NAME, TokenCache, is_valid, make_key + +log = logging.getLogger(__name__) + +_KEYRING_MISSING = object() # sentinel: import attempted but unavailable + + +class KeyringTokenCache(TokenCache): + """Token cache backed by the system keyring.""" + + def __init__(self) -> None: + self._keyring_module = None # not yet resolved + + def _keyring(self): + """Return the keyring module if usable, else None. Result is cached.""" + if self._keyring_module is _KEYRING_MISSING: + return None + if self._keyring_module is not None: + return self._keyring_module + try: + import keyring + + backend = keyring.get_keyring() + if "fail" in type(backend).__name__.lower(): + raise RuntimeError("unusable keyring backend") + self._keyring_module = keyring + except Exception: + self._keyring_module = _KEYRING_MISSING + return None + return self._keyring_module + + def load(self, auth_server: str, client_id: str) -> Optional[dict]: + kr = self._keyring() + if kr is None: + return None + try: + key = make_key(auth_server, client_id) + stored = kr.get_password(SERVICE_NAME, key) + if stored is None: + return None + token = json.loads(stored) + if not is_valid(token): + log.debug("Cached keyring token expired or missing expires_at, discarding") + return None + log.debug("Using cached token from keyring (key=%s)", key) + return token + except Exception: + log.debug("Failed to load token from keyring", exc_info=True) + return None + + def save(self, auth_server: str, client_id: str, token: dict) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = make_key(auth_server, client_id) + kr.set_password(SERVICE_NAME, key, json.dumps(token)) + log.debug("Saved token to keyring for key=%s", key) + except Exception: + log.debug("Failed to save token to keyring", exc_info=True) + + def clear(self, auth_server: str, client_id: str) -> None: + kr = self._keyring() + if kr is None: + return + try: + key = make_key(auth_server, client_id) + kr.delete_password(SERVICE_NAME, key) + log.debug("Cleared cached token from keyring for key=%s", key) + except Exception: + log.debug("Failed to clear token from keyring", exc_info=True) From f26a0b3f1af54aa4ee1b59194557aefcf19987d9 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:25:08 +0100 Subject: [PATCH 10/19] Fix CLI imports to use kognic.auth.internal.token_cache directly Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/cli/api_request.py | 2 +- src/kognic/auth/cli/get_access_token.py | 2 +- src/kognic/auth/cli/token_cache.py | 8 -------- 3 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 src/kognic/auth/cli/token_cache.py diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index 5988bcc..9ad9f83 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -8,9 +8,9 @@ from typing import Any from kognic.auth.cli import _configure_logging -from kognic.auth.cli.token_cache import make_cache from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment +from kognic.auth.internal.token_cache import make_cache from kognic.auth.requests.base_client import create_session METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 76fcee0..1d25c86 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -4,9 +4,9 @@ import sys from kognic.auth import DEFAULT_HOST -from kognic.auth.cli.token_cache import make_cache from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config +from kognic.auth.internal.token_cache import make_cache from kognic.auth.requests.auth_session import RequestsAuthSession COMMAND = "get-access-token" diff --git a/src/kognic/auth/cli/token_cache.py b/src/kognic/auth/cli/token_cache.py deleted file mode 100644 index 4f12b33..0000000 --- a/src/kognic/auth/cli/token_cache.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Re-exports from kognic.auth.internal.token_cache for CLI use.""" - -from kognic.auth.internal.token_cache import ( # noqa: F401 - FileTokenCache, - KeyringTokenCache, - TokenCache, - make_cache, -) From 47c242e7081a81ab1405ffa43b93fc86931c1897 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:31:39 +0100 Subject: [PATCH 11/19] Add provider pool tests for BaseApiClient Covers: - Same credentials share one provider instance - Different credentials, auth hosts, or cache types get separate providers - Explicit token_provider bypasses the pool entirely - Pool entries are held weakly and GC'd when no client references them Co-Authored-By: Claude Sonnet 4.6 --- tests/test_base_client_sync.py | 136 +++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py index b6a497d..23790ad 100644 --- a/tests/test_base_client_sync.py +++ b/tests/test_base_client_sync.py @@ -192,5 +192,141 @@ class MyClient(BaseApiClient): Path(config_path).unlink() +class TestProviderPool(unittest.TestCase): + def setUp(self): + import kognic.auth.requests.base_client as bc + + bc._provider_pool.clear() + + def tearDown(self): + import kognic.auth.requests.base_client as bc + + bc._provider_pool.clear() + + def _make_clients(self, mock_session, n=2, **kwargs): + from kognic.auth.requests.base_client import BaseApiClient + + clients = [BaseApiClient(**kwargs) for _ in range(n)] + for c in clients: + _ = c.session + return clients + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch("kognic.auth.requests.base_client.resolve_credentials", return_value=("id1", "secret1")) + def test_same_credentials_share_provider(self, _resolve, mock_ras, mock_session): + self._make_clients(mock_session, n=2, auth=("id1", "secret1")) + + mock_ras.assert_called_once() + self.assertEqual(len(mock_session.return_value.mount.call_args_list), 4) # 2x per session + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch( + "kognic.auth.requests.base_client.resolve_credentials", + side_effect=lambda auth, *a, **kw: auth, + ) + def test_different_credentials_get_different_providers(self, _resolve, mock_ras, mock_session): + from kognic.auth.requests.base_client import BaseApiClient + + c1 = BaseApiClient(auth=("id1", "secret1")) + c2 = BaseApiClient(auth=("id2", "secret2")) + _ = c1.session + _ = c2.session + + self.assertEqual(mock_ras.call_count, 2) + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch("kognic.auth.requests.base_client.resolve_credentials", return_value=("id1", "secret1")) + def test_different_auth_host_gets_different_provider(self, _resolve, mock_ras, mock_session): + from kognic.auth.requests.base_client import BaseApiClient + + c1 = BaseApiClient(auth=("id1", "secret1"), auth_host="https://auth.a.kognic.com") + c2 = BaseApiClient(auth=("id1", "secret1"), auth_host="https://auth.b.kognic.com") + _ = c1.session + _ = c2.session + + self.assertEqual(mock_ras.call_count, 2) + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch("kognic.auth.requests.base_client.resolve_credentials", return_value=("id1", "secret1")) + def test_cache_type_is_part_of_pool_key(self, _resolve, mock_ras, mock_session): + from kognic.auth.internal.token_cache import FileTokenCache + from kognic.auth.requests.base_client import BaseApiClient + + c1 = BaseApiClient(auth=("id1", "secret1")) + c2 = BaseApiClient(auth=("id1", "secret1"), token_cache=FileTokenCache()) + _ = c1.session + _ = c2.session + + self.assertEqual(mock_ras.call_count, 2) + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch("kognic.auth.requests.base_client.resolve_credentials", return_value=("id1", "secret1")) + def test_explicit_token_provider_bypasses_pool(self, mock_resolve, mock_ras, mock_session): + from kognic.auth.requests.base_client import BaseApiClient, _provider_pool + + explicit = MagicMock() + client = BaseApiClient(auth=("id1", "secret1"), token_provider=explicit) + _ = client.session + + self.assertEqual(len(_provider_pool), 0) + mock_ras.assert_not_called() + mock_resolve.assert_not_called() + + @patch("kognic.auth.requests.base_client.requests.Session") + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + @patch("kognic.auth.requests.base_client.resolve_credentials", return_value=("id1", "secret1")) + def test_pool_entry_alive_while_client_referenced(self, _resolve, mock_ras, mock_session): + from kognic.auth.requests.base_client import ( + DEFAULT_HOST, + DEFAULT_TOKEN_ENDPOINT_RELPATH, + BaseApiClient, + _provider_pool, + ) + + client = BaseApiClient(auth=("id1", "secret1")) + _ = client.session + + pool_key = ("id1", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None)) + self.assertIn(pool_key, _provider_pool) + _ = client # keep alive + + def test_provider_gc_when_all_clients_deleted(self): + import gc + import weakref + + from kognic.auth.requests.base_client import ( + DEFAULT_HOST, + DEFAULT_TOKEN_ENDPOINT_RELPATH, + BaseApiClient, + _provider_pool, + ) + + # Use side_effect so each call returns a fresh object with no external strong references + with patch( + "kognic.auth.requests.base_client.resolve_credentials", + return_value=("id-gc", "secret-gc"), + ): + with patch( + "kognic.auth.requests.base_client.RequestsAuthSession", + side_effect=lambda **kw: MagicMock(), + ): + client = BaseApiClient(auth=("id-gc", "secret-gc")) + session = client.session + pool_key = ("id-gc", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None)) + provider_ref = weakref.ref(_provider_pool[pool_key]) + self.assertIsNotNone(provider_ref()) + + del session, client + gc.collect() + gc.collect() # two passes for cycles from _monkey_patch_send closures + + self.assertIsNone(provider_ref()) + + if __name__ == "__main__": unittest.main() From f5abe6ebd99f9793de21ab18d0cbedef0625cf16 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:32:39 +0100 Subject: [PATCH 12/19] Remove stale token_cache.py after conversion to package Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/internal/token_cache.py | 197 ------------------------ 1 file changed, 197 deletions(-) delete mode 100644 src/kognic/auth/internal/token_cache.py diff --git a/src/kognic/auth/internal/token_cache.py b/src/kognic/auth/internal/token_cache.py deleted file mode 100644 index 1e8824b..0000000 --- a/src/kognic/auth/internal/token_cache.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Token cache backends. - -All keyring imports are lazy so the module works when keyring is not installed. -""" - -from __future__ import annotations - -import json -import logging -import time -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Optional - -from kognic.auth import DEFAULT_CACHE_PATH - -log = logging.getLogger(__name__) - -SERVICE_NAME = "kognic-auth" - -# Tokens are considered expired this many seconds before their actual expiry, -# to avoid using a token that expires mid-request. -EXPIRY_MARGIN_SECONDS = 30 - - -def _make_key(auth_server: str, client_id: str) -> str: - return f"{auth_server}:{client_id}" - - -def _is_valid(token: dict) -> bool: - expires_at = token.get("expires_at") - if expires_at is None: - return False - return time.time() < (expires_at - EXPIRY_MARGIN_SECONDS) - - -class TokenCache(ABC): - """Abstract base class for token caches.""" - - @abstractmethod - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - """Return a non-expired token dict, or None.""" - - @abstractmethod - def save(self, auth_server: str, client_id: str, token: dict) -> None: - """Persist a token dict. Silently ignores errors.""" - - @abstractmethod - def clear(self, auth_server: str, client_id: str) -> None: - """Remove a cached token. Silently ignores errors.""" - - -_KEYRING_MISSING = object() # sentinel: import attempted but unavailable - - -class KeyringTokenCache(TokenCache): - """Token cache backed by the system keyring.""" - - def __init__(self) -> None: - self._keyring_module = None # not yet resolved - - def _keyring(self): - """Return the keyring module if usable, else None. Result is cached.""" - if self._keyring_module is _KEYRING_MISSING: - return None - if self._keyring_module is not None: - return self._keyring_module - try: - import keyring - - backend = keyring.get_keyring() - if "fail" in type(backend).__name__.lower(): - raise RuntimeError("unusable keyring backend") - self._keyring_module = keyring - except Exception: - self._keyring_module = _KEYRING_MISSING - return None - return self._keyring_module - - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - kr = self._keyring() - if kr is None: - return None - try: - key = _make_key(auth_server, client_id) - stored = kr.get_password(SERVICE_NAME, key) - if stored is None: - return None - token = json.loads(stored) - if not _is_valid(token): - log.debug("Cached keyring token expired or missing expires_at, discarding") - return None - log.debug("Using cached token from keyring (key=%s)", key) - return token - except Exception: - log.debug("Failed to load token from keyring", exc_info=True) - return None - - def save(self, auth_server: str, client_id: str, token: dict) -> None: - kr = self._keyring() - if kr is None: - return - try: - key = _make_key(auth_server, client_id) - kr.set_password(SERVICE_NAME, key, json.dumps(token)) - log.debug("Saved token to keyring for key=%s", key) - except Exception: - log.debug("Failed to save token to keyring", exc_info=True) - - def clear(self, auth_server: str, client_id: str) -> None: - kr = self._keyring() - if kr is None: - return - try: - key = _make_key(auth_server, client_id) - kr.delete_password(SERVICE_NAME, key) - log.debug("Cleared cached token from keyring for key=%s", key) - except Exception: - log.debug("Failed to clear token from keyring", exc_info=True) - - -class FileTokenCache(TokenCache): - """Token cache backed by a JSON file on disk.""" - - def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None: - self.path = path - - def _load_all(self) -> dict: - try: - return json.loads(self.path.read_text()) - except FileNotFoundError: - return {} - except Exception: - log.debug("Failed to read token cache file", exc_info=True) - return {} - - def _save_all(self, data: dict) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text(json.dumps(data, indent=2)) - - def load(self, auth_server: str, client_id: str) -> Optional[dict]: - try: - key = _make_key(auth_server, client_id) - token = self._load_all().get(key) - if token is None: - return None - if not _is_valid(token): - log.debug("Cached file token expired or missing expires_at, discarding") - return None - log.debug("Using cached token from file (key=%s)", key) - return token - except Exception: - log.debug("Failed to load token from file cache", exc_info=True) - return None - - def save(self, auth_server: str, client_id: str, token: dict) -> None: - try: - key = _make_key(auth_server, client_id) - data = self._load_all() - data[key] = token - self._save_all(data) - log.debug("Saved token to file cache for key=%s", key) - except Exception: - log.debug("Failed to save token to file cache", exc_info=True) - - def clear(self, auth_server: str, client_id: str) -> None: - try: - key = _make_key(auth_server, client_id) - data = self._load_all() - if key in data: - del data[key] - self._save_all(data) - log.debug("Cleared cached token from file cache for key=%s", key) - except Exception: - log.debug("Failed to clear token from file cache", exc_info=True) - - -def make_cache(mode: str) -> TokenCache | None: - """Return a TokenCache for the given mode, or None for 'none'. - - Modes: - auto – keyring if available, file otherwise (default) - keyring – system keyring only - file – file-based cache only - none – no caching - """ - if mode == "none": - return None - if mode == "file": - return FileTokenCache() - if mode == "keyring": - return KeyringTokenCache() - # auto - candidate = KeyringTokenCache() - if candidate._keyring() is not None: - return candidate - return FileTokenCache() From e541953d499a0ae9532ba316a8a4e9d151411a03 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:55:41 +0100 Subject: [PATCH 13/19] Extract make_token_provider helper, simplify CLI commands Both CLI commands shared duplicated credential+cache wiring. The new make_token_provider() in base_client.py centralises this: it resolves credentials and wires up initial_token/on_token_updated from the cache in one place. get_access_token now calls make_token_provider() + ensure_token() instead of constructing RequestsAuthSession directly. api_request's _create_authenticated_session now calls make_token_provider() + create_session(token_provider=). test_cli.py updated to match the current interface (--token-cache choices, make_token_provider/make_cache patch targets). Co-Authored-By: Claude Sonnet 4.6 --- src/kognic/auth/cli/api_request.py | 13 +- src/kognic/auth/cli/get_access_token.py | 17 +- src/kognic/auth/requests/base_client.py | 29 ++ tests/test_cli.py | 396 ++++++++++++------------ 4 files changed, 235 insertions(+), 220 deletions(-) diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index 9ad9f83..efee874 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -8,10 +8,9 @@ from typing import Any from kognic.auth.cli import _configure_logging -from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment from kognic.auth.internal.token_cache import make_cache -from kognic.auth.requests.base_client import create_session +from kognic.auth.requests.base_client import create_session, make_token_provider METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] @@ -166,14 +165,8 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: def _create_authenticated_session(*, auth, auth_host, cache_mode: str = "auto"): - cache = make_cache(cache_mode) - client_id, client_secret = resolve_credentials(auth) - return create_session( - auth=(client_id, client_secret), - auth_host=auth_host, - initial_token=cache.load(auth_host, client_id) if (cache and client_id) else None, - on_token_updated=(lambda t: cache.save(auth_host, client_id, t)) if (cache and client_id) else None, - ) + provider = make_token_provider(auth=auth, auth_host=auth_host, token_cache=make_cache(cache_mode)) + return create_session(token_provider=provider) def run(parsed: argparse.Namespace) -> int: diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 1d25c86..3fcc1b1 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -4,10 +4,9 @@ import sys from kognic.auth import DEFAULT_HOST -from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.internal.token_cache import make_cache -from kognic.auth.requests.auth_session import RequestsAuthSession +from kognic.auth.requests.base_client import make_token_provider COMMAND = "get-access-token" @@ -65,16 +64,12 @@ def run(parsed: argparse.Namespace) -> int: auth_host = host or DEFAULT_HOST - cache = make_cache(parsed.token_cache) - client_id, client_secret = resolve_credentials(credentials) - auth_session = RequestsAuthSession( - auth=(client_id, client_secret), - host=auth_host, - initial_token=cache.load(auth_host, client_id) if (cache and client_id) else None, - on_token_updated=(lambda t: cache.save(auth_host, client_id, t)) if (cache and client_id) else None, + provider = make_token_provider( + auth=credentials, + auth_host=auth_host, + token_cache=make_cache(parsed.token_cache), ) - _ = auth_session.session # trigger fetch if no valid initial_token - print(auth_session.access_token) + print(provider.ensure_token()["access_token"]) return 0 except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index f7c83b5..e6d6bcf 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -139,6 +139,35 @@ def create_session( return session +def make_token_provider( + *, + auth: Optional[Union[str, os.PathLike, tuple]] = None, + auth_host: str = DEFAULT_HOST, + auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + token_cache: Optional[TokenCache] = None, +) -> RequestsAuthSession: + """Create a RequestsAuthSession wired to an optional token cache. + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + auth_host: Authentication server base URL + auth_token_endpoint: Relative path to token endpoint + token_cache: Token cache for cross-process persistence. When given, a valid cached token is + injected on startup and new tokens are saved automatically. + + Returns: + Configured RequestsAuthSession + """ + client_id, client_secret = resolve_credentials(auth) + return RequestsAuthSession( + auth=(client_id, client_secret), + host=auth_host, + token_endpoint=auth_token_endpoint, + initial_token=token_cache.load(auth_host, client_id) if (token_cache and client_id) else None, + on_token_updated=(lambda t: token_cache.save(auth_host, client_id, t)) if (token_cache and client_id) else None, + ) + + _provider_pool: WeakValueDictionary[tuple, RequestsAuthSession] = WeakValueDictionary() _provider_pool_lock = threading.Lock() diff --git a/tests/test_cli.py b/tests/test_cli.py index 591d726..43a8909 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,107 +46,126 @@ def test_no_command_shows_help(self): result = main([]) self.assertEqual(result, 0) - def test_no_cache_flag(self): + def test_token_cache_default(self): parser = create_parser() - args = parser.parse_args(["get-access-token", "--no-cache"]) - self.assertTrue(args.no_cache) + args = parser.parse_args(["get-access-token"]) + self.assertEqual(args.token_cache, "auto") - def test_no_cache_default_false(self): + def test_token_cache_none(self): parser = create_parser() - args = parser.parse_args(["get-access-token"]) - self.assertFalse(args.no_cache) + args = parser.parse_args(["get-access-token", "--token-cache", "none"]) + self.assertEqual(args.token_cache, "none") + + def test_token_cache_choices(self): + parser = create_parser() + for choice in ("auto", "keyring", "file", "none"): + args = parser.parse_args(["get-access-token", "--token-cache", choice]) + self.assertEqual(args.token_cache, choice) class CliMainTest(unittest.TestCase): - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_prints_token(self, mock_session_class): - mock_session = mock.MagicMock() - mock_session.access_token = "test-access-token-123" - mock_session_class.return_value = mock_session + def _make_provider(self, access_token): + provider = mock.MagicMock() + provider.ensure_token.return_value = {"access_token": access_token} + return provider + + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_prints_token(self, mock_make_provider): + mock_make_provider.return_value = self._make_provider("test-access-token-123") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--no-cache"]) + result = main(["get-access-token", "--token-cache", "none"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("test-access-token-123") - mock_session_class.assert_called_once_with(auth=None, host=DEFAULT_HOST) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_with_credentials_file(self, mock_session_class): - mock_session = mock.MagicMock() - mock_session.access_token = "token-from-file" - mock_session_class.return_value = mock_session + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_with_credentials_file(self, mock_make_provider): + mock_make_provider.return_value = self._make_provider("token-from-file") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--credentials", "/path/to/creds.json", "--no-cache"]) + result = main(["get-access-token", "--credentials", "/path/to/creds.json", "--token-cache", "none"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("token-from-file") - mock_session_class.assert_called_once_with(auth="/path/to/creds.json", host=DEFAULT_HOST) + mock_make_provider.assert_called_once_with( + auth="/path/to/creds.json", + auth_host=DEFAULT_HOST, + token_cache=None, + ) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_with_custom_server(self, mock_session_class): - mock_session = mock.MagicMock() - mock_session.access_token = "custom-server-token" - mock_session_class.return_value = mock_session + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_with_custom_server(self, mock_make_provider): + mock_make_provider.return_value = self._make_provider("custom-server-token") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--server", "https://custom.server", "--no-cache"]) + result = main(["get-access-token", "--server", "https://custom.server", "--token-cache", "none"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("custom-server-token") - mock_session_class.assert_called_once_with(auth=None, host="https://custom.server") + mock_make_provider.assert_called_once_with( + auth=None, + auth_host="https://custom.server", + token_cache=None, + ) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_with_all_options(self, mock_session_class): - mock_session = mock.MagicMock() - mock_session.access_token = "full-options-token" - mock_session_class.return_value = mock_session + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_with_all_options(self, mock_make_provider): + mock_make_provider.return_value = self._make_provider("full-options-token") with mock.patch("builtins.print"): result = main( - ["get-access-token", "--server", "https://my.server", "--credentials", "creds.json", "--no-cache"] + [ + "get-access-token", + "--server", + "https://my.server", + "--credentials", + "creds.json", + "--token-cache", + "none", + ] ) self.assertEqual(result, 0) - mock_session_class.assert_called_once_with(auth="creds.json", host="https://my.server") + mock_make_provider.assert_called_once_with( + auth="creds.json", + auth_host="https://my.server", + token_cache=None, + ) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_file_not_found(self, mock_session_class): - mock_session_class.side_effect = FileNotFoundError("Could not find Api Credentials file at /bad/path.json") + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_file_not_found(self, mock_make_provider): + mock_make_provider.side_effect = FileNotFoundError("Could not find Api Credentials file at /bad/path.json") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--credentials", "/bad/path.json", "--no-cache"]) + result = main(["get-access-token", "--credentials", "/bad/path.json", "--token-cache", "none"]) self.assertEqual(result, 1) - mock_print.assert_called_once() self.assertIn("Error:", mock_print.call_args[0][0]) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_value_error(self, mock_session_class): - mock_session_class.side_effect = ValueError("Bad auth credentials") + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_value_error(self, mock_make_provider): + mock_make_provider.side_effect = ValueError("Bad auth credentials") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--no-cache"]) + result = main(["get-access-token", "--token-cache", "none"]) self.assertEqual(result, 1) - mock_print.assert_called_once() self.assertIn("Error:", mock_print.call_args[0][0]) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_main_generic_exception(self, mock_session_class): - mock_session_class.side_effect = Exception("Network error") + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_generic_exception(self, mock_make_provider): + mock_make_provider.side_effect = Exception("Network error") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--no-cache"]) + result = main(["get-access-token", "--token-cache", "none"]) self.assertEqual(result, 1) - mock_print.assert_called_once() self.assertIn("Error fetching token:", mock_print.call_args[0][0]) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") - def test_main_with_context(self, mock_load_config, mock_session_class): + def test_main_with_context(self, mock_load_config, mock_make_provider): from kognic.auth.env_config import KognicEnvConfig mock_load_config.return_value = KognicEnvConfig( @@ -159,23 +178,22 @@ def test_main_with_context(self, mock_load_config, mock_session_class): ), }, ) - mock_session = mock.MagicMock() - mock_session.access_token = "demo-token" - mock_session_class.return_value = mock_session + mock_make_provider.return_value = self._make_provider("demo-token") with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--env", "demo", "--no-cache"]) + result = main(["get-access-token", "--env", "demo", "--token-cache", "none"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("demo-token") - mock_session_class.assert_called_once_with( + mock_make_provider.assert_called_once_with( auth="/path/to/demo-creds.json", - host="https://auth.demo.kognic.com", + auth_host="https://auth.demo.kognic.com", + token_cache=None, ) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") - def test_main_with_context_server_override(self, mock_load_config, mock_session_class): + def test_main_with_context_server_override(self, mock_load_config, mock_make_provider): from kognic.auth.env_config import KognicEnvConfig mock_load_config.return_value = KognicEnvConfig( @@ -188,17 +206,18 @@ def test_main_with_context_server_override(self, mock_load_config, mock_session_ ), }, ) - mock_session = mock.MagicMock() - mock_session.access_token = "override-token" - mock_session_class.return_value = mock_session + mock_make_provider.return_value = self._make_provider("override-token") with mock.patch("builtins.print"): - result = main(["get-access-token", "--env", "demo", "--server", "https://custom.server", "--no-cache"]) + result = main( + ["get-access-token", "--env", "demo", "--server", "https://custom.server", "--token-cache", "none"] + ) self.assertEqual(result, 0) - mock_session_class.assert_called_once_with( + mock_make_provider.assert_called_once_with( auth="/path/to/demo-creds.json", - host="https://custom.server", + auth_host="https://custom.server", + token_cache=None, ) def test_main_with_unknown_context(self): @@ -211,88 +230,77 @@ def test_main_with_unknown_context(self): result = main(["get-access-token", "--env", "nonexistent"]) self.assertEqual(result, 1) - mock_print.assert_called_once() - self.assertIn("Unknown environment", mock_print.call_args[0][0]) + self.assertIn("nonexistent", mock_print.call_args[0][0]) class CliCacheTest(unittest.TestCase): - """Tests for keyring token caching in get-access-token.""" - - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - @mock.patch("kognic.auth.cli.token_cache.load_cached_token") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - @mock.patch("kognic.auth.credentials_parser.resolve_credentials", return_value=("client-1", "secret")) - def test_cache_hit_returns_cached_token(self, mock_resolve, mock_kr, mock_load, mock_session_class): - mock_load.return_value = { - "access_token": "cached-token-abc", + """Tests for token caching in get-access-token.""" + + def _make_token(self, access_token="cached-token-abc"): + return { + "access_token": access_token, "expires_at": time.time() + 3600, "expires_in": 3600, "token_type": "bearer", } - with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token"]) + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + @mock.patch("kognic.auth.cli.get_access_token.make_cache") + def test_cache_hit_injects_token_into_provider(self, mock_make_cache, mock_make_provider): + """make_cache is called with 'auto' and its result is passed to make_token_provider.""" + mock_cache = mock.MagicMock() + mock_make_cache.return_value = mock_cache - self.assertEqual(result, 0) - mock_print.assert_called_once_with("cached-token-abc") - mock_session_class.assert_not_called() - - @mock.patch("kognic.auth.cli.token_cache.save_token") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - @mock.patch("kognic.auth.cli.token_cache.load_cached_token", return_value=None) - @mock.patch("kognic.auth.credentials_parser.resolve_credentials", return_value=("client-1", "secret")) - def test_cache_miss_saves_token(self, mock_resolve, mock_load, mock_session_class, mock_kr, mock_save): - token_dict = { - "access_token": "fresh-token", - "expires_at": time.time() + 3600, - "expires_in": 3600, - "token_type": "bearer", - } - mock_session = mock.MagicMock() - mock_session.access_token = "fresh-token" - mock_session.token = token_dict - mock_session.oauth_session.client_id = "client-1" - mock_session_class.return_value = mock_session + provider = mock.MagicMock() + provider.ensure_token.return_value = {"access_token": "cached-token-abc"} + mock_make_provider.return_value = provider with mock.patch("builtins.print") as mock_print: result = main(["get-access-token"]) self.assertEqual(result, 0) - mock_print.assert_called_once_with("fresh-token") - mock_save.assert_called_once_with(DEFAULT_HOST, "client-1", token_dict) + mock_make_cache.assert_called_once_with("auto") + mock_make_provider.assert_called_once_with( + auth=mock.ANY, + auth_host=mock.ANY, + token_cache=mock_cache, + ) + mock_print.assert_called_once_with("cached-token-abc") - @mock.patch("kognic.auth.cli.token_cache.save_token") - @mock.patch("kognic.auth.cli.token_cache.load_cached_token") - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - def test_no_cache_skips_keyring(self, mock_session_class, mock_load, mock_save): - mock_session = mock.MagicMock() - mock_session.access_token = "no-cache-token" - mock_session_class.return_value = mock_session + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + @mock.patch("kognic.auth.cli.get_access_token.make_cache") + def test_no_cache_passes_none_to_provider(self, mock_make_cache, mock_make_provider): + """--token-cache none results in make_cache returning None and provider receiving None.""" + mock_make_cache.return_value = None - with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--no-cache"]) + provider = mock.MagicMock() + provider.ensure_token.return_value = {"access_token": "fresh-token"} + mock_make_provider.return_value = provider + + with mock.patch("builtins.print"): + result = main(["get-access-token", "--token-cache", "none"]) self.assertEqual(result, 0) - mock_print.assert_called_once_with("no-cache-token") - mock_load.assert_not_called() - mock_save.assert_not_called() - - @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - @mock.patch("kognic.auth.cli.token_cache.load_cached_token", return_value=None) - @mock.patch("kognic.auth.credentials_parser.resolve_credentials", side_effect=FileNotFoundError("no file")) - def test_cache_credential_resolve_failure_falls_through(self, mock_resolve, mock_load, mock_session_class): - """If resolve_credentials fails during cache check, fall through to normal auth flow.""" - mock_session = mock.MagicMock() - mock_session.access_token = "fallback-token" - mock_session_class.return_value = mock_session + mock_make_cache.assert_called_once_with("none") + mock_make_provider.assert_called_once_with( + auth=mock.ANY, + auth_host=mock.ANY, + token_cache=None, + ) - with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token"]) + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + @mock.patch("kognic.auth.cli.get_access_token.make_cache") + def test_cache_mode_forwarded(self, mock_make_cache, mock_make_provider): + """--token-cache keyring passes 'keyring' to make_cache.""" + mock_make_cache.return_value = mock.MagicMock() + provider = mock.MagicMock() + provider.ensure_token.return_value = {"access_token": "t"} + mock_make_provider.return_value = provider - self.assertEqual(result, 0) - mock_print.assert_called_once_with("fallback-token") - mock_load.assert_not_called() + with mock.patch("builtins.print"): + main(["get-access-token", "--token-cache", "keyring"]) + + mock_make_cache.assert_called_once_with("keyring") class KogParserTest(unittest.TestCase): @@ -342,15 +350,15 @@ def test_kog_with_config(self): ) self.assertEqual(args.env_config_file_path, "/custom/config.json") - def test_kog_no_cache_flag(self): + def test_kog_token_cache_default(self): parser = create_kog_parser() - args = parser.parse_args(["get", "https://app.kognic.com/v1/projects", "--no-cache"]) - self.assertTrue(args.no_cache) + args = parser.parse_args(["get", "https://app.kognic.com/v1/projects"]) + self.assertEqual(args.token_cache, "auto") - def test_kog_no_cache_default_false(self): + def test_kog_token_cache_none(self): parser = create_kog_parser() - args = parser.parse_args(["get", "https://app.kognic.com/v1/projects"]) - self.assertFalse(args.no_cache) + args = parser.parse_args(["get", "https://app.kognic.com/v1/projects", "--token-cache", "none"]) + self.assertEqual(args.token_cache, "none") class CallApiTest(unittest.TestCase): @@ -362,7 +370,7 @@ def _make_parsed( headers=None, env_config_file_path="/nonexistent/config.json", env_name=None, - no_cache=True, + token_cache="none", ): parser = create_kog_parser() args = [method, url] @@ -374,8 +382,7 @@ def _make_parsed( args.extend(["--env-config-file-path", env_config_file_path]) if env_name: args.extend(["--env", env_name]) - if no_cache: - args.append("--no-cache") + args.extend(["--token-cache", token_cache]) return parser.parse_args(args) @mock.patch("kognic.auth.cli.api_request.resolve_environment") @@ -503,8 +510,7 @@ def test_call_api_invalid_json_data(self): result = call_run(parsed) self.assertEqual(result, 1) - error_output = mock_print.call_args[0][0] - self.assertIn("Invalid JSON data", error_output) + self.assertIn("Invalid JSON data", mock_print.call_args[0][0]) def test_call_api_invalid_header_format(self): parsed = self._make_parsed(headers=["BadHeader"]) @@ -512,8 +518,7 @@ def test_call_api_invalid_header_format(self): result = call_run(parsed) self.assertEqual(result, 1) - error_output = mock_print.call_args[0][0] - self.assertIn("Invalid header format", error_output) + self.assertIn("Invalid header format", mock_print.call_args[0][0]) @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") @@ -655,9 +660,6 @@ def test_call_api_jsonl_top_level_list(self, mock_create_session, mock_load_conf self.assertEqual(result, 0) self.assertEqual(mock_print.call_count, 3) - mock_print.assert_any_call(json.dumps({"id": 1})) - mock_print.assert_any_call(json.dumps({"id": 2})) - mock_print.assert_any_call(json.dumps({"id": 3})) @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") @@ -1016,68 +1018,18 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ mock_create_session.assert_called_once_with( auth="/path/to/demo-creds.json", auth_host="https://auth.demo.kognic.com", - use_cache=False, + cache_mode="none", ) class KogCacheTest(unittest.TestCase): - """Tests for keyring token caching in kog command.""" - - @mock.patch("kognic.auth.cli.api_request.resolve_environment") - @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") - @mock.patch("kognic.auth.cli.token_cache.load_cached_token") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_kog_uses_cached_token( - self, mock_kr, mock_load, mock_session_class, mock_load_config, mock_resolve_environment - ): - """When a cached token is available, kog injects it and skips the network fetch.""" - mock_load_config.return_value = mock.MagicMock() - mock_resolve_environment.return_value = Environment( - name="default", - host="app.kognic.com", - auth_server="https://auth.app.kognic.com", - credentials="/path/to/creds.json", - ) - - cached_token = { - "access_token": "cached-kog-token", - "expires_at": time.time() + 3600, - "expires_in": 3600, - "token_type": "bearer", - } - mock_load.return_value = cached_token - - mock_auth_session = mock.MagicMock() - mock_auth_session.oauth_session.client_id = "client-1" - mock_auth_session.token = cached_token - - mock_raw_session = mock.MagicMock() - mock_raw_session.headers = {} - mock_response = mock.MagicMock() - mock_response.ok = True - mock_response.headers = {"Content-Type": "application/json"} - mock_response.json.return_value = {"result": "ok"} - mock_raw_session.request.return_value = mock_response - mock_auth_session.session = mock_raw_session - - mock_session_class.return_value = mock_auth_session - - parser = create_kog_parser() - parsed = parser.parse_args( - ["get", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/nonexistent/config.json"] - ) - with mock.patch("builtins.print"): - result = call_run(parsed) - - self.assertEqual(result, 0) - mock_load.assert_called_once_with("https://auth.app.kognic.com", "client-1") + """Tests for token caching in kog command.""" @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") - def test_kog_no_cache_passes_flag(self, mock_create_session, mock_load_config, mock_resolve_environment): - """When --no-cache is passed, use_cache=False is forwarded.""" + def test_kog_token_cache_none_forwarded(self, mock_create_session, mock_load_config, mock_resolve_environment): + """--token-cache none is forwarded as cache_mode='none'.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -1100,7 +1052,8 @@ def test_kog_no_cache_passes_flag(self, mock_create_session, mock_load_config, m "https://app.kognic.com/v1/projects", "--env-config-file-path", "/nonexistent/config.json", - "--no-cache", + "--token-cache", + "none", ] ) with mock.patch("builtins.print"): @@ -1109,7 +1062,52 @@ def test_kog_no_cache_passes_flag(self, mock_create_session, mock_load_config, m mock_create_session.assert_called_once_with( auth=None, auth_host="https://auth.app.kognic.com", - use_cache=False, + cache_mode="none", + ) + + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.make_token_provider") + @mock.patch("kognic.auth.cli.api_request.make_cache") + def test_kog_uses_cached_token( + self, mock_make_cache, mock_make_provider, mock_load_config, mock_resolve_environment + ): + """When a cached token is available, it is injected via make_token_provider.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_environment.return_value = Environment( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials="/path/to/creds.json", + ) + + mock_cache = mock.MagicMock() + mock_make_cache.return_value = mock_cache + + mock_provider = mock.MagicMock() + mock_make_provider.return_value = mock_provider + + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"result": "ok"} + mock_session.request.return_value = mock_response + + with mock.patch("kognic.auth.cli.api_request.create_session", return_value=mock_session): + parser = create_kog_parser() + parsed = parser.parse_args( + ["get", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/nonexistent/config.json"] + ) + with mock.patch("builtins.print"): + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_make_cache.assert_called_once_with("auto") + mock_make_provider.assert_called_once_with( + auth="/path/to/creds.json", + auth_host="https://auth.app.kognic.com", + token_cache=mock_cache, ) From de42745ac2da978a9d545888234f7c0463dac79e Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 19 Feb 2026 17:57:31 +0100 Subject: [PATCH 14/19] refactor constructor --- src/kognic/auth/cli/api_request.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index efee874..959ca25 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -164,11 +164,6 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: print(response.text) -def _create_authenticated_session(*, auth, auth_host, cache_mode: str = "auto"): - provider = make_token_provider(auth=auth, auth_host=auth_host, token_cache=make_cache(cache_mode)) - return create_session(token_provider=provider) - - def run(parsed: argparse.Namespace) -> int: try: headers = _parse_headers(parsed.headers) or {} @@ -177,11 +172,10 @@ def run(parsed: argparse.Namespace) -> int: config = load_kognic_env_config(parsed.env_config_file_path) env = resolve_environment(config, parsed.url, parsed.env_name) - session = _create_authenticated_session( - auth=env.credentials, - auth_host=env.auth_server, - cache_mode=parsed.token_cache, + provider = make_token_provider( + auth=env.credentials, auth_host=env.auth_server, token_cache=make_cache(parsed.token_cache) ) + session = create_session(token_provider=provider) response = session.request( method=parsed.method.upper(), From 576c6b4c8cff289ded3506b616f5fee76df0f92f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 20 Feb 2026 05:10:22 +0100 Subject: [PATCH 15/19] refactor credentials_parser.py --- src/kognic/auth/credentials_parser.py | 59 ++++---- src/kognic/auth/requests/base_client.py | 11 +- tests/test_cli.py | 62 +++++---- tests/test_credentials_parser.py | 62 ++++----- tests/test_token_cache.py | 174 ++++++++++++------------ 5 files changed, 183 insertions(+), 185 deletions(-) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index ed504e5..26dbd87 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Optional, Union +ANY_AUTH_TYPE = Union[str, os.PathLike, tuple, "ApiCredentials", dict, None] + REQUIRED_CREDENTIALS_FILE_KEYS = [ "clientId", "clientSecret", @@ -22,7 +24,7 @@ class ApiCredentials: issuer: str -def parse_credentials(path: Union[str, os.PathLike, dict]): +def parse_credentials(path: Union[str, os.PathLike, dict]) -> ApiCredentials: if isinstance(path, dict): credentials = path else: @@ -47,19 +49,7 @@ def parse_credentials(path: Union[str, os.PathLike, dict]): ) -def get_credentials(auth): - if isinstance(auth, (str, os.PathLike)): - path = str(auth) - if not path.endswith(".json"): - raise ValueError(f"Bad auth credentials file, must be json: {path}") - return parse_credentials(auth) - elif isinstance(auth, ApiCredentials): - return auth - else: - raise ValueError("Bad auth credentials, must be path to credentials file, or ApiCredentials object") - - -def get_credentials_from_env(): +def get_credentials_from_env() -> tuple[Optional[str], Optional[str]]: creds = os.getenv("KOGNIC_CREDENTIALS") if creds: client_credentials = parse_credentials(creds) @@ -71,20 +61,37 @@ def get_credentials_from_env(): return client_id, client_secret -def resolve_credentials(auth=None, client_id: Optional[str] = None, client_secret: Optional[str] = None): +def resolve_credentials( + auth: ANY_AUTH_TYPE = None, client_id: Optional[str] = None, client_secret: Optional[str] = None +) -> tuple[Optional[str], Optional[str]]: has_credentials_tuple = client_id is not None and client_secret is not None - if auth is not None: - if has_credentials_tuple: + + if has_credentials_tuple: + if auth is not None: raise ValueError("Choose either auth or client_id+client_secret") - if isinstance(auth, tuple): - if len(auth) != 2: - raise ValueError("Credentials tuple must be tuple of (client_id, client_secret)") - client_id, client_secret = auth - else: - creds = get_credentials(auth) - client_id = creds.client_id - client_secret = creds.client_secret - elif not has_credentials_tuple: + + elif isinstance(auth, tuple): + if len(auth) != 2: + raise ValueError("Credentials tuple must be tuple of (client_id, client_secret)") + client_id, client_secret = auth + elif isinstance(auth, ApiCredentials): + client_id = auth.client_id + client_secret = auth.client_secret + elif isinstance(auth, dict): + creds = parse_credentials(auth) + client_id = creds.client_id + client_secret = creds.client_secret + elif isinstance(auth, (str, os.PathLike)): + path = str(auth) + if 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 + elif auth is not None: + raise ValueError(f"Unsupported auth type: {type(auth)}") + + if not client_id and not client_secret: client_id, client_secret = get_credentials_from_env() return client_id, client_secret diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index e6d6bcf..589d3cf 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -19,7 +19,7 @@ from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent -from kognic.auth.credentials_parser import resolve_credentials +from kognic.auth.credentials_parser import ANY_AUTH_TYPE, resolve_credentials from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.internal.token_cache import TokenCache from kognic.auth.requests.auth_session import RequestsAuthSession @@ -141,7 +141,7 @@ def create_session( def make_token_provider( *, - auth: Optional[Union[str, os.PathLike, tuple]] = None, + auth: ANY_AUTH_TYPE = None, auth_host: str = DEFAULT_HOST, auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, token_cache: Optional[TokenCache] = None, @@ -173,7 +173,7 @@ def make_token_provider( def _get_shared_provider( - auth: Optional[Union[str, os.PathLike, tuple]], + auth: ANY_AUTH_TYPE, auth_host: str, auth_token_endpoint: str, token_cache: Optional[TokenCache] = None, @@ -183,10 +183,7 @@ def _get_shared_provider( Providers are keyed by (client_id, auth_host, auth_token_endpoint, cache_type) and held weakly, so they are GC'd once no BaseApiClient instances reference them. """ - try: - client_id, client_secret = resolve_credentials(auth) - except Exception: - client_id, client_secret = None, None + client_id, client_secret = resolve_credentials(auth) if not client_id or not client_secret: return RequestsAuthSession(auth=auth, host=auth_host, token_endpoint=auth_token_endpoint) diff --git a/tests/test_cli.py b/tests/test_cli.py index 43a8909..ba12458 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -387,7 +387,7 @@ def _make_parsed( @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_get_success(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -419,7 +419,7 @@ def test_call_api_get_success(self, mock_create_session, mock_load_config, mock_ @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_post_with_data(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -450,7 +450,7 @@ def test_call_api_post_with_data(self, mock_create_session, mock_load_config, mo @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_with_custom_headers(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -481,7 +481,7 @@ def test_call_api_with_custom_headers(self, mock_create_session, mock_load_confi @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_error_status(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -522,7 +522,7 @@ def test_call_api_invalid_header_format(self): @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_plain_text_response(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -548,7 +548,7 @@ def test_call_api_plain_text_response(self, mock_create_session, mock_load_confi @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -577,7 +577,7 @@ def test_call_api_jsonl_data_array(self, mock_create_session, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_single_key_non_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response has a single key holding a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() @@ -607,7 +607,7 @@ def test_call_api_jsonl_single_key_non_data(self, mock_create_session, mock_load @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_multiple_keys(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used but response has multiple keys, pretty-print as usual.""" mock_load_config.return_value = mock.MagicMock() @@ -635,7 +635,7 @@ def test_call_api_jsonl_multiple_keys(self, mock_create_session, mock_load_confi @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_top_level_list(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response body is a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() @@ -663,7 +663,7 @@ def test_call_api_jsonl_top_level_list(self, mock_create_session, mock_load_conf @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and data is an empty list, nothing is printed.""" mock_load_config.return_value = mock.MagicMock() @@ -691,7 +691,7 @@ def test_call_api_jsonl_empty_data(self, mock_create_session, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -722,7 +722,7 @@ def test_call_api_csv_data_array(self, mock_create_session, mock_load_config, mo @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_tsv_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -753,7 +753,7 @@ def test_call_api_tsv_data_array(self, mock_create_session, mock_load_config, mo @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_data_array(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -784,7 +784,7 @@ def test_call_api_table_data_array(self, mock_create_session, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """Table with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() @@ -812,7 +812,7 @@ def test_call_api_table_empty_data(self, mock_create_session, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_nested_values(self, mock_create_session, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in CSV output.""" mock_load_config.return_value = mock.MagicMock() @@ -843,7 +843,7 @@ def test_call_api_csv_nested_values(self, mock_create_session, mock_load_config, @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_nested_values(self, mock_create_session, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in table output.""" mock_load_config.return_value = mock.MagicMock() @@ -874,7 +874,7 @@ def test_call_api_table_nested_values(self, mock_create_session, mock_load_confi @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_top_level_list(self, mock_create_session, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -905,7 +905,7 @@ def test_call_api_csv_top_level_list(self, mock_create_session, mock_load_config @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_sparse_keys(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV output includes all keys across all rows, with blanks for missing values.""" mock_load_config.return_value = mock.MagicMock() @@ -937,7 +937,7 @@ def test_call_api_csv_sparse_keys(self, mock_create_session, mock_load_config, m @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_empty_data(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() @@ -965,7 +965,7 @@ def test_call_api_csv_empty_data(self, mock_create_session, mock_load_config, mo @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_not_flattenable(self, mock_create_session, mock_load_config, mock_resolve_environment): """CSV with non-flattenable response falls back to pretty JSON.""" mock_load_config.return_value = mock.MagicMock() @@ -1002,7 +1002,10 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ credentials="/path/to/demo-creds.json", ) - with mock.patch("kognic.auth.cli.api_request._create_authenticated_session") as mock_create_session: + with ( + mock.patch("kognic.auth.cli.api_request.make_token_provider") as mock_make_provider, + mock.patch("kognic.auth.cli.api_request.create_session") as mock_create_session, + ): mock_session = mock.MagicMock() mock_response = mock.MagicMock() mock_response.ok = True @@ -1015,10 +1018,10 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ with mock.patch("builtins.print"): call_run(parsed) - mock_create_session.assert_called_once_with( + mock_make_provider.assert_called_once_with( auth="/path/to/demo-creds.json", auth_host="https://auth.demo.kognic.com", - cache_mode="none", + token_cache=None, ) @@ -1027,9 +1030,12 @@ class KogCacheTest(unittest.TestCase): @mock.patch("kognic.auth.cli.api_request.resolve_environment") @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") - @mock.patch("kognic.auth.cli.api_request._create_authenticated_session") - def test_kog_token_cache_none_forwarded(self, mock_create_session, mock_load_config, mock_resolve_environment): - """--token-cache none is forwarded as cache_mode='none'.""" + @mock.patch("kognic.auth.cli.api_request.create_session") + @mock.patch("kognic.auth.cli.api_request.make_token_provider") + def test_kog_token_cache_none_forwarded( + self, mock_make_provider, mock_create_session, mock_load_config, mock_resolve_environment + ): + """--token-cache none is forwarded as token_cache=None to make_token_provider.""" mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( name="default", @@ -1059,10 +1065,10 @@ def test_kog_token_cache_none_forwarded(self, mock_create_session, mock_load_con with mock.patch("builtins.print"): call_run(parsed) - mock_create_session.assert_called_once_with( + mock_make_provider.assert_called_once_with( auth=None, auth_host="https://auth.app.kognic.com", - cache_mode="none", + token_cache=None, ) @mock.patch("kognic.auth.cli.api_request.resolve_environment") diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index 9cb2c5a..382ec9f 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -8,7 +8,6 @@ from kognic.auth.credentials_parser import ( ApiCredentials, - get_credentials, get_credentials_from_env, parse_credentials, resolve_credentials, @@ -58,41 +57,6 @@ def test_parse_missing_key_raises(self): self.assertIn("email", str(ctx.exception)) -class TestGetCredentials(unittest.TestCase): - def test_json_file_path(self): - import tempfile - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(VALID_CREDENTIALS_DICT, f) - path = f.name - - try: - creds = get_credentials(path) - self.assertEqual(creds.client_id, "test_id") - finally: - Path(path).unlink() - - def test_non_json_file_raises(self): - with self.assertRaises(ValueError) as ctx: - get_credentials("/some/path/creds.yaml") - self.assertIn("must be json", str(ctx.exception)) - - def test_api_credentials_passthrough(self): - creds = ApiCredentials( - client_id="id", - client_secret="secret", - email="a@b.com", - user_id=1, - issuer="issuer", - ) - result = get_credentials(creds) - self.assertIs(result, creds) - - def test_unsupported_type_raises(self): - with self.assertRaises(ValueError): - get_credentials(12345) - - class TestGetCredentialsFromEnv(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_no_env_vars_returns_none(self): @@ -184,6 +148,32 @@ def test_no_credentials_returns_none(self): self.assertIsNone(client_id) self.assertIsNone(client_secret) + def test_auth_non_json_path_raises(self): + with self.assertRaises(ValueError) as ctx: + resolve_credentials(auth="/some/path/creds.yaml") + self.assertIn("must be json", str(ctx.exception)) + + def test_auth_api_credentials(self): + creds = ApiCredentials( + client_id="id", + client_secret="secret", + email="a@b.com", + user_id=1, + issuer="issuer", + ) + client_id, client_secret = resolve_credentials(auth=creds) + self.assertEqual(client_id, "id") + self.assertEqual(client_secret, "secret") + + def test_auth_unsupported_type_raises(self): + with self.assertRaises(ValueError): + resolve_credentials(auth=12345) + + def test_auth_dict(self): + client_id, client_secret = resolve_credentials(auth=VALID_CREDENTIALS_DICT) + self.assertEqual(client_id, "test_id") + self.assertEqual(client_secret, "test_secret") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 8fbebba..a01d005 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -3,14 +3,8 @@ import unittest from unittest import mock -from kognic.auth.cli.token_cache import ( - EXPIRY_MARGIN_SECONDS, - SERVICE_NAME, - _make_key, - clear_token, - load_cached_token, - save_token, -) +from kognic.auth.internal.token_cache import KeyringTokenCache +from kognic.auth.internal.token_cache._base import EXPIRY_MARGIN_SECONDS, SERVICE_NAME, make_key def _make_token(*, expires_in=3600, extra=None): @@ -27,149 +21,153 @@ def _make_token(*, expires_in=3600, extra=None): return token +def _cache_no_keyring() -> KeyringTokenCache: + cache = KeyringTokenCache() + cache._keyring = lambda: None + return cache + + +def _cache_with_keyring(mock_kr) -> KeyringTokenCache: + cache = KeyringTokenCache() + cache._keyring = lambda: mock_kr + return cache + + class MakeKeyTest(unittest.TestCase): def test_format(self): - key = _make_key("https://auth.app.kognic.com", "my-client-id") + key = make_key("https://auth.app.kognic.com", "my-client-id") self.assertEqual(key, "https://auth.app.kognic.com:my-client-id") def test_different_servers_produce_different_keys(self): - key1 = _make_key("https://auth.app.kognic.com", "client-1") - key2 = _make_key("https://auth.demo.kognic.com", "client-1") + key1 = make_key("https://auth.app.kognic.com", "client-1") + key2 = make_key("https://auth.demo.kognic.com", "client-1") self.assertNotEqual(key1, key2) class KeyringAvailableTest(unittest.TestCase): - @mock.patch("keyring.get_keyring") - def test_keyring_available(self, mock_get_keyring): - from kognic.auth.cli.token_cache import _keyring_available - - mock_get_keyring.return_value = mock.MagicMock(__class__=type("SecretService", (), {})) - self.assertTrue(_keyring_available()) + def test_keyring_available_when_valid_backend(self): + cache = KeyringTokenCache() + mock_kr = mock.MagicMock() + mock_kr.get_keyring.return_value = mock.MagicMock() + with mock.patch.dict("sys.modules", {"keyring": mock_kr}): + result = cache._keyring() + self.assertIsNotNone(result) - @mock.patch("keyring.get_keyring") - def test_keyring_fail_backend(self, mock_get_keyring): - from kognic.auth.cli.token_cache import _keyring_available + def test_keyring_unavailable_when_fail_backend(self): + cache = KeyringTokenCache() class FailKeyring: pass - mock_get_keyring.return_value = FailKeyring() - self.assertFalse(_keyring_available()) + mock_kr = mock.MagicMock() + mock_kr.get_keyring.return_value = FailKeyring() + with mock.patch.dict("sys.modules", {"keyring": mock_kr}): + result = cache._keyring() + self.assertIsNone(result) class LoadCachedTokenTest(unittest.TestCase): - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) - def test_keyring_not_available(self, _): - result = load_cached_token("https://auth.app.kognic.com", "client-1") + def test_keyring_not_available(self): + result = _cache_no_keyring().load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password", return_value=None) - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_not_found(self, _, mock_get): - result = load_cached_token("https://auth.app.kognic.com", "client-1") + def test_not_found(self): + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = None + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - mock_get.assert_called_once_with(SERVICE_NAME, "https://auth.app.kognic.com:client-1") + mock_kr.get_password.assert_called_once_with(SERVICE_NAME, "https://auth.app.kognic.com:client-1") - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_valid_token(self, _, mock_get): + def test_valid_token(self): token = _make_token(expires_in=3600) - mock_get.return_value = json.dumps(token) - result = load_cached_token("https://auth.app.kognic.com", "client-1") + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = json.dumps(token) + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNotNone(result) self.assertEqual(result["access_token"], "eyJ.test.token") - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_expired_token(self, _, mock_get): + def test_expired_token(self): token = _make_token(expires_in=-100) - mock_get.return_value = json.dumps(token) - result = load_cached_token("https://auth.app.kognic.com", "client-1") + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = json.dumps(token) + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_token_within_margin(self, _, mock_get): + def test_token_within_margin(self): token = _make_token(expires_in=EXPIRY_MARGIN_SECONDS - 1) - mock_get.return_value = json.dumps(token) - result = load_cached_token("https://auth.app.kognic.com", "client-1") + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = json.dumps(token) + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_no_expires_at(self, _, mock_get): + def test_no_expires_at(self): token = {"access_token": "eyJ.test.token", "token_type": "bearer"} - mock_get.return_value = json.dumps(token) - result = load_cached_token("https://auth.app.kognic.com", "client-1") + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = json.dumps(token) + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_corrupt_json(self, _, mock_get): - mock_get.return_value = "not valid json!!!" - result = load_cached_token("https://auth.app.kognic.com", "client-1") + def test_corrupt_json(self): + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = "not valid json!!!" + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password", side_effect=Exception("keyring error")) - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_keyring_error(self, _, mock_get): - result = load_cached_token("https://auth.app.kognic.com", "client-1") + def test_keyring_error(self): + mock_kr = mock.MagicMock() + mock_kr.get_password.side_effect = Exception("keyring error") + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNone(result) - @mock.patch("keyring.get_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_includes_refresh_token(self, _, mock_get): + def test_includes_refresh_token(self): token = _make_token(extra={"refresh_token": "refresh-abc"}) - mock_get.return_value = json.dumps(token) - result = load_cached_token("https://auth.app.kognic.com", "client-1") + mock_kr = mock.MagicMock() + mock_kr.get_password.return_value = json.dumps(token) + result = _cache_with_keyring(mock_kr).load("https://auth.app.kognic.com", "client-1") self.assertIsNotNone(result) self.assertEqual(result["refresh_token"], "refresh-abc") class SaveTokenTest(unittest.TestCase): - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) - def test_keyring_not_available(self, _): + def test_keyring_not_available(self): # Should not raise - save_token("https://auth.app.kognic.com", "client-1", _make_token()) + _cache_no_keyring().save("https://auth.app.kognic.com", "client-1", _make_token()) - @mock.patch("keyring.set_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_saves_to_keyring(self, _, mock_set): + def test_saves_to_keyring(self): token = _make_token() - save_token("https://auth.app.kognic.com", "client-1", token) - mock_set.assert_called_once_with( + mock_kr = mock.MagicMock() + _cache_with_keyring(mock_kr).save("https://auth.app.kognic.com", "client-1", token) + mock_kr.set_password.assert_called_once_with( SERVICE_NAME, "https://auth.app.kognic.com:client-1", json.dumps(token), ) - @mock.patch("keyring.set_password", side_effect=Exception("write failed")) - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_save_error_silenced(self, _, mock_set): + def test_save_error_silenced(self): + mock_kr = mock.MagicMock() + mock_kr.set_password.side_effect = Exception("write failed") # Should not raise - save_token("https://auth.app.kognic.com", "client-1", _make_token()) + _cache_with_keyring(mock_kr).save("https://auth.app.kognic.com", "client-1", _make_token()) class ClearTokenTest(unittest.TestCase): - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=False) - def test_keyring_not_available(self, _): + def test_keyring_not_available(self): # Should not raise - clear_token("https://auth.app.kognic.com", "client-1") + _cache_no_keyring().clear("https://auth.app.kognic.com", "client-1") - @mock.patch("keyring.delete_password") - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_clears_from_keyring(self, _, mock_delete): - clear_token("https://auth.app.kognic.com", "client-1") - mock_delete.assert_called_once_with( + def test_clears_from_keyring(self): + mock_kr = mock.MagicMock() + _cache_with_keyring(mock_kr).clear("https://auth.app.kognic.com", "client-1") + mock_kr.delete_password.assert_called_once_with( SERVICE_NAME, "https://auth.app.kognic.com:client-1", ) - @mock.patch("keyring.delete_password", side_effect=Exception("delete failed")) - @mock.patch("kognic.auth.cli.token_cache._keyring_available", return_value=True) - def test_clear_error_silenced(self, _, mock_delete): + def test_clear_error_silenced(self): + mock_kr = mock.MagicMock() + mock_kr.delete_password.side_effect = Exception("delete failed") # Should not raise - clear_token("https://auth.app.kognic.com", "client-1") + _cache_with_keyring(mock_kr).clear("https://auth.app.kognic.com", "client-1") if __name__ == "__main__": From d3cf23c1c1f7c3fd614a224f39b47096cafc09a6 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 20 Feb 2026 10:52:32 +0100 Subject: [PATCH 16/19] Add keyring-based credential storage and keyring:// URI support Introduces `kognic-auth credentials load/clear` subcommands to store full credentials in the system keyring. Adds `keyring://profile` URI support in resolve_credentials and env_config for per-environment keyring lookups. The keyring is also checked as a fallback in get_credentials_from_env. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 86 +++++++++++- src/kognic/auth/cli/__init__.py | 15 ++- src/kognic/auth/cli/credentials.py | 73 ++++++++++ src/kognic/auth/cli/get_access_token.py | 4 +- src/kognic/auth/credentials_parser.py | 30 ++++- src/kognic/auth/env_config.py | 2 +- src/kognic/auth/internal/credential_store.py | 77 +++++++++++ tests/test_cli.py | 76 +++++++++++ tests/test_config.py | 19 +++ tests/test_credential_store.py | 135 +++++++++++++++++++ tests/test_credentials_parser.py | 41 +++++- 11 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 src/kognic/auth/cli/credentials.py create mode 100644 src/kognic/auth/internal/credential_store.py create mode 100644 tests/test_credential_store.py diff --git a/README.md b/README.md index 98456e5..998bf69 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 [--profile PROFILE] +kognic-auth credentials clear [--profile PROFILE] +``` + +**`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`) +- `--profile` / `--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. diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py index fb20ca0..703ac78 100644 --- a/src/kognic/auth/cli/__init__.py +++ b/src/kognic/auth/cli/__init__.py @@ -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: @@ -32,7 +32,16 @@ def _configure_logging(verbose: bool = False) -> None: def main(args: list[str] | None = None) -> int: - parser = create_parser() + parser = argparse.ArgumentParser( + prog="kognic-auth", + description="Kognic authentication CLI", + ) + parser.add_argument("-v", "--verbose", action="store_true", default=False, help="Enable debug logging") + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + for subcommand in _SUBCOMMANDS: + subcommand.register_parser(subparsers) + parsed = parser.parse_args(args) _configure_logging(verbose=parsed.verbose) diff --git a/src/kognic/auth/cli/credentials.py b/src/kognic/auth/cli/credentials.py new file mode 100644 index 0000000..2c82945 --- /dev/null +++ b/src/kognic/auth/cli/credentials.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import sys + +from kognic.auth.credentials_parser import parse_credentials +from kognic.auth.internal.credential_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 diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 3fcc1b1..cf9b959 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -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", @@ -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: diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 26dbd87..73af8c2 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -58,6 +58,15 @@ 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 + + from kognic.auth.internal.credential_store import load_credentials + + kr_creds = load_credentials() + if kr_creds: + return kr_creds.client_id, kr_creds.client_secret + return client_id, client_secret @@ -83,11 +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://") :] + from kognic.auth.internal.credential_store import load_credentials + + kr_creds = load_credentials(profile) + if kr_creds: + client_id, client_secret = kr_creds.client_id, kr_creds.client_secret + else: + raise ValueError( + f"No credentials found in keyring for profile '{profile}'. " + f"Run 'kognic-auth credentials load --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: raise ValueError(f"Unsupported auth type: {type(auth)}") diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 8a446a3..195d6e1 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -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, diff --git a/src/kognic/auth/internal/credential_store.py b/src/kognic/auth/internal/credential_store.py new file mode 100644 index 0000000..3b121a5 --- /dev/null +++ b/src/kognic/auth/internal/credential_store.py @@ -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 + +from kognic.auth.credentials_parser import parse_credentials + +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 + + 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) diff --git a/tests/test_cli.py b/tests/test_cli.py index ba12458..499947f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import json import time import unittest +from pathlib import Path from unittest import mock from kognic.auth import DEFAULT_HOST @@ -1117,5 +1118,80 @@ def test_kog_uses_cached_token( ) +class CredentialsCommandTest(unittest.TestCase): + def test_load_stores_credentials(self): + import json + import tempfile + + creds = { + "clientId": "test-client-id", + "clientSecret": "test-secret", + "email": "test@kognic.com", + "userId": 1, + "issuer": "auth.kognic.test", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(creds, f) + path = f.name + + try: + with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save: + result = main(["credentials", "load", path]) + self.assertEqual(result, 0) + args, kwargs = mock_save.call_args + self.assertEqual(args[0].client_id, "test-client-id") + self.assertEqual(args[0].client_secret, "test-secret") + self.assertEqual(args[1], "default") + finally: + Path(path).unlink() + + def test_load_custom_profile(self): + import json + import tempfile + + creds = { + "clientId": "id", + "clientSecret": "secret", + "email": "e@kognic.com", + "userId": 1, + "issuer": "issuer", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(creds, f) + path = f.name + + try: + with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save: + result = main(["credentials", "load", path, "--env", "demo"]) + self.assertEqual(result, 0) + args, kwargs = mock_save.call_args + self.assertEqual(args[0].client_id, "id") + self.assertEqual(args[0].client_secret, "secret") + self.assertEqual(args[1], "demo") + finally: + Path(path).unlink() + + def test_load_missing_file_returns_error(self): + result = main(["credentials", "load", "/nonexistent/creds.json"]) + self.assertEqual(result, 1) + + def test_clear_removes_credentials(self): + with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear: + result = main(["credentials", "clear"]) + self.assertEqual(result, 0) + mock_clear.assert_called_once_with("default") + + def test_clear_custom_profile(self): + with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear: + result = main(["credentials", "clear", "--env", "demo"]) + self.assertEqual(result, 0) + mock_clear.assert_called_once_with("demo") + + def test_no_subcommand_prints_help(self): + with mock.patch("builtins.print"): + result = main(["credentials"]) + self.assertEqual(result, 0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 658f0e8..bd5cb8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -56,6 +56,25 @@ def test_invalid_json_raises(self): load_kognic_env_config(f.name) Path(f.name).unlink() + def test_keyring_uri_not_expanded(self): + """keyring:// credentials are stored as-is, not treated as file paths.""" + data = { + "environments": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "keyring://production", + } + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + config = load_kognic_env_config(f.name) + Path(f.name).unlink() + + self.assertEqual(config.environments["production"].credentials, "keyring://production") + def test_empty_contexts(self): data = {"environments": {}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: diff --git a/tests/test_credential_store.py b/tests/test_credential_store.py new file mode 100644 index 0000000..717cfd5 --- /dev/null +++ b/tests/test_credential_store.py @@ -0,0 +1,135 @@ +"""Tests for the keyring-based credential store.""" + +import json +import unittest +from unittest import mock + +from kognic.auth.credentials_parser import ApiCredentials +from kognic.auth.internal.credential_store import ( + DEFAULT_PROFILE, + SERVICE_NAME, + clear_credentials, + load_credentials, + save_credentials, +) + +FULL_CREDS_DICT = { + "clientId": "my-id", + "clientSecret": "my-secret", + "email": "test@kognic.com", + "userId": 1, + "issuer": "auth.kognic.test", +} + +FULL_CREDS = ApiCredentials( + client_id="my-id", + client_secret="my-secret", + email="test@kognic.com", + user_id=1, + issuer="auth.kognic.test", +) + + +def _mock_keyring(get_password=None, set_password=None, delete_password=None): + """Return a mock keyring module wired to the given side effects / return values.""" + kr = mock.MagicMock() + if get_password is not None: + kr.get_password.return_value = get_password + if set_password is not None: + kr.set_password.side_effect = set_password + if delete_password is not None: + kr.delete_password.side_effect = delete_password + kr.get_keyring.return_value = mock.MagicMock() # non-fail backend + return kr + + +class LoadCredentialsTest(unittest.TestCase): + def test_no_keyring_returns_none(self): + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + self.assertIsNone(load_credentials()) + + def test_not_stored_returns_none(self): + kr = _mock_keyring(get_password=None) + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + kr.get_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) + + def test_stored_credentials_returned(self): + data = json.dumps(FULL_CREDS_DICT) + kr = _mock_keyring(get_password=data) + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + result = load_credentials() + self.assertIsInstance(result, ApiCredentials) + self.assertEqual(result.client_id, "my-id") + self.assertEqual(result.client_secret, "my-secret") + self.assertEqual(result.email, "test@kognic.com") + self.assertEqual(result.user_id, 1) + self.assertEqual(result.issuer, "auth.kognic.test") + + def test_custom_profile(self): + data = json.dumps(FULL_CREDS_DICT) + kr = _mock_keyring(get_password=data) + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + load_credentials(profile="demo") + kr.get_password.assert_called_once_with(SERVICE_NAME, "demo") + + def test_corrupt_json_returns_none(self): + kr = _mock_keyring(get_password="not-json") + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + + def test_keyring_error_returns_none(self): + kr = mock.MagicMock() + kr.get_password.side_effect = Exception("keyring exploded") + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + + +class SaveCredentialsTest(unittest.TestCase): + def test_no_keyring_raises(self): + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + with self.assertRaises(RuntimeError) as ctx: + save_credentials(FULL_CREDS) + self.assertIn("keyring", str(ctx.exception).lower()) + + def test_stores_in_keyring(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + save_credentials(FULL_CREDS) + kr.set_password.assert_called_once_with( + SERVICE_NAME, + DEFAULT_PROFILE, + json.dumps(FULL_CREDS_DICT), + ) + + def test_custom_profile(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + save_credentials(FULL_CREDS, profile="demo") + kr.set_password.assert_called_once_with( + SERVICE_NAME, + "demo", + json.dumps(FULL_CREDS_DICT), + ) + + +class ClearCredentialsTest(unittest.TestCase): + def test_no_keyring_does_not_raise(self): + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + clear_credentials() # should not raise + + def test_clears_from_keyring(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + clear_credentials() + kr.delete_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) + + def test_error_silenced(self): + kr = mock.MagicMock() + kr.delete_password.side_effect = Exception("gone already") + with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + clear_credentials() # should not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index 382ec9f..25ac037 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -59,11 +59,32 @@ def test_parse_missing_key_raises(self): class TestGetCredentialsFromEnv(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) - def test_no_env_vars_returns_none(self): + @patch("kognic.auth.internal.credential_store.load_credentials", return_value=None) + def test_no_env_vars_returns_none(self, _): client_id, client_secret = get_credentials_from_env() self.assertIsNone(client_id) self.assertIsNone(client_secret) + @patch.dict(os.environ, {}, clear=True) + @patch( + "kognic.auth.internal.credential_store.load_credentials", + return_value=ApiCredentials( + client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" + ), + ) + def test_falls_back_to_keyring(self, _): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "kr_id") + self.assertEqual(client_secret, "kr_secret") + + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) + @patch("kognic.auth.internal.credential_store.load_credentials") + def test_env_vars_take_precedence_over_keyring(self, mock_load): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "env_id") + self.assertEqual(client_secret, "env_secret") + mock_load.assert_not_called() + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) def test_client_id_and_secret_env_vars(self): client_id, client_secret = get_credentials_from_env() @@ -174,6 +195,24 @@ def test_auth_dict(self): self.assertEqual(client_id, "test_id") self.assertEqual(client_secret, "test_secret") + @patch( + "kognic.auth.internal.credential_store.load_credentials", + return_value=ApiCredentials( + client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" + ), + ) + def test_auth_keyring_uri(self, mock_load): + client_id, client_secret = resolve_credentials(auth="keyring://myprofile") + self.assertEqual(client_id, "kr_id") + self.assertEqual(client_secret, "kr_secret") + mock_load.assert_called_once_with("myprofile") + + @patch("kognic.auth.internal.credential_store.load_credentials", return_value=None) + def test_auth_keyring_uri_not_found_raises(self, _): + with self.assertRaises(ValueError) as ctx: + resolve_credentials(auth="keyring://missing-profile") + self.assertIn("missing-profile", str(ctx.exception)) + if __name__ == "__main__": unittest.main() From b7f892afc16e6afcf4d65a9cd2207449f7ff1807 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 20 Feb 2026 16:19:56 +0100 Subject: [PATCH 17/19] fix imports --- src/kognic/auth/credentials_parser.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 73af8c2..baa0f9e 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Optional, Union +from kognic.auth.internal import credential_store + ANY_AUTH_TYPE = Union[str, os.PathLike, tuple, "ApiCredentials", dict, None] REQUIRED_CREDENTIALS_FILE_KEYS = [ @@ -61,9 +63,7 @@ def get_credentials_from_env() -> tuple[Optional[str], Optional[str]]: if client_id and client_secret: return client_id, client_secret - from kognic.auth.internal.credential_store import load_credentials - - kr_creds = load_credentials() + kr_creds = credential_store.load_credentials() if kr_creds: return kr_creds.client_id, kr_creds.client_secret @@ -94,9 +94,7 @@ def resolve_credentials( path = str(auth) if path.startswith("keyring://"): profile = path[len("keyring://") :] - from kognic.auth.internal.credential_store import load_credentials - - kr_creds = load_credentials(profile) + kr_creds = credential_store.load_credentials(profile) if kr_creds: client_id, client_secret = kr_creds.client_id, kr_creds.client_secret else: @@ -111,6 +109,7 @@ def resolve_credentials( 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: From b7e88645448ef4e04c355619e898b807b6c16959 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 20 Feb 2026 16:24:02 +0100 Subject: [PATCH 18/19] fix imports --- src/kognic/auth/credentials_parser.py | 14 +++++++------- .../{credential_store.py => credentials_store.py} | 0 tests/test_credentials_parser.py | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) rename src/kognic/auth/internal/{credential_store.py => credentials_store.py} (100%) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index baa0f9e..05cad84 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional, Union -from kognic.auth.internal import credential_store +from kognic.auth.internal import credentials_store ANY_AUTH_TYPE = Union[str, os.PathLike, tuple, "ApiCredentials", dict, None] @@ -63,9 +63,9 @@ def get_credentials_from_env() -> tuple[Optional[str], Optional[str]]: if client_id and client_secret: return client_id, client_secret - kr_creds = credential_store.load_credentials() - if kr_creds: - return kr_creds.client_id, kr_creds.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 @@ -94,9 +94,9 @@ def resolve_credentials( path = str(auth) if path.startswith("keyring://"): profile = path[len("keyring://") :] - kr_creds = credential_store.load_credentials(profile) - if kr_creds: - client_id, client_secret = kr_creds.client_id, kr_creds.client_secret + 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}'. " diff --git a/src/kognic/auth/internal/credential_store.py b/src/kognic/auth/internal/credentials_store.py similarity index 100% rename from src/kognic/auth/internal/credential_store.py rename to src/kognic/auth/internal/credentials_store.py diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index 25ac037..f853963 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -59,7 +59,7 @@ def test_parse_missing_key_raises(self): class TestGetCredentialsFromEnv(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) - @patch("kognic.auth.internal.credential_store.load_credentials", return_value=None) + @patch("kognic.auth.internal.credentials_store.load_credentials", return_value=None) def test_no_env_vars_returns_none(self, _): client_id, client_secret = get_credentials_from_env() self.assertIsNone(client_id) @@ -67,7 +67,7 @@ def test_no_env_vars_returns_none(self, _): @patch.dict(os.environ, {}, clear=True) @patch( - "kognic.auth.internal.credential_store.load_credentials", + "kognic.auth.internal.credentials_store.load_credentials", return_value=ApiCredentials( client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" ), @@ -78,7 +78,7 @@ def test_falls_back_to_keyring(self, _): self.assertEqual(client_secret, "kr_secret") @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) - @patch("kognic.auth.internal.credential_store.load_credentials") + @patch("kognic.auth.internal.credentials_store.load_credentials") def test_env_vars_take_precedence_over_keyring(self, mock_load): client_id, client_secret = get_credentials_from_env() self.assertEqual(client_id, "env_id") @@ -196,7 +196,7 @@ def test_auth_dict(self): self.assertEqual(client_secret, "test_secret") @patch( - "kognic.auth.internal.credential_store.load_credentials", + "kognic.auth.internal.credentials_store.load_credentials", return_value=ApiCredentials( client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" ), @@ -207,7 +207,7 @@ def test_auth_keyring_uri(self, mock_load): self.assertEqual(client_secret, "kr_secret") mock_load.assert_called_once_with("myprofile") - @patch("kognic.auth.internal.credential_store.load_credentials", return_value=None) + @patch("kognic.auth.internal.credentials_store.load_credentials", return_value=None) def test_auth_keyring_uri_not_found_raises(self, _): with self.assertRaises(ValueError) as ctx: resolve_credentials(auth="keyring://missing-profile") From 2e3a32a8af0c6143fbb22135d693404046f43ed6 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 20 Feb 2026 16:43:58 +0100 Subject: [PATCH 19/19] Fix README --profile flag, circular import, and rename credential_store - README: fix synopsis showing --profile instead of --env - credentials_store.py: move parse_credentials import inside load_credentials to break circular dependency with credentials_parser.py - credentials_parser.py: add missing credentials_store import - Rename credential_store.py -> credentials_store.py (and test file) - Update all references and mock patch paths accordingly Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 ++--- src/kognic/auth/cli/credentials.py | 2 +- src/kognic/auth/internal/credentials_store.py | 4 +-- tests/test_credentials_parser.py | 10 +++---- ...ial_store.py => test_credentials_store.py} | 26 +++++++++---------- 5 files changed, 24 insertions(+), 24 deletions(-) rename tests/{test_credential_store.py => test_credentials_store.py} (76%) diff --git a/README.md b/README.md index 998bf69..3c76a88 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,14 @@ kognic-auth get-access-token --env demo --server https://custom.server 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 [--profile PROFILE] -kognic-auth credentials clear [--profile PROFILE] +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`) -- `--profile` / `--env` - Profile name to store under (default: `default`). Use the environment name from `environments.json` to link the credentials to that environment. +- `--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. diff --git a/src/kognic/auth/cli/credentials.py b/src/kognic/auth/cli/credentials.py index 2c82945..48455d7 100644 --- a/src/kognic/auth/cli/credentials.py +++ b/src/kognic/auth/cli/credentials.py @@ -4,7 +4,7 @@ import sys from kognic.auth.credentials_parser import parse_credentials -from kognic.auth.internal.credential_store import DEFAULT_PROFILE, clear_credentials, save_credentials +from kognic.auth.internal.credentials_store import DEFAULT_PROFILE, clear_credentials, save_credentials COMMAND = "credentials" diff --git a/src/kognic/auth/internal/credentials_store.py b/src/kognic/auth/internal/credentials_store.py index 3b121a5..b2ae459 100644 --- a/src/kognic/auth/internal/credentials_store.py +++ b/src/kognic/auth/internal/credentials_store.py @@ -9,8 +9,6 @@ if TYPE_CHECKING: from kognic.auth.credentials_parser import ApiCredentials -from kognic.auth.credentials_parser import parse_credentials - SERVICE_NAME = "kognic-credentials" DEFAULT_PROFILE = "default" @@ -40,6 +38,8 @@ def load_credentials(profile: str = DEFAULT_PROFILE) -> Optional[ApiCredentials] 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) diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index f853963..23d5c51 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -59,7 +59,7 @@ def test_parse_missing_key_raises(self): class TestGetCredentialsFromEnv(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) - @patch("kognic.auth.internal.credentials_store.load_credentials", return_value=None) + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=None) def test_no_env_vars_returns_none(self, _): client_id, client_secret = get_credentials_from_env() self.assertIsNone(client_id) @@ -67,7 +67,7 @@ def test_no_env_vars_returns_none(self, _): @patch.dict(os.environ, {}, clear=True) @patch( - "kognic.auth.internal.credentials_store.load_credentials", + "kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=ApiCredentials( client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" ), @@ -78,7 +78,7 @@ def test_falls_back_to_keyring(self, _): self.assertEqual(client_secret, "kr_secret") @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) - @patch("kognic.auth.internal.credentials_store.load_credentials") + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials") def test_env_vars_take_precedence_over_keyring(self, mock_load): client_id, client_secret = get_credentials_from_env() self.assertEqual(client_id, "env_id") @@ -196,7 +196,7 @@ def test_auth_dict(self): self.assertEqual(client_secret, "test_secret") @patch( - "kognic.auth.internal.credentials_store.load_credentials", + "kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=ApiCredentials( client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" ), @@ -207,7 +207,7 @@ def test_auth_keyring_uri(self, mock_load): self.assertEqual(client_secret, "kr_secret") mock_load.assert_called_once_with("myprofile") - @patch("kognic.auth.internal.credentials_store.load_credentials", return_value=None) + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=None) def test_auth_keyring_uri_not_found_raises(self, _): with self.assertRaises(ValueError) as ctx: resolve_credentials(auth="keyring://missing-profile") diff --git a/tests/test_credential_store.py b/tests/test_credentials_store.py similarity index 76% rename from tests/test_credential_store.py rename to tests/test_credentials_store.py index 717cfd5..e7017ab 100644 --- a/tests/test_credential_store.py +++ b/tests/test_credentials_store.py @@ -5,7 +5,7 @@ from unittest import mock from kognic.auth.credentials_parser import ApiCredentials -from kognic.auth.internal.credential_store import ( +from kognic.auth.internal.credentials_store import ( DEFAULT_PROFILE, SERVICE_NAME, clear_credentials, @@ -45,19 +45,19 @@ def _mock_keyring(get_password=None, set_password=None, delete_password=None): class LoadCredentialsTest(unittest.TestCase): def test_no_keyring_returns_none(self): - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): self.assertIsNone(load_credentials()) def test_not_stored_returns_none(self): kr = _mock_keyring(get_password=None) - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): self.assertIsNone(load_credentials()) kr.get_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) def test_stored_credentials_returned(self): data = json.dumps(FULL_CREDS_DICT) kr = _mock_keyring(get_password=data) - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): result = load_credentials() self.assertIsInstance(result, ApiCredentials) self.assertEqual(result.client_id, "my-id") @@ -69,32 +69,32 @@ def test_stored_credentials_returned(self): def test_custom_profile(self): data = json.dumps(FULL_CREDS_DICT) kr = _mock_keyring(get_password=data) - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): load_credentials(profile="demo") kr.get_password.assert_called_once_with(SERVICE_NAME, "demo") def test_corrupt_json_returns_none(self): kr = _mock_keyring(get_password="not-json") - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): self.assertIsNone(load_credentials()) def test_keyring_error_returns_none(self): kr = mock.MagicMock() kr.get_password.side_effect = Exception("keyring exploded") - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): self.assertIsNone(load_credentials()) class SaveCredentialsTest(unittest.TestCase): def test_no_keyring_raises(self): - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): with self.assertRaises(RuntimeError) as ctx: save_credentials(FULL_CREDS) self.assertIn("keyring", str(ctx.exception).lower()) def test_stores_in_keyring(self): kr = mock.MagicMock() - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): save_credentials(FULL_CREDS) kr.set_password.assert_called_once_with( SERVICE_NAME, @@ -104,7 +104,7 @@ def test_stores_in_keyring(self): def test_custom_profile(self): kr = mock.MagicMock() - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): save_credentials(FULL_CREDS, profile="demo") kr.set_password.assert_called_once_with( SERVICE_NAME, @@ -115,19 +115,19 @@ def test_custom_profile(self): class ClearCredentialsTest(unittest.TestCase): def test_no_keyring_does_not_raise(self): - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=None): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): clear_credentials() # should not raise def test_clears_from_keyring(self): kr = mock.MagicMock() - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): clear_credentials() kr.delete_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) def test_error_silenced(self): kr = mock.MagicMock() kr.delete_password.side_effect = Exception("gone already") - with mock.patch("kognic.auth.internal.credential_store._get_keyring", return_value=kr): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): clear_credentials() # should not raise