From 1b76eec43e503d80c775fb7e4ea0789c76c15ba6 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Thu, 20 Jun 2024 02:27:23 +0300 Subject: [PATCH] Autocreate the auth store when missing In order to ease managing of auth entries and give some examples to end users, instead of asking them to create the auth store themselves, we better create one automatically. --- README.md | 4 ++-- src/httpie_auth_store/_auth.py | 6 +++--- src/httpie_auth_store/_store.py | 34 +++++++++++++++++++++++++-------- tests/test_plugin.py | 23 ++++++++++++++-------- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 53a318b..e8f8fdb 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ configuration directory. On macOS and Linux, it tries the following locations: > [!NOTE] > -> The authentication store is not created automatically; it is the user's -> responsibility to create one. +> The authentication store can be automatically created with few examples +> inside on first plugin activation, e.g. `http -A store https://pie.dev`. The authentication store is a JSON file that contains two sections: `bindings` and `secrets`: diff --git a/src/httpie_auth_store/_auth.py b/src/httpie_auth_store/_auth.py index c1708b8..79f521d 100644 --- a/src/httpie_auth_store/_auth.py +++ b/src/httpie_auth_store/_auth.py @@ -1,4 +1,4 @@ -import os +import pathlib import typing as t import httpie.cli.argtypes @@ -23,8 +23,8 @@ def __init__(self, binding_id: t.Optional[str] = None) -> None: self._binding_id = binding_id def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: - auth_store_dir = httpie.config.DEFAULT_CONFIG_DIR - auth_store = AuthStore.from_filename(os.path.join(auth_store_dir, self.AUTH_STORE_FILENAME)) + auth_store_dir = pathlib.Path(httpie.config.DEFAULT_CONFIG_DIR) + auth_store = AuthStore.from_filename(auth_store_dir / self.AUTH_STORE_FILENAME) # The credentials store plugin provides extended authentication # capabilities, and therefore requires registering extra HTTPie diff --git a/src/httpie_auth_store/_store.py b/src/httpie_auth_store/_store.py index 4f6818c..6159c69 100644 --- a/src/httpie_auth_store/_store.py +++ b/src/httpie_auth_store/_store.py @@ -1,7 +1,6 @@ import collections.abc import dataclasses import json -import os import pathlib import stat import string @@ -101,24 +100,44 @@ def __contains__(self, key: object) -> bool: class AuthStore: """Authentication store.""" + DEFAULT_AUTH_STORE: t.Mapping[str, t.Any] = { + "bindings": [ + { + "auth_type": "basic", + "auth": "$PIE_USERNAME:$PIE_PASSWORD", + "resources": ["https://pie.dev/basic-auth/batman/I@mTheN1ght"], + }, + { + "auth_type": "bearer", + "auth": "$PIE_TOKEN", + "resources": ["https://pie.dev/bearer"], + }, + ], + "secrets": { + "PIE_USERNAME": "batman", + "PIE_PASSWORD": "I@mTheN1ght", + "PIE_TOKEN": "000000000000000000000000deadc0de", + }, + } + def __init__(self, bindings: t.List[Binding], secrets: Secrets): self._bindings = bindings self._secrets = secrets @classmethod - def from_filename(cls, filename: t.Union[str, pathlib.Path]) -> "AuthStore": + def from_filename(cls, filename: pathlib.Path) -> "AuthStore": """Construct an instance from given JSON file.""" - if not os.path.exists(filename): - error_message = f"Authentication store is not found: '{filename}'." - raise FileNotFoundError(error_message) + if not filename.exists(): + filename.write_text(json.dumps(cls.DEFAULT_AUTH_STORE, indent=2)) + filename.chmod(0o600) # Since an authentication store may contain unencrypted secrets, I # decided to follow the same practice SSH does and do not work if the # file can be read by anyone but current user. Windows is ignored # because I haven't figured out yet how to deal with permissions there. if sys.platform != "win32": - mode = stat.S_IMODE(os.stat(filename).st_mode) + mode = stat.S_IMODE(filename.stat().st_mode) if mode & 0o077 > 0o000: error_message = ( @@ -134,8 +153,7 @@ def from_filename(cls, filename: t.Union[str, pathlib.Path]) -> "AuthStore": ) raise PermissionError(error_message) - with open(filename, encoding="UTF-8") as f: - return cls.from_mapping(json.load(f)) + return cls.from_mapping(json.loads(filename.read_text(encoding="UTF-8"))) @classmethod def from_mapping(cls, mapping: t.Mapping[str, t.Any]) -> "AuthStore": diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 0169086..daf420e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -13,6 +13,7 @@ import responses from httpie_auth_store._auth import StoreAuth +from httpie_auth_store._store import AuthStore _is_windows = sys.platform == "win32" @@ -1217,16 +1218,22 @@ def test_store_permissions_not_enough( @responses.activate -def test_store_auth_no_database( +def test_store_autocreation_when_missing( httpie_run: HttpieRunT, auth_store_path: pathlib.Path, - httpie_stderr: io.StringIO, ) -> None: - """The plugin raises error if auth store does not exist.""" + """The auth store is created when missing with some examples.""" - httpie_run(["-A", "store", "https://yoda.ua"]) + httpie_run(["-A", "store", "https://pie.dev/basic-auth/batman/I@mTheN1ght"]) + httpie_run(["-A", "store", "https://pie.dev/bearer"]) - assert len(responses.calls) == 0 - assert httpie_stderr.getvalue().strip() == ( - f"http: error: FileNotFoundError: Authentication store is not found: '{auth_store_path}'." - ) + assert auth_store_path.exists() + assert json.loads(auth_store_path.read_text()) == AuthStore.DEFAULT_AUTH_STORE + + request = responses.calls[0].request + assert request.url == "https://pie.dev/basic-auth/batman/I@mTheN1ght" + assert request.headers["Authorization"] == b"Basic YmF0bWFuOklAbVRoZU4xZ2h0" + + request = responses.calls[1].request + assert request.url == "https://pie.dev/bearer" + assert request.headers["Authorization"] == "Bearer 000000000000000000000000deadc0de"