From f1187d63859590813cc99a745d15dc6115b96cee Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 17 Mar 2026 12:51:15 +0100 Subject: [PATCH 1/2] Add OAuth2 scopes support for token requests Adds a `scopes` parameter (List[str]) threaded through all auth clients, base clients, and the CLI. Scopes can also be set in credentials.json as a `"scopes"` array, used as defaults when no explicit scopes are given. Co-Authored-By: Claude Opus 4.6 --- README.md | 34 +++++++++++++++++++++--- src/kognic/auth/cli/get_access_token.py | 7 +++++ src/kognic/auth/credentials.py | 5 ++-- src/kognic/auth/credentials_parser.py | 1 + src/kognic/auth/httpx/async_client.py | 7 +++++ src/kognic/auth/httpx/base_client.py | 5 +++- src/kognic/auth/requests/auth_session.py | 8 +++++- src/kognic/auth/requests/base_client.py | 21 +++++++++++---- tests/test_base_client_sync.py | 4 +-- tests/test_cli.py | 7 +++++ 10 files changed, 85 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cf502a9..2c89223 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,23 @@ The credentials will contain the Client Id and 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)) +The credentials file also supports an optional `scopes` field — an array of OAuth2 scopes +to request when fetching tokens: + +```json +{ + "clientId": "...", + "clientSecret": "...", + "email": "...", + "userId": 123, + "issuer": "...", + "scopes": ["api:read", "api:write"] +} +``` + +Scopes from the credentials file are used as defaults. They can be overridden by passing +`scopes` explicitly to any client constructor or CLI command. + API clients such as the `InputApiClient` accept this `auth` parameter. Under the hood, they commonly use the AuthSession class which is implements a `requests` session with automatic token @@ -76,7 +93,7 @@ Each environment has the following fields: Generate an access token for Kognic API authentication. ```bash -kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--env-config-file-path FILE] +kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--env-config-file-path FILE] [--scopes SCOPE ...] ``` **Options:** @@ -84,6 +101,7 @@ kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. - `--env` - Use a named environment from the config file. - `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`) +- `--scopes` - OAuth2 scopes to request (e.g. `--scopes api:read api:write`). Overrides scopes from credentials file. When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values. @@ -100,13 +118,16 @@ kognic-auth get-access-token --env example # Using an environment but overriding the server kognic-auth get-access-token --env example --server https://custom.server + +# Request specific scopes +kognic-auth get-access-token --scopes api:read api:write ``` ### kognic-auth credentials -Manage credentials stored in the system keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager, etc.). +Manage credentials stored in the system keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager, etc.). This is the recommended way to store credentials on a developer machine — more secure than a credentials file and no environment variables in shell profiles. -Credentials files downloaded from the Kognic Platform UI can be put into the keyring. +Credentials files downloaded from the Kognic Platform UI can be put into the keyring. ```bash kognic-auth credentials put FILE [--env ENV] @@ -271,6 +292,9 @@ client = MyApiClient(auth=("my-client-id", "my-client-secret")) # Or with credentials file client = MyApiClient(auth="~/.config/kognic/credentials.json") + +# With explicit scopes +client = MyApiClient(auth=("my-client-id", "my-client-secret"), scopes=["api:read", "api:write"]) ``` ### Async Client (httpx) @@ -287,6 +311,10 @@ class MyAsyncApiClient(BaseAsyncApiClient): # Usage as async context manager async with MyAsyncApiClient() as client: resource = await client.get_resource("123") + +# With explicit scopes +async with MyAsyncApiClient(scopes=["api:read"]) as client: + resource = await client.get_resource("123") ``` ## Serialization & Deserialization diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index cf9b959..1c37025 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -43,6 +43,12 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument help="Token cache backend: auto (default), keyring, file, or none. " "Auto will use keyring if available, otherwise file-based caching.", ) + token_parser.add_argument( + "--scopes", + nargs="+", + default=None, + help="OAuth2 scopes to request, e.g. --scopes api:read api:write", + ) return token_parser @@ -70,6 +76,7 @@ def run(parsed: argparse.Namespace) -> int: auth=credentials, auth_host=auth_host, token_cache=make_cache(parsed.token_cache), + scopes=parsed.scopes, ) print(provider.ensure_token()["access_token"]) return 0 diff --git a/src/kognic/auth/credentials.py b/src/kognic/auth/credentials.py index 909982c..88a9ee8 100644 --- a/src/kognic/auth/credentials.py +++ b/src/kognic/auth/credentials.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional +from typing import List, Optional @dataclass @@ -13,3 +13,4 @@ class ApiCredentials: name: str = "API Credentials" created: Optional[datetime] = None expires: Optional[datetime] = None + scopes: List[str] = field(default_factory=list) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 27508e0..f1e2de8 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -73,6 +73,7 @@ def parse_credentials(path: Union[str, os.PathLike, dict]) -> ApiCredentials: name=credentials.get("name", "API Credentials"), created=_parse_optional_datetime(credentials.get("created")), expires=_parse_optional_datetime(credentials.get("expires")), + scopes=credentials.get("scopes", []), ) diff --git a/src/kognic/auth/httpx/async_client.py b/src/kognic/auth/httpx/async_client.py index 6bada10..e748efc 100644 --- a/src/kognic/auth/httpx/async_client.py +++ b/src/kognic/auth/httpx/async_client.py @@ -1,5 +1,6 @@ import logging from asyncio import Lock +from typing import List, Optional import httpx from authlib.integrations.httpx_client import AsyncOAuth2Client @@ -30,6 +31,7 @@ def __init__( auth=None, host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + scopes: Optional[List[str]] = None, **kwargs, ): """Initialize the async auth client. @@ -38,6 +40,7 @@ def __init__( auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple host: Base url for authentication server token_endpoint: Relative path to the token endpoint + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. **kwargs: Additional params to pass into Httpx Client Constructor """ self.host = host @@ -50,12 +53,16 @@ def __init__( client_id = creds.client_id if creds else None client_secret = creds.client_secret if creds else None + if scopes is None and creds and creds.scopes: + scopes = creds.scopes + self._oauth_client = _AsyncFixedClient( client_id=client_id, client_secret=client_secret, update_token=self._update_token, token_endpoint=self.token_url, grant_type="client_credentials", + scope=" ".join(scopes) if scopes else None, **kwargs, ) self._oauth_client.register_compliance_hook("access_token_response", AuthClient.check_rate_limit) diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index a5ffe7a..0898919 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -5,7 +5,7 @@ import asyncio import logging import os -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union from kognic.auth.credentials_parser import ANY_AUTH_TYPE @@ -71,6 +71,7 @@ def __init__( client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, sunset_handler: Optional[SunsetHandler] = _DEFAULT_SUNSET_HANDLER, + scopes: Optional[List[str]] = None, **kwargs, ): """Initialize the async API client. @@ -83,6 +84,7 @@ def __init__( json_serializer: Callable to serialize request bodies. Defaults to serialize_body. sunset_handler: Callable invoked with ``(sunset_date, method, url)`` when a sunset header is detected. Defaults to logging a warning or error. Pass ``None`` to disable. + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. **kwargs: Additional arguments passed to the underlying httpx client (e.g. timeout, verify). """ if client_name == "auto": @@ -98,6 +100,7 @@ def __init__( auth=auth, host=auth_host, token_endpoint=auth_token_endpoint, + scopes=scopes, headers=headers, **kwargs, ) diff --git a/src/kognic/auth/requests/auth_session.py b/src/kognic/auth/requests/auth_session.py index 0d4b67b..7b026f4 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 Callable, Optional +from typing import Callable, List, Optional import requests from authlib.common.errors import AuthlibBaseError @@ -59,6 +59,7 @@ def __init__( token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, initial_token: Optional[dict] = None, on_token_updated: Optional[Callable[[dict], None]] = None, + scopes: Optional[List[str]] = None, **kwargs, ): """Initialize the auth session. @@ -71,6 +72,7 @@ def __init__( 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. + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. **kwargs: Additional params to pass into Client Constructor """ self.host = host @@ -84,6 +86,9 @@ def __init__( self._client_id = client_id self._on_token_updated = on_token_updated + if scopes is None and creds and creds.scopes: + scopes = creds.scopes + self.oauth_session = _FixedSession( client_id=client_id, client_secret=client_secret, @@ -92,6 +97,7 @@ def __init__( update_token=self._update_token, token_endpoint=self.token_url, token=initial_token, + scope=" ".join(scopes) if scopes else None, **kwargs, ) self.oauth_session.register_compliance_hook("access_token_response", AuthClient.check_rate_limit) diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index f288274..a3b47f1 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -6,7 +6,7 @@ import os import threading from threading import Lock -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union from weakref import WeakValueDictionary if TYPE_CHECKING: @@ -105,6 +105,7 @@ def create_session( on_token_updated: Optional[Callable[[dict], None]] = None, token_provider: Optional[RequestsAuthSession] = None, sunset_handler: Optional[SunsetHandler] = _DEFAULT_SUNSET_HANDLER, + scopes: Optional[List[str]] = None, ) -> Session: """Create a requests session with enhancements. @@ -126,6 +127,7 @@ def create_session( are ignored. Multiple sessions sharing one provider share the same token lifecycle. sunset_handler: Callable invoked with ``(sunset_date, method, url)`` when a sunset header is detected. Defaults to logging a warning or error. Pass ``None`` to disable. + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. Returns: Configured requests Session @@ -137,6 +139,7 @@ def create_session( token_endpoint=auth_token_endpoint, initial_token=initial_token, on_token_updated=on_token_updated, + scopes=scopes, ) session = requests.Session() @@ -154,6 +157,7 @@ def make_token_provider( auth_host: str = DEFAULT_HOST, auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, token_cache: Optional[TokenCache] = None, + scopes: Optional[List[str]] = None, ) -> RequestsAuthSession: """Create a RequestsAuthSession wired to an optional token cache. @@ -163,6 +167,7 @@ def make_token_provider( 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. + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. Returns: Configured RequestsAuthSession @@ -175,6 +180,7 @@ def make_token_provider( 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, + scopes=scopes, ) @@ -187,10 +193,11 @@ def _get_shared_provider( auth_host: str, auth_token_endpoint: str, token_cache: Optional[TokenCache] = None, + scopes: Optional[List[str]] = 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, cache_type) and held + Providers are keyed by (client_id, auth_host, auth_token_endpoint, cache_type, scopes) and held weakly, so they are GC'd once no BaseApiClient instances reference them. """ credentials = _resolve_credentials(auth) @@ -198,9 +205,9 @@ def _get_shared_provider( client_secret = credentials.client_secret if credentials else None if not client_id or not client_secret: - return RequestsAuthSession(auth=auth, host=auth_host, token_endpoint=auth_token_endpoint) + return RequestsAuthSession(auth=auth, host=auth_host, token_endpoint=auth_token_endpoint, scopes=scopes) - key = (client_id, auth_host, auth_token_endpoint, type(token_cache)) + key = (client_id, auth_host, auth_token_endpoint, type(token_cache), tuple(scopes) if scopes else None) with _provider_pool_lock: provider = _provider_pool.get(key) if provider is None: @@ -210,6 +217,7 @@ def _get_shared_provider( 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, + scopes=scopes, ) _provider_pool[key] = provider return provider @@ -248,6 +256,7 @@ def __init__( token_provider: Optional[RequestsAuthSession] = None, token_cache: Optional[TokenCache] = None, sunset_handler: Optional[SunsetHandler] = _DEFAULT_SUNSET_HANDLER, + scopes: Optional[List[str]] = None, ): """Initialize the API client. @@ -263,6 +272,7 @@ def __init__( token is injected on startup and new tokens are saved automatically. sunset_handler: Callable invoked with ``(sunset_date, method, url)`` when a sunset header is detected. Defaults to logging a warning or error. Pass ``None`` to disable. + scopes: OAuth2 scopes to request, e.g. ["api:read", "api:write"]. """ self._session: Optional[Session] = None self._auth = auth @@ -272,6 +282,7 @@ def __init__( self._token_provider = token_provider self._token_cache = token_cache self._sunset_handler = sunset_handler + self._scopes = scopes self._lock = Lock() if client_name == "auto": @@ -288,7 +299,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._token_cache + self._auth, self._auth_host, self._auth_token_endpoint, self._token_cache, self._scopes ) self._session = create_session( token_provider=provider, diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py index 0273dbe..5739041 100644 --- a/tests/test_base_client_sync.py +++ b/tests/test_base_client_sync.py @@ -296,7 +296,7 @@ def test_pool_entry_alive_while_client_referenced(self, _resolve, mock_ras, mock client = BaseApiClient(auth=("id1", "secret1")) _ = client.session - pool_key = ("id1", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None)) + pool_key = ("id1", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None), None) self.assertIn(pool_key, _provider_pool) _ = client # keep alive @@ -322,7 +322,7 @@ def test_provider_gc_when_all_clients_deleted(self): ): client = BaseApiClient(auth=("id-gc", "secret-gc")) session = client.session - pool_key = ("id-gc", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None)) + pool_key = ("id-gc", DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH, type(None), None) provider_ref = weakref.ref(_provider_pool[pool_key]) self.assertIsNotNone(provider_ref()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8a62543..214225b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -93,6 +93,7 @@ def test_main_with_credentials_file(self, mock_make_provider): auth="/path/to/creds.json", auth_host=DEFAULT_HOST, token_cache=None, + scopes=None, ) @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @@ -108,6 +109,7 @@ def test_main_with_custom_server(self, mock_make_provider): auth=None, auth_host="https://custom.server", token_cache=None, + scopes=None, ) @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @@ -132,6 +134,7 @@ def test_main_with_all_options(self, mock_make_provider): auth="creds.json", auth_host="https://my.server", token_cache=None, + scopes=None, ) @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @@ -190,6 +193,7 @@ def test_main_with_context(self, mock_load_config, mock_make_provider): auth="/path/to/demo-creds.json", auth_host="https://auth.demo.kognic.com", token_cache=None, + scopes=None, ) @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") @@ -219,6 +223,7 @@ def test_main_with_context_server_override(self, mock_load_config, mock_make_pro auth="/path/to/demo-creds.json", auth_host="https://custom.server", token_cache=None, + scopes=None, ) def test_main_with_unknown_context(self): @@ -265,6 +270,7 @@ def test_cache_hit_injects_token_into_provider(self, mock_make_cache, mock_make_ auth=mock.ANY, auth_host=mock.ANY, token_cache=mock_cache, + scopes=None, ) mock_print.assert_called_once_with("cached-token-abc") @@ -287,6 +293,7 @@ def test_no_cache_passes_none_to_provider(self, mock_make_cache, mock_make_provi auth=mock.ANY, auth_host=mock.ANY, token_cache=None, + scopes=None, ) @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") From d5f1d8303ad8ea64cf65e0ec74c58dcf1ad35809 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 17 Mar 2026 16:34:48 +0100 Subject: [PATCH 2/2] Support scopes in environment config and fix token cache keying Move scopes configuration to environment.json as the primary source, with credentials file as fallback. Environment scopes trump credential scopes, and explicit scopes (constructor/CLI) trump both. Include scopes in the token cache key (hashed) to prevent serving cached tokens with wrong scopes across different configurations. Co-Authored-By: Claude Opus 4.6 --- README.md | 17 ++++-- src/kognic/auth/cli/get_access_token.py | 8 ++- src/kognic/auth/env_config.py | 4 +- src/kognic/auth/httpx/base_client.py | 2 + src/kognic/auth/internal/token_cache/_base.py | 15 +++-- src/kognic/auth/internal/token_cache/_file.py | 12 ++-- .../auth/internal/token_cache/_keyring.py | 12 ++-- src/kognic/auth/requests/base_client.py | 16 +++-- tests/test_cli.py | 59 +++++++++++++++++++ tests/test_config.py | 24 ++++++++ 10 files changed, 140 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2c89223..4fa61f5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ The credentials will contain the Client Id and 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)) -The credentials file also supports an optional `scopes` field — an array of OAuth2 scopes +OAuth2 scopes can be configured to restrict the permissions of tokens. Scopes are resolved +in this order (first match wins): +1. Explicit `scopes` parameter passed to client constructors or `--scopes` on the CLI +2. `scopes` field in the environment configuration (`environments.json`) +3. `scopes` field in the credentials file + +The credentials file supports an optional `scopes` field — an array of OAuth2 scopes to request when fetching tokens: ```json @@ -31,9 +37,6 @@ to request when fetching tokens: } ``` -Scopes from the credentials file are used as defaults. They can be overridden by passing -`scopes` explicitly to any client constructor or CLI command. - API clients such as the `InputApiClient` accept this `auth` parameter. Under the hood, they commonly use the AuthSession class which is implements a `requests` session with automatic token @@ -72,7 +75,8 @@ The CLI can be configured with a JSON file at `~/.config/kognic/environments.jso "example": { "host": "example.kognic.com", "auth_server": "https://auth.example.kognic.com", - "credentials": "~/.config/kognic/credentials-example.json" + "credentials": "~/.config/kognic/credentials-example.json", + "scopes": ["api:read", "api:write"] } } } @@ -81,6 +85,7 @@ 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 `kog` to automatically match an environment based on the request URL. - `auth_server` - The OAuth server URL used to fetch tokens. +- `scopes` *(optional)* - OAuth2 scopes to request when fetching tokens. Overrides scopes from the credentials file. - `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) @@ -101,7 +106,7 @@ kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. - `--env` - Use a named environment from the config file. - `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`) -- `--scopes` - OAuth2 scopes to request (e.g. `--scopes api:read api:write`). Overrides scopes from credentials file. +- `--scopes` - OAuth2 scopes to request (e.g. `--scopes api:read api:write`). Overrides scopes from environment config and credentials file. When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values. diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 1c37025..0bfc678 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -57,6 +57,7 @@ def run(parsed: argparse.Namespace) -> int: try: host = parsed.server credentials = parsed.credentials + env_scopes = [] if parsed.env_name: config = load_kognic_env_config(parsed.env_config_file_path) @@ -69,14 +70,19 @@ def run(parsed: argparse.Namespace) -> int: host = ctx.auth_server if credentials is None: credentials = ctx.credentials + env_scopes = ctx.scopes auth_host = host or DEFAULT_HOST + scopes = parsed.scopes + if scopes is None and env_scopes: + scopes = env_scopes + provider = make_token_provider( auth=credentials, auth_host=auth_host, token_cache=make_cache(parsed.token_cache), - scopes=parsed.scopes, + scopes=scopes, ) print(provider.ensure_token()["access_token"]) return 0 diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 195d6e1..18f5c27 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -2,7 +2,7 @@ import os from dataclasses import dataclass, field from pathlib import Path -from typing import Optional, Union +from typing import List, Optional, Union from urllib.parse import urlparse from kognic.auth import DEFAULT_ENV_CONFIG_FILE_PATH, DEFAULT_HOST, DEFAULT_KOGNIC_PLATFORM @@ -14,6 +14,7 @@ class Environment: host: str auth_server: str credentials: Optional[str] = None + scopes: List[str] = field(default_factory=list) @dataclass @@ -40,6 +41,7 @@ def load_kognic_env_config(path: Union[str, os.PathLike] = DEFAULT_ENV_CONFIG_FI host=env_data["host"], auth_server=env_data["auth_server"], credentials=credentials, + scopes=env_data.get("scopes", []), ) return KognicEnvConfig( diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index 0898919..88d85c6 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -169,4 +169,6 @@ def from_env( resolved = cfg.environments[env] kwargs.setdefault("auth", resolved.credentials) kwargs["auth_host"] = resolved.auth_server + if resolved.scopes and "scopes" not in kwargs: + kwargs["scopes"] = resolved.scopes return cls(**kwargs) diff --git a/src/kognic/auth/internal/token_cache/_base.py b/src/kognic/auth/internal/token_cache/_base.py index ae489a8..b34ad7f 100644 --- a/src/kognic/auth/internal/token_cache/_base.py +++ b/src/kognic/auth/internal/token_cache/_base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import time from abc import ABC, abstractmethod from typing import Optional @@ -11,8 +12,12 @@ EXPIRY_MARGIN_SECONDS = 30 -def make_key(auth_server: str, client_id: str) -> str: - return f"{auth_server}:{client_id}" +def make_key(auth_server: str, client_id: str, scopes: Optional[str] = None) -> str: + key = f"{auth_server}:{client_id}" + if scopes: + scope_hash = hashlib.sha256(scopes.encode()).hexdigest()[:12] + key = f"{key}:s-{scope_hash}" + return key def is_valid(token: dict) -> bool: @@ -26,13 +31,13 @@ class TokenCache(ABC): """Abstract base class for token caches.""" @abstractmethod - def load(self, auth_server: str, client_id: str) -> Optional[dict]: + def load(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> Optional[dict]: """Return a non-expired token dict, or None.""" @abstractmethod - def save(self, auth_server: str, client_id: str, token: dict) -> None: + def save(self, auth_server: str, client_id: str, token: dict, scopes: Optional[str] = None) -> None: """Persist a token dict. Silently ignores errors.""" @abstractmethod - def clear(self, auth_server: str, client_id: str) -> None: + def clear(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> 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 index d8b1fe9..cdf0d7a 100644 --- a/src/kognic/auth/internal/token_cache/_file.py +++ b/src/kognic/auth/internal/token_cache/_file.py @@ -30,9 +30,9 @@ 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]: + def load(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> Optional[dict]: try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) token = self._load_all().get(key) if token is None: return None @@ -45,9 +45,9 @@ def load(self, auth_server: str, client_id: str) -> Optional[dict]: 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: + def save(self, auth_server: str, client_id: str, token: dict, scopes: Optional[str] = None) -> None: try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) data = self._load_all() data[key] = token self._save_all(data) @@ -55,9 +55,9 @@ def save(self, auth_server: str, client_id: str, token: dict) -> None: except Exception: log.debug("Failed to save token to file cache", exc_info=True) - def clear(self, auth_server: str, client_id: str) -> None: + def clear(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> None: try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) data = self._load_all() if key in data: del data[key] diff --git a/src/kognic/auth/internal/token_cache/_keyring.py b/src/kognic/auth/internal/token_cache/_keyring.py index ceebbc3..f0c6a8a 100644 --- a/src/kognic/auth/internal/token_cache/_keyring.py +++ b/src/kognic/auth/internal/token_cache/_keyring.py @@ -35,12 +35,12 @@ def _keyring(self): return None return self._keyring_module - def load(self, auth_server: str, client_id: str) -> Optional[dict]: + def load(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> Optional[dict]: kr = self._keyring() if kr is None: return None try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) stored = kr.get_password(SERVICE_NAME, key) if stored is None: return None @@ -54,23 +54,23 @@ def load(self, auth_server: str, client_id: str) -> Optional[dict]: 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: + def save(self, auth_server: str, client_id: str, token: dict, scopes: Optional[str] = None) -> None: kr = self._keyring() if kr is None: return try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) 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: + def clear(self, auth_server: str, client_id: str, scopes: Optional[str] = None) -> None: kr = self._keyring() if kr is None: return try: - key = make_key(auth_server, client_id) + key = make_key(auth_server, client_id, scopes) kr.delete_password(SERVICE_NAME, key) log.debug("Cleared cached token from keyring for key=%s", key) except Exception: diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index a3b47f1..8b4dbae 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -174,12 +174,15 @@ def make_token_provider( """ credentials = _resolve_credentials(auth) client_id = credentials.client_id if credentials else None + scope_str = " ".join(scopes) if scopes else None return RequestsAuthSession( auth=credentials, 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, + initial_token=(token_cache.load(auth_host, client_id, scope_str) if (token_cache and client_id) else None), + on_token_updated=( + (lambda t: token_cache.save(auth_host, client_id, t, scope_str)) if (token_cache and client_id) else None + ), scopes=scopes, ) @@ -211,12 +214,15 @@ def _get_shared_provider( with _provider_pool_lock: provider = _provider_pool.get(key) if provider is None: + scope_str = " ".join(scopes) if scopes else None provider = 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 else None, - on_token_updated=(lambda t: token_cache.save(auth_host, client_id, t)) if token_cache else None, + initial_token=token_cache.load(auth_host, client_id, scope_str) if token_cache else None, + on_token_updated=( + (lambda t: token_cache.save(auth_host, client_id, t, scope_str)) if token_cache else None + ), scopes=scopes, ) _provider_pool[key] = provider @@ -335,4 +341,6 @@ def from_env( resolved = cfg.environments[env] kwargs.setdefault("auth", resolved.credentials) kwargs["auth_host"] = resolved.auth_server + if resolved.scopes and "scopes" not in kwargs: + kwargs["scopes"] = resolved.scopes return cls(**kwargs) diff --git a/tests/test_cli.py b/tests/test_cli.py index 214225b..b274948 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -226,6 +226,65 @@ def test_main_with_context_server_override(self, mock_load_config, mock_make_pro scopes=None, ) + @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_env_scopes(self, mock_load_config, mock_make_provider): + from kognic.auth.env_config import KognicEnvConfig + + mock_load_config.return_value = KognicEnvConfig( + environments={ + "demo": Environment( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", + scopes=["api:read", "api:write"], + ), + }, + ) + mock_make_provider.return_value = self._make_provider("scoped-token") + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--env", "demo", "--token-cache", "none"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("scoped-token") + mock_make_provider.assert_called_once_with( + auth="/path/to/demo-creds.json", + auth_host="https://auth.demo.kognic.com", + token_cache=None, + scopes=["api:read", "api:write"], + ) + + @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_cli_scopes_override_env_scopes(self, mock_load_config, mock_make_provider): + from kognic.auth.env_config import KognicEnvConfig + + mock_load_config.return_value = KognicEnvConfig( + environments={ + "demo": Environment( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", + scopes=["api:read", "api:write"], + ), + }, + ) + mock_make_provider.return_value = self._make_provider("scoped-token") + + with mock.patch("builtins.print"): + result = main(["get-access-token", "--env", "demo", "--token-cache", "none", "--scopes", "custom:scope"]) + + self.assertEqual(result, 0) + mock_make_provider.assert_called_once_with( + auth="/path/to/demo-creds.json", + auth_host="https://auth.demo.kognic.com", + token_cache=None, + scopes=["custom:scope"], + ) + def test_main_with_unknown_context(self): with mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") as mock_load_config: from kognic.auth.env_config import KognicEnvConfig diff --git a/tests/test_config.py b/tests/test_config.py index bd5cb8a..aeeaabc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -47,6 +47,30 @@ def test_valid_config(self): demo = config.environments["demo"] self.assertIsNone(demo.credentials) + self.assertEqual(demo.scopes, []) + + def test_scopes_loaded_from_environment(self): + data = { + "environments": { + "scoped": { + "host": "scoped.kognic.com", + "auth_server": "https://auth.scoped.kognic.com", + "scopes": ["api:read", "api:write"], + }, + "unscoped": { + "host": "unscoped.kognic.com", + "auth_server": "https://auth.unscoped.kognic.com", + }, + } + } + 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["scoped"].scopes, ["api:read", "api:write"]) + self.assertEqual(config.environments["unscoped"].scopes, []) def test_invalid_json_raises(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: