diff --git a/README.md b/README.md index cf502a9..4fa61f5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,26 @@ 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)) +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 +{ + "clientId": "...", + "clientSecret": "...", + "email": "...", + "userId": 123, + "issuer": "...", + "scopes": ["api:read", "api:write"] +} +``` + 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 @@ -55,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"] } } } @@ -64,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) @@ -76,7 +98,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 +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 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. @@ -100,13 +123,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 +297,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 +316,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..0bfc678 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 @@ -51,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) @@ -63,13 +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=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/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/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..88d85c6 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, ) @@ -166,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/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..8b4dbae 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,18 +167,23 @@ 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 """ 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, ) @@ -187,10 +196,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,18 +208,22 @@ 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: + 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 return provider @@ -248,6 +262,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 +278,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 +288,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 +305,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, @@ -324,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_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..b274948 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,66 @@ 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, + ) + + @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): @@ -265,6 +329,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 +352,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") 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: