├── src └── httpie_auth_store │ ├── __init__.py │ ├── _secret.py │ ├── _auth.py │ └── _store.py ├── tox.ini ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── pyproject.toml ├── tests ├── test_secret.py └── test_plugin.py └── README.md /src/httpie_auth_store/__init__.py: -------------------------------------------------------------------------------- 1 | from ._auth import StoreAuthPlugin 2 | 3 | 4 | __all__ = ["StoreAuthPlugin"] 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint, test 3 | 4 | [testenv] 5 | allowlist_externals = poetry 6 | deps = poetry 7 | skip_install = true 8 | 9 | [testenv:lint] 10 | commands_pre = poetry install --only lint 11 | commands = 12 | ruff check {posargs:.} 13 | ruff format --check --diff {posargs:.} 14 | passenv = RUFF_OUTPUT_FORMAT 15 | 16 | [testenv:test] 17 | commands_pre = poetry install --with test 18 | commands = poetry run pytest -vv {posargs:.} 19 | passenv = GITHUB_ACTIONS 20 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up sources 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | 20 | - name: Publish to PyPI 21 | env: 22 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 23 | run: | 24 | pip install poetry 25 | poetry build 26 | poetry publish 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Backup files 2 | *.~ 3 | 4 | # Byte-compiles / optimizied 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | bin/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | pip-wheel-metadata/ 28 | poetry.lock 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | .tox/ 36 | .coverage 37 | .coverage.* 38 | .cache 39 | .pytest_cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | 46 | # Sphinx documentation 47 | docs/_build/ 48 | 49 | # Ignore sublime's project (for fans) 50 | .ropeproject/ 51 | *.sublime-project 52 | *.sublime-workspace 53 | 54 | # Ignore virtualenvs (who places it near) 55 | .venv/ 56 | 57 | # Various shit from the OS itself 58 | .DS_Store 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "httpie-auth-store" 7 | version = "4.0.0" 8 | description = "HTTPie: one auth to rule them all!" 9 | authors = ["Ihor Kalnytskyi "] 10 | license = "MIT" 11 | readme = "README.md" 12 | homepage = "https://github.com/ikalnytskyi/httpie-auth-store" 13 | repository = "https://github.com/ikalnytskyi/httpie-auth-store" 14 | keywords = ["httpie", "auth", "store", "keychain", "plugin", "credential"] 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.8" 18 | httpie = "^3.1" 19 | keyring = ">= 23.5" 20 | 21 | [tool.poetry.group.lint] 22 | optional = true 23 | 24 | [tool.poetry.group.lint.dependencies] 25 | ruff = "^0.4.2" 26 | 27 | [tool.poetry.group.test] 28 | optional = true 29 | 30 | [tool.poetry.group.test.dependencies] 31 | pytest = "^7.1" 32 | responses = "^0.20" 33 | pytest-github-actions-annotate-failures = "*" 34 | httpie-hmac = "*" 35 | 36 | [tool.poetry.plugins."httpie.plugins.auth.v1"] 37 | store = "httpie_auth_store:StoreAuthPlugin" 38 | 39 | [tool.ruff] 40 | line-length = 100 41 | target-version = "py38" 42 | 43 | [tool.ruff.lint] 44 | select = ["ALL"] 45 | ignore = ["D", "PTH", "PLR", "PT005", "ISC001", "INP001", "S101", "S603", "S607", "COM812", "FA100", "ANN101"] 46 | 47 | [tool.ruff.lint.per-file-ignores] 48 | "src/*" = ["ANN"] 49 | "src/httpie_auth_store/_secret.py" = ["S602"] 50 | "tests/*" = ["S101", "S106", "INP001"] 51 | 52 | [tool.ruff.lint.isort] 53 | known-first-party = ["httpie_auth_store"] 54 | lines-after-imports = 2 55 | lines-between-types = 1 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up sources 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.12" 20 | 21 | - name: Run ruff 22 | run: | 23 | python3 -m pip install tox 24 | python3 -m tox -e lint 25 | env: 26 | RUFF_OUTPUT_FORMAT: github 27 | 28 | test: 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest] 32 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 33 | include: 34 | - os: macos-latest 35 | python-version: "3.12" 36 | - os: windows-latest 37 | python-version: "3.12" 38 | 39 | runs-on: ${{ matrix.os }} 40 | steps: 41 | - name: Set up sources 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | 49 | - name: Install Ubuntu dependencies 50 | run: sudo apt install pass gnupg 51 | if: matrix.os == 'ubuntu-latest' 52 | 53 | - name: Install macOS dependencies 54 | run: brew install pass gnupg 55 | if: matrix.os == 'macos-latest' 56 | 57 | - name: Run pytest 58 | run: | 59 | python3 -m pip install tox 60 | python3 -m tox -e test 61 | -------------------------------------------------------------------------------- /src/httpie_auth_store/_secret.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import subprocess 3 | import typing as t 4 | 5 | import keyring 6 | 7 | 8 | __all__ = [ 9 | "Secret", 10 | "IdentitySecret", 11 | "ShSecret", 12 | "PasswordStoreSecret", 13 | "SystemSecret", 14 | "create_secret", 15 | ] 16 | 17 | 18 | class Secret(metaclass=abc.ABCMeta): 19 | """The secret interface""" 20 | 21 | @abc.abstractmethod 22 | def get(self) -> str: 23 | """Retrieve secret from the provider.""" 24 | 25 | 26 | class IdentitySecret(Secret): 27 | """Secret from in-memory value.""" 28 | 29 | def __init__(self, secret: str) -> None: 30 | self._secret = secret 31 | 32 | def get(self) -> str: 33 | return self._secret 34 | 35 | 36 | class ShSecret(Secret): 37 | """Secret from shell script output.""" 38 | 39 | def __init__(self, script: str) -> None: 40 | self._script = script 41 | 42 | def get(self) -> str: 43 | try: 44 | return subprocess.check_output(self._script, shell=True, text=True) 45 | except subprocess.CalledProcessError as exc: 46 | error_message = "sh: no secret found" 47 | raise LookupError(error_message) from exc 48 | 49 | 50 | class PasswordStoreSecret(Secret): 51 | """Secret from password store.""" 52 | 53 | def __init__(self, pass_name: str) -> None: 54 | self._pass_name = pass_name 55 | 56 | def get(self) -> str: 57 | try: 58 | # password-store may store securely extra information along with a 59 | # password. Nevertheless, a password is always at first line. 60 | text = subprocess.check_output(["pass", self._pass_name], text=True) 61 | return text.splitlines()[0] 62 | except subprocess.CalledProcessError as exc: 63 | error_message = f"password-store: no secret found: {self._pass_name!r}" 64 | raise LookupError(error_message) from exc 65 | 66 | 67 | class SystemSecret(Secret): 68 | """Secret from operatin system's keychain.""" 69 | 70 | def __init__(self, service: str, username: str) -> None: 71 | self._service = service 72 | self._username = username 73 | self._keyring = keyring.get_keyring() 74 | 75 | def get(self) -> str: 76 | secret = self._keyring.get_password(self._service, self._username) 77 | if not secret: 78 | error_message = f"system: no secret found: {self._service!r}/{self._username!r}" 79 | raise LookupError(error_message) 80 | return secret 81 | 82 | 83 | _SECRET_CLASS_MAPPING = { 84 | "sh": ShSecret, 85 | "password-store": PasswordStoreSecret, 86 | "system": SystemSecret, 87 | } 88 | 89 | 90 | def create_secret(entry: t.Union[str, t.Mapping[str, str]]) -> Secret: 91 | if isinstance(entry, str): 92 | return IdentitySecret(entry) 93 | 94 | entry = dict(entry) 95 | secret_provider = entry.pop("provider", None) 96 | 97 | if secret_provider not in _SECRET_CLASS_MAPPING: 98 | error_message = "unsupported provider" 99 | raise KeyError(error_message) 100 | 101 | return _SECRET_CLASS_MAPPING[secret_provider](**entry) 102 | -------------------------------------------------------------------------------- /src/httpie_auth_store/_auth.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import typing as t 3 | 4 | import httpie.cli.argtypes 5 | import httpie.config 6 | import httpie.plugins 7 | import httpie.plugins.registry 8 | import requests 9 | import requests.auth 10 | 11 | from ._store import AuthEntry, AuthStore 12 | 13 | 14 | __all__ = ["StoreAuthPlugin"] 15 | 16 | 17 | class StoreAuth(requests.auth.AuthBase): 18 | """Authenticate the given request using authentication store.""" 19 | 20 | AUTH_STORE_FILENAME = "auth_store.json" 21 | 22 | def __init__(self, binding_id: t.Optional[str] = None) -> None: 23 | self._binding_id = binding_id 24 | 25 | def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: 26 | auth_store_dir = pathlib.Path(httpie.config.DEFAULT_CONFIG_DIR) 27 | auth_store = AuthStore.from_filename(auth_store_dir / self.AUTH_STORE_FILENAME) 28 | 29 | # The credentials store plugin provides extended authentication 30 | # capabilities, and therefore requires registering extra HTTPie 31 | # authentication plugins for the given request only. 32 | httpie.plugins.registry.plugin_manager.register(HeaderAuthPlugin) 33 | httpie.plugins.registry.plugin_manager.register(CompositeAuthPlugin) 34 | 35 | try: 36 | auth_entry = auth_store.get_entry_for(request, self._binding_id) 37 | request_auth = get_request_auth(auth_entry) 38 | request = request_auth(request) 39 | finally: 40 | httpie.plugins.registry.plugin_manager.unregister(CompositeAuthPlugin) 41 | httpie.plugins.registry.plugin_manager.unregister(HeaderAuthPlugin) 42 | return request 43 | 44 | 45 | class HeaderAuth(requests.auth.AuthBase): 46 | """Authenticate the given request using a free-form HTTP header.""" 47 | 48 | def __init__(self, name: str, value: t.Optional[str]) -> None: 49 | self._name = name 50 | self._value = value or "" 51 | 52 | def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: 53 | request.headers[self._name] = self._value 54 | return request 55 | 56 | 57 | class CompositeAuth(requests.auth.AuthBase): 58 | """Authenticate the given request using several authentication types at once.""" 59 | 60 | def __init__(self, instances: t.List[requests.auth.AuthBase]) -> None: 61 | self._instances = instances 62 | 63 | def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: 64 | for auth in self._instances: 65 | request = auth(request) 66 | return request 67 | 68 | 69 | class StoreAuthPlugin(httpie.plugins.AuthPlugin): 70 | """Authenticate using credential store.""" 71 | 72 | name = "Credential Store HTTP Auth" 73 | description = __doc__ 74 | 75 | auth_type = "store" 76 | auth_require = False 77 | auth_parse = False 78 | 79 | def get_auth( 80 | self, 81 | username: t.Optional[str] = None, 82 | password: t.Optional[str] = None, 83 | ) -> requests.auth.AuthBase: 84 | _ = username 85 | _ = password 86 | return StoreAuth(self.raw_auth) 87 | 88 | 89 | class HeaderAuthPlugin(httpie.plugins.AuthPlugin): 90 | """Authenticate using a free-form HTTP header.""" 91 | 92 | name = "Custom Header HTTP Auth" 93 | description = __doc__ 94 | 95 | auth_type = "header" 96 | auth_require = True 97 | auth_parse = False 98 | raw_auth: str 99 | 100 | def get_auth( 101 | self, 102 | username: t.Optional[str] = None, 103 | password: t.Optional[str] = None, 104 | ) -> requests.auth.AuthBase: 105 | _ = username 106 | _ = password 107 | parsed = httpie.cli.argtypes.parse_auth(self.raw_auth) 108 | return HeaderAuth(parsed.key, parsed.value) 109 | 110 | 111 | class CompositeAuthPlugin(httpie.plugins.AuthPlugin): 112 | """Authenticate using several authentication mechanisms simultaneously.""" 113 | 114 | name = "Composite HTTP Auth" 115 | description = __doc__ 116 | 117 | auth_type = "composite" 118 | auth_require = True 119 | auth_parse = False 120 | raw_auth: t.List[AuthEntry] 121 | 122 | def get_auth( 123 | self, 124 | username: t.Optional[str] = None, 125 | password: t.Optional[str] = None, 126 | ) -> requests.auth.AuthBase: 127 | _ = username 128 | _ = password 129 | assert self.raw_auth is not None, "raw_auth must be provided" 130 | return CompositeAuth([get_request_auth(auth_entry) for auth_entry in self.raw_auth]) 131 | 132 | 133 | def get_request_auth(auth_entry: AuthEntry) -> requests.auth.AuthBase: 134 | """Construct and return an appropriate authenticator instance.""" 135 | 136 | plugin = httpie.plugins.registry.plugin_manager.get_auth_plugin(auth_entry.auth_type)() 137 | plugin.raw_auth = auth_entry.auth 138 | 139 | if plugin.auth_require and plugin.raw_auth is None: 140 | error_message = f"Broken '{auth_entry.auth_type}' authentication entry: missing 'auth'." 141 | raise ValueError(error_message) 142 | 143 | kwargs = {} 144 | if plugin.auth_parse and plugin.raw_auth is not None: 145 | parsed = httpie.cli.argtypes.parse_auth(plugin.raw_auth) 146 | 147 | # Both basic and digest authentication plugins don't expect password 148 | # to be None, and thus cast the input value to string, meaning that 149 | # the effective password becomes "None" string. This behaviour is weird 150 | # and unexpected. We better pass an empty string in this case. 151 | if plugin.auth_type in {"basic", "digest"} and parsed.value is None: 152 | parsed.value = "" 153 | kwargs = {"username": parsed.key, "password": parsed.value} 154 | 155 | return plugin.get_auth(**kwargs) 156 | -------------------------------------------------------------------------------- /src/httpie_auth_store/_store.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import dataclasses 3 | import json 4 | import pathlib 5 | import stat 6 | import string 7 | import sys 8 | import typing as t 9 | import urllib.parse 10 | 11 | import requests 12 | 13 | from ._secret import Secret, create_secret 14 | 15 | 16 | __all__ = ["AuthEntry", "Binding", "AuthStore"] 17 | 18 | 19 | @dataclasses.dataclass(frozen=True) 20 | class AuthEntry: 21 | """An entity that defines an authentication type and payload.""" 22 | 23 | auth_type: str 24 | auth: t.Optional[t.Union[str, t.List["AuthEntry"]]] = None 25 | 26 | @classmethod 27 | def from_mapping(cls, mapping: t.Mapping[str, t.Any]) -> "AuthEntry": 28 | """Construct an instance from mapping.""" 29 | 30 | mapping = dict(mapping) 31 | if isinstance(mapping.get("auth"), list): 32 | mapping["auth"] = [cls.from_mapping(entry) for entry in mapping["auth"]] 33 | return cls(**mapping) 34 | 35 | 36 | @dataclasses.dataclass(frozen=True) 37 | class Binding(AuthEntry): 38 | """An entity that binds an authentication entry to HTTP resources.""" 39 | 40 | id: t.Optional[str] = None 41 | resources: t.List[str] = dataclasses.field(default_factory=list) 42 | 43 | def __post_init__(self): 44 | for resource in self.resources: 45 | parts = urllib.parse.urlparse(resource) 46 | 47 | if not parts.scheme: 48 | error_message = f"Broken binding: missing schema in '{resource}'." 49 | raise ValueError(error_message) 50 | 51 | if not parts.hostname: 52 | error_message = f"Broken binding: missing hostname in '{resource}'." 53 | raise ValueError(error_message) 54 | 55 | @classmethod 56 | def from_mapping(cls, mapping: t.Mapping[str, t.Any]) -> "Binding": 57 | """Construct an instance from mapping.""" 58 | 59 | mapping = dict(mapping) 60 | if isinstance(mapping.get("auth"), list): 61 | mapping["auth"] = [AuthEntry.from_mapping(entry) for entry in mapping["auth"]] 62 | return cls(**mapping) 63 | 64 | def is_request_matched(self, request: requests.PreparedRequest) -> bool: 65 | """Return 'True' if this binding should be used for the given request.""" 66 | 67 | request_parsed = urllib.parse.urlparse(request.url, allow_fragments=False) 68 | 69 | for resource in self.resources: 70 | resource_parsed = urllib.parse.urlparse(resource, allow_fragments=False) 71 | 72 | if ( 73 | request_parsed.scheme.lower() == resource_parsed.scheme.lower() 74 | and request_parsed.netloc.lower() == resource_parsed.netloc.lower() 75 | and request_parsed.path.startswith(resource_parsed.path) 76 | ): 77 | return True 78 | return False 79 | 80 | 81 | class Secrets(collections.abc.Mapping): 82 | """The secrets container that retrieves them from providers on-demand.""" 83 | 84 | def __init__(self, secrets: t.Dict[str, Secret]) -> None: 85 | self._secrets = secrets 86 | 87 | def __getitem__(self, key: str): 88 | return self._secrets[key].get() 89 | 90 | def __len__(self) -> int: 91 | return len(self._secrets) 92 | 93 | def __iter__(self) -> t.Iterator[str]: 94 | return self._secrets.__iter__() 95 | 96 | def __contains__(self, key: object) -> bool: 97 | return self._secrets.__contains__(key) 98 | 99 | 100 | class AuthStore: 101 | """Authentication store.""" 102 | 103 | DEFAULT_AUTH_STORE: t.Mapping[str, t.Any] = { 104 | "bindings": [ 105 | { 106 | "auth_type": "basic", 107 | "auth": "$PIE_USERNAME:$PIE_PASSWORD", 108 | "resources": ["https://pie.dev/basic-auth/batman/I@mTheN1ght"], 109 | }, 110 | { 111 | "auth_type": "bearer", 112 | "auth": "$PIE_TOKEN", 113 | "resources": ["https://pie.dev/bearer"], 114 | }, 115 | ], 116 | "secrets": { 117 | "PIE_USERNAME": "batman", 118 | "PIE_PASSWORD": "I@mTheN1ght", 119 | "PIE_TOKEN": "000000000000000000000000deadc0de", 120 | }, 121 | } 122 | 123 | def __init__(self, bindings: t.List[Binding], secrets: Secrets): 124 | self._bindings = bindings 125 | self._secrets = secrets 126 | 127 | @classmethod 128 | def from_filename(cls, filename: pathlib.Path) -> "AuthStore": 129 | """Construct an instance from given JSON file.""" 130 | 131 | if not filename.exists(): 132 | filename.write_text(json.dumps(cls.DEFAULT_AUTH_STORE, indent=2)) 133 | filename.chmod(0o600) 134 | 135 | # Since an authentication store may contain unencrypted secrets, I 136 | # decided to follow the same practice SSH does and do not work if the 137 | # file can be read by anyone but current user. Windows is ignored 138 | # because I haven't figured out yet how to deal with permissions there. 139 | if sys.platform != "win32": 140 | mode = stat.S_IMODE(filename.stat().st_mode) 141 | 142 | if mode & 0o077 > 0o000: 143 | error_message = ( 144 | f"Permissions {mode:04o} for '{filename}' are too open. " 145 | f"Authentication store MUST NOT be accessible by others." 146 | ) 147 | raise PermissionError(error_message) 148 | 149 | if mode & 0o400 != 0o400: 150 | error_message = ( 151 | f"Permissions {mode:04o} for '{filename}' are too close. " 152 | f"Authentication store MUST be readabe by you." 153 | ) 154 | raise PermissionError(error_message) 155 | 156 | return cls.from_mapping(json.loads(filename.read_text(encoding="UTF-8"))) 157 | 158 | @classmethod 159 | def from_mapping(cls, mapping: t.Mapping[str, t.Any]) -> "AuthStore": 160 | """Construct an instance from given mapping.""" 161 | 162 | bindings = [Binding.from_mapping(binding) for binding in mapping["bindings"]] 163 | secrets = Secrets({k: create_secret(v) for k, v in mapping.get("secrets", {}).items()}) 164 | return cls(bindings, secrets) 165 | 166 | def get_entry_for( 167 | self, 168 | request: requests.PreparedRequest, 169 | binding_id: t.Optional[str] = None, 170 | ) -> AuthEntry: 171 | """Find and return an authentication entry for the given request.""" 172 | 173 | for binding in self._bindings: 174 | if not binding.is_request_matched(request): 175 | continue 176 | 177 | if binding_id and binding.id != binding_id: 178 | continue 179 | 180 | return inject_secrets_if_any(binding, self._secrets) 181 | 182 | error_message = f"No binding found for '{request.url}'." 183 | raise LookupError(error_message) 184 | 185 | 186 | def inject_secrets_if_any(auth_entry: AuthEntry, secrets: Secrets) -> AuthEntry: 187 | """Return a new instance of AuthEntry with secrets injected.""" 188 | 189 | if isinstance(auth_entry.auth, list): 190 | return AuthEntry( 191 | auth_type=auth_entry.auth_type, 192 | auth=[inject_secrets_if_any(entry, secrets) for entry in auth_entry.auth], 193 | ) 194 | 195 | try: 196 | auth = auth_entry.auth and string.Template(auth_entry.auth).substitute(secrets) 197 | return AuthEntry(auth_type=auth_entry.auth_type, auth=auth) 198 | except KeyError as exc: 199 | secret_id = str(exc).strip("'") 200 | error_message = f"Broken authentication entry: missing secret: {secret_id}." 201 | raise ValueError(error_message) from exc 202 | -------------------------------------------------------------------------------- /tests/test_secret.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import re 4 | import shutil 5 | import subprocess 6 | import sys 7 | import tempfile 8 | import textwrap 9 | import typing as t 10 | 11 | import keyring 12 | import keyring.backend 13 | import keyring.compat 14 | import pytest 15 | 16 | from httpie_auth_store._secret import IdentitySecret, PasswordStoreSecret, ShSecret, SystemSecret 17 | 18 | 19 | _is_macos = sys.platform == "darwin" 20 | 21 | 22 | class TestIdentitySecret: 23 | """Test IdentitySecret.""" 24 | 25 | def test_secret_retrieved(self) -> None: 26 | """The stored secret is retrieved and returned.""" 27 | 28 | assert IdentitySecret(secret="p@ss").get() == "p@ss" 29 | 30 | 31 | class TestShSecret: 32 | """Test ShSecret.""" 33 | 34 | def test_secret_retrieved(self, tmp_path: pathlib.Path) -> None: 35 | """The stored secret is retrieved and returned.""" 36 | 37 | secrettxt = tmp_path.joinpath("secret.txt") 38 | secrettxt.write_text("p@ss", encoding="UTF-8") 39 | script = f"cat {secrettxt}" 40 | assert ShSecret(script=script).get() == "p@ss" 41 | 42 | def test_secret_retrieved_pipe(self, tmp_path: pathlib.Path) -> None: 43 | """The stored secret is retrieved and returned, even when pipes are used.""" 44 | 45 | secrettxt = tmp_path.joinpath("secret.txt") 46 | secrettxt.write_text("p@ss\nextra", encoding="UTF-8") 47 | script = rf"cat {secrettxt} | head -n 1 | tr -d {os.linesep!r}" 48 | assert ShSecret(script=script).get() == "p@ss" 49 | 50 | def test_secret_retrieved_lazy(self, tmp_path: pathlib.Path) -> None: 51 | """The stored secret is not retrieved until .get() is called.""" 52 | 53 | secrettxt = tmp_path.joinpath("secret.txt") 54 | secret = ShSecret(script=f"cat {secrettxt}") 55 | secrettxt.write_text("p@ss", encoding="UTF-8") 56 | assert secret.get() == "p@ss" 57 | 58 | def test_secret_not_found(self, tmp_path: pathlib.Path) -> None: 59 | """LookupError is raised when no secrets are found.""" 60 | 61 | secrettxt = tmp_path.joinpath("secret.txt") 62 | secret = ShSecret(script=f"cat {secrettxt}") 63 | 64 | with pytest.raises(LookupError) as excinfo: 65 | secret.get() 66 | assert str(excinfo.value) == "sh: no secret found" 67 | 68 | 69 | @pytest.mark.skipif(not shutil.which("pass"), reason="password-store is not found") 70 | class TestPasswordStoreSecret: 71 | """Test PasswordStoreSecret.""" 72 | 73 | if _is_macos: 74 | # Unfortunately, when 'gpg' is ran on macOS with GNUPGHOME set to a 75 | # temporary directory and generate-key template pointed to a file in 76 | # temporary directory too, it complains about using too long names. It's 77 | # not clear why 'gpg' complains about too long names, but it's clear that 78 | # built-in 'tmp_path' fixture produces too long names. That's why on macOS we 79 | # override 'tmp_path' fixture to return much shorter path to a temporary 80 | # directory. 81 | @pytest.fixture() 82 | def tmp_path(self) -> t.Generator[pathlib.Path, None, None]: 83 | with tempfile.TemporaryDirectory() as path: 84 | yield pathlib.Path(path) 85 | 86 | @pytest.fixture(autouse=True) 87 | def password_store_dir( 88 | self, 89 | monkeypatch: pytest.MonkeyPatch, 90 | tmp_path: pathlib.Path, 91 | ) -> pathlib.Path: 92 | """Set password-store home directory to a temporary one.""" 93 | 94 | password_store_dir = tmp_path.joinpath(".password-store") 95 | monkeypatch.setenv("PASSWORD_STORE_DIR", str(password_store_dir)) 96 | return password_store_dir 97 | 98 | @pytest.fixture() 99 | def gpg_key_id(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> str: 100 | """Return a Key ID of just generated GPG key.""" 101 | 102 | gpghome = tmp_path.joinpath(".gnupg") 103 | gpgtemplate = tmp_path.joinpath("gpg-template") 104 | 105 | monkeypatch.setenv("GNUPGHOME", str(gpghome)) 106 | gpgtemplate.write_text( 107 | textwrap.dedent( 108 | """ 109 | %no-protection 110 | Key-Type: RSA 111 | Subkey-Type: RSA 112 | Name-Real: Test 113 | Name-Email: test@test 114 | Expire-Date: 0 115 | %commit 116 | """ 117 | ), 118 | encoding="UTF-8", 119 | ) 120 | 121 | subprocess.check_call(["gpg", "--batch", "--generate-key", gpgtemplate]) 122 | keys = subprocess.check_output(["gpg", "--list-secret-keys"], text=True) 123 | 124 | key = re.search(r"\s+([0-9A-F]{40})\s+", keys) 125 | if not key: 126 | error_message = "cannot generate a GPG key" 127 | raise RuntimeError(error_message) 128 | return key.group(1) 129 | 130 | def test_secret_retrieved(self, gpg_key_id: str) -> None: 131 | """The stored secret is retrieved and returned.""" 132 | 133 | subprocess.check_call(["pass", "init", gpg_key_id]) 134 | subprocess.run(["pass", "insert", "--echo", "service/user"], input=b"p@ss", check=True) 135 | assert PasswordStoreSecret(pass_name="service/user").get() == "p@ss" 136 | 137 | def test_secret_retrieved_lazy(self, gpg_key_id: str) -> None: 138 | """The stored secret is not retrieved until .get() is called.""" 139 | 140 | secret = PasswordStoreSecret(pass_name="service/user") 141 | subprocess.check_call(["pass", "init", gpg_key_id]) 142 | subprocess.run(["pass", "insert", "--echo", "service/user"], input=b"p@ss", check=True) 143 | assert secret.get() == "p@ss" 144 | 145 | def test_secret_not_found(self) -> None: 146 | """LookupError is raised when no secrets are found.""" 147 | 148 | secret = PasswordStoreSecret(pass_name="service/user") 149 | with pytest.raises(LookupError) as excinfo: 150 | secret.get() 151 | assert str(excinfo.value) == "password-store: no secret found: 'service/user'" 152 | 153 | 154 | class TestSystemSecret: 155 | """Test SystemSecret.""" 156 | 157 | class _InmemoryKeyring(keyring.backend.KeyringBackend): 158 | """Keyring backend that stores secrets in-memory.""" 159 | 160 | @keyring.compat.properties.classproperty 161 | def priority(self) -> float: 162 | return 1.0 163 | 164 | def __init__(self) -> None: 165 | self._keyring = {} 166 | 167 | def get_password(self, service: str, username: str) -> t.Optional[str]: 168 | return self._keyring.get((service, username)) 169 | 170 | def set_password(self, service: str, username: str, password: str) -> None: 171 | self._keyring[(service, username)] = password 172 | 173 | @pytest.fixture(autouse=True) 174 | def keyring_backend(self) -> t.Generator[keyring.backend.KeyringBackend, None, None]: 175 | """Temporary set in-memory keyring as current backend.""" 176 | 177 | prev_backend = keyring.get_keyring() 178 | keyring.set_keyring(self._InmemoryKeyring()) 179 | yield keyring.get_keyring() 180 | keyring.set_keyring(prev_backend) 181 | 182 | def test_secret_retrieved(self, keyring_backend: keyring.backend.KeyringBackend) -> None: 183 | """The stored secret is retrieved and returned.""" 184 | 185 | keyring_backend.set_password("testsvc", "testuser", "p@ss") 186 | assert SystemSecret(service="testsvc", username="testuser").get() == "p@ss" 187 | 188 | def test_secret_retrieved_lazy(self, keyring_backend: keyring.backend.KeyringBackend) -> None: 189 | """The stored secret is not retrieved until .get() is called.""" 190 | 191 | secret = SystemSecret(service="testsvc", username="testuser") 192 | keyring_backend.set_password("testsvc", "testuser", "p@ss") 193 | assert secret.get() == "p@ss" 194 | 195 | def test_secret_not_found(self) -> None: 196 | """LookupError is raised when no secrets are found.""" 197 | 198 | secret = SystemSecret(service="testsvc", username="testuser") 199 | with pytest.raises(LookupError) as excinfo: 200 | secret.get() 201 | assert str(excinfo.value) == "system: no secret found: 'testsvc'/'testuser'" 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTPie Auth Store 2 | 3 | > [!NOTE] 4 | > 5 | > The plugin was renamed from `httpie-credential-store` to `httpie-auth-store` 6 | > primarily due to backward incompatible changes and because the new name 7 | > better reflects its nature. 8 | 9 | HTTPie Auth Store is an [HTTPie] authentication plugin that automatically 10 | selects the appropriate authentication type and payload from a file containing 11 | authentication bindings, then uses them for the current request. No more 12 | memorizing or searching for tokens, usernames, or passwords — simply add them 13 | to the authentication store, and the plugin handles the rest. This plugin also 14 | supports various secret storage options, such as system keychains and password 15 | managers (see supported [Secret providers]). 16 | 17 | Eager to get started? Just start with installing! 18 | 19 | ```sh 20 | httpie cli plugins install httpie-auth-store 21 | ``` 22 | 23 | or via `pip` in the same python environment where HTTPie is installed: 24 | 25 | ```sh 26 | python3 -m pip install httpie-auth-store 27 | ``` 28 | 29 | 30 | ## Table of Content 31 | 32 | * [Usage](#usage) 33 | * [Authentication types](#authentication-types) 34 | * [basic](#basic) 35 | * [digest](#digest) 36 | * [bearer](#bearer) 37 | * [header](#header) 38 | * [composite](#composite) 39 | * [hmac](#hmac) 40 | * [Secret providers](#secret-providers) 41 | * [sh](#sh) 42 | * [system](#system) 43 | * [password-store](#password-store) 44 | * [FAQ](#faq) 45 | 46 | 47 | ## Usage 48 | 49 | > [!IMPORTANT] 50 | > 51 | > Do not forget to pass `-A store` or `--auth-type store` to HTTPie in order to 52 | > activate the plugin. 53 | 54 | Once installed, the plugin looks for `auth_store.json` located in the HTTPie 55 | configuration directory. On macOS and Linux, it tries the following locations: 56 | `$HTTPIE_CONFIG_DIR/auth_store.json`, `$HOME/.httpie/auth_store.json` and 57 | `$XDG_CONFIG_HOME/httpie/auth_store.json`; on Windows — 58 | `%HTTPIE_CONFIG_DIR%\auth_store.json` and `%APPDATA%\httpie\auth_store.json` 59 | 60 | > [!NOTE] 61 | > 62 | > The authentication store can be automatically created with few examples on 63 | > first plugin activation, e.g. `http -A store https://pie.dev/bearer`. 64 | 65 | The authentication store is a JSON file that contains two sections: `bindings` 66 | and `secrets`: 67 | 68 | ```json 69 | { 70 | "bindings": [ 71 | { 72 | "auth_type": "bearer", 73 | "auth": "$GITHUB_TOKEN", 74 | "resources": ["https://api.github.com/"] 75 | }, 76 | { 77 | "id": "bots", 78 | "auth_type": "bearer", 79 | "auth": "ZWFzdGVyIGVnZwo", 80 | "resources": ["https://api.github.com/"] 81 | }, 82 | ], 83 | "secrets": { 84 | "GITHUB_TOKEN": { 85 | "provider": "system", 86 | "service": "github.com", 87 | "username": "ikalnytskyi" 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | Each _binding_ is a JSON object that contains the following keys: 94 | 95 | * `id` (*str*, *optional*) is an authentication binding ID that can be used to 96 | overcome ambiguity when two or more bindings are matched. 97 | 98 | * `auth_type` (*str*, *required*) is an authentication type supported by HTTPie, 99 | either natively or via third-party plugins. See [Authentication types] for 100 | details. 101 | 102 | * `auth` (*str*, *optional*) is an authentication payload for the given 103 | authentication type. Required for certain authentication types, optional for 104 | others. Tokens started with `$` are replaced with secrets referred by those 105 | tokens. The `$` sign must be escaped (`$$`) to remain untouched. 106 | 107 | * `resources` (*List\[str\]*, *required*) is an array of URLs to activate this 108 | binding for. Must contain both scheme and hostname. 109 | 110 | Each _secret_ is a KV-pair, where key is a secret name, and value is either a 111 | secret itself or a JSON object that specified how a secret must be retrieved 112 | from secure storage. The JSON object must contain the `provider` key. Presence 113 | of other keys are provider dependent (see [Secret providers] section below). 114 | 115 | Once the authenticate store is set up, just pass `-A store` to HTTPie to 116 | activate the plugin and perform some magic for you. 117 | 118 | ```sh 119 | http -A store https://api.github.com 120 | ``` 121 | 122 | If there are two or more authentication bingind in the store that match the 123 | same resource, you can select appropriate binding by providing a binding ID by 124 | passing `-a` or `--auth` to HTTPie. This might come handy when you have 125 | multiple accounts on the same web resource. 126 | 127 | ```sh 128 | http -A store -a bots https://api.github.com 129 | ``` 130 | 131 | 132 | ## Authentication types 133 | 134 | HTTPie Auth Store supports both built-in and third-party HTTPie authentication 135 | types as well as provides few authentication types on its own. 136 | 137 | > [!TIP] 138 | > 139 | > It's advised to store your secrets in password managers instead of storing 140 | > them directly in the authentication store file. 141 | 142 | ### basic 143 | 144 | The 'Basic' HTTP authentication type as defined in RFC 7617. Transmits 145 | credentials as username/password pairs, encoded using Base64. 146 | 147 | ```json 148 | { 149 | "auth_type": "basic", 150 | "auth": "ihor:p@ss" 151 | } 152 | ``` 153 | 154 | where 155 | 156 | * `auth` is a `:`-delimited username/password pair 157 | 158 | ### digest 159 | 160 | The 'Digest' HTTP authentication type as defined in RFC 2617. It applies a hash 161 | function to the username and password before sending them over the network. 162 | 163 | 164 | ```json 165 | { 166 | "auth_type": "digest", 167 | "auth": "ihor:p@ss" 168 | } 169 | ``` 170 | 171 | where 172 | 173 | * `auth` is a `:`-delimited username/password pair 174 | 175 | ### bearer 176 | 177 | The 'Bearer' HTTP authentication type transmits token in the `Authorization` 178 | HTTP header. 179 | 180 | ```json 181 | { 182 | "auth_type": "bearer", 183 | "auth": "t0ken" 184 | } 185 | ``` 186 | 187 | where 188 | 189 | * `auth` is a bearer token to authenticate with 190 | 191 | ### header 192 | 193 | The 'Header' authentication type is not exactly an authentication scheme. It's 194 | rather a way to set a free-formed HTTP header that may or may not contain any 195 | secret material. 196 | 197 | ```json 198 | { 199 | "auth_type": "header", 200 | "auth": "X-Secret:s3cret" 201 | } 202 | ``` 203 | 204 | where 205 | 206 | * `auth` is a `:`-delimited HTTP header name/value pair 207 | 208 | The 'Header' authentication type can be used to bypass any kind of limitations 209 | imposed by built-in or third-party authentication types. For instance, you can 210 | pass a bearer token with non-default authentication scheme, say `JWT`, without 211 | breaking a sweat. 212 | 213 | ```json 214 | { 215 | "auth_type": "header", 216 | "auth": "Authorization:JWT t0ken" 217 | } 218 | ``` 219 | 220 | ### composite 221 | 222 | The 'Composite' authentication type is a not an authentication type either. 223 | It's a way to use multiple authentication types simultaneously. It might come 224 | handy when in addition to `basic` or `bearer` authentication, you have to 225 | supply an extra secret via custom HTTP header. 226 | 227 | ```json 228 | { 229 | "auth_type": "composite", 230 | "auth": [ 231 | { 232 | "auth_type": "bearer", 233 | "auth": "t0ken" 234 | }, 235 | { 236 | "auth_type": "header", 237 | "auth": "X-Secret:s3cret" 238 | } 239 | ] 240 | } 241 | ``` 242 | 243 | where 244 | 245 | * `auth` is a list of authentication entries, as supported by HTTPie 246 | 247 | ### hmac 248 | 249 | The 'HMAC' authentication type is not built-in and requires the `httpie-hmac` 250 | plugin to be installed first. Its only purpose here is to serve as an example 251 | of how to use a third-party authentication type in the authentication store. 252 | 253 | ```json 254 | { 255 | "auth_type": "hmac", 256 | "auth": "secret:czNjcjN0Cg==" 257 | } 258 | ``` 259 | 260 | where 261 | 262 | * `auth` is a HMAC specific authentication payload 263 | 264 | 265 | ## Secret providers 266 | 267 | The plugin supports some secret providers that can be used to retrieve tokens, 268 | passwords and other secret materials from various secured storages. 269 | 270 | ### sh 271 | 272 | The 'Sh' secret provider retrieves a secret from the standard output of the 273 | shell script. This is a universal approach that can be used to retrieve secrets 274 | from unsupported password managers using their command line interfaces. 275 | 276 | ```json 277 | { 278 | "provider": "sh", 279 | "script": "cat ~/path/to/secret | tr -d '\n'" 280 | } 281 | ``` 282 | 283 | where 284 | 285 | * `script` is a shell script to execute 286 | 287 | ### system 288 | 289 | The 'System' secret provider, as the name suggests, retrieves a secret from 290 | your system keychain. It may be KWallet, GNOME Keyring, macOS Keychain or even 291 | Windows Credential Locker. 292 | 293 | ```json 294 | { 295 | "provider": "system", 296 | "service": "github", 297 | "username": "ikalnytskyi" 298 | } 299 | ``` 300 | 301 | where 302 | 303 | * `service` is a service to retrieve a secret from 304 | * `username` is a username to retrieve a secret from 305 | 306 | ### password-store 307 | 308 | The 'Password Store' secret provider invokes the `pass` executable on your 309 | system, and retrieves the secret from the first line of referred record. 310 | 311 | ```json 312 | { 313 | "provider": "password-store", 314 | "name": "github.com/ikalnytskyi" 315 | } 316 | ``` 317 | 318 | where 319 | 320 | * `name` is a password-store entry name to retrieve a secret from 321 | 322 | ## FAQ 323 | 324 | * **Q**: How to get know what authentication is used for the given request? 325 | 326 | **A**: You can run HTTPie with `--offline` argument to print the request 327 | header along with injected authentication credentials. 328 | 329 | 330 | [HTTPie]: https://httpie.org/ 331 | [Authentication types]: #authentication-types 332 | [Secret providers]: #secret-providers 333 | [password-store]: https://www.passwordstore.org/ 334 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import pathlib 4 | import re 5 | import sys 6 | import typing as t 7 | 8 | from urllib.request import parse_http_list, parse_keqv_list 9 | 10 | import httpie.context 11 | import httpie.core 12 | import pytest 13 | import responses 14 | 15 | from httpie_auth_store._auth import StoreAuth 16 | from httpie_auth_store._store import AuthStore 17 | 18 | 19 | _is_windows = sys.platform == "win32" 20 | 21 | 22 | HttpieRunT = t.Callable[[t.List[t.Union[str, bytes]]], int] 23 | StoreSetT = t.Callable[..., None] 24 | 25 | 26 | class _DigestAuthHeader: 27 | """Assert that a given Authorization header has expected digest parameters.""" 28 | 29 | def __init__(self, parameters: t.Mapping[str, t.Any]) -> None: 30 | self._parameters = parameters 31 | 32 | def __eq__(self, authorization_header_value: object) -> bool: 33 | assert isinstance(authorization_header_value, str) 34 | auth_type, auth_value = authorization_header_value.split(maxsplit=1) 35 | assert auth_type.lower() == "digest" 36 | assert parse_keqv_list(parse_http_list(auth_value)) == self._parameters 37 | return True 38 | 39 | 40 | class _RegExp: 41 | """Assert that a given string meets some expectations.""" 42 | 43 | def __init__(self, pattern: str, flags: int = 0) -> None: 44 | self._regex = re.compile(pattern, flags) 45 | 46 | def __eq__(self, actual: object) -> bool: 47 | assert isinstance(actual, str) 48 | return bool(self._regex.match(actual)) 49 | 50 | def __repr__(self) -> str: 51 | return self._regex.pattern 52 | 53 | 54 | @pytest.fixture(autouse=True) 55 | def httpie_config_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: 56 | """Return a path to HTTPie configuration directory.""" 57 | 58 | config_dir = tmp_path.joinpath(".httpie") 59 | config_dir.mkdir() 60 | monkeypatch.setattr("httpie.config.DEFAULT_CONFIG_DIR", config_dir) 61 | return config_dir 62 | 63 | 64 | @pytest.fixture() 65 | def auth_store_path(httpie_config_dir: pathlib.Path) -> pathlib.Path: 66 | """Return a path to the auth store file.""" 67 | 68 | return httpie_config_dir / StoreAuth.AUTH_STORE_FILENAME 69 | 70 | 71 | @pytest.fixture() 72 | def store_set(auth_store_path: pathlib.Path) -> StoreSetT: 73 | """Render given auth store to auth_store.json.""" 74 | 75 | def render( 76 | *, 77 | bindings: t.List[t.Mapping[str, t.Any]], 78 | secrets: t.Optional[t.Mapping[str, t.Any]] = None, 79 | mode: int = 0o600, 80 | ) -> None: 81 | auth_store_path.write_text( 82 | json.dumps( 83 | { 84 | "bindings": bindings, 85 | "secrets": secrets or {}, 86 | }, 87 | indent=4, 88 | ) 89 | ) 90 | auth_store_path.chmod(mode) 91 | 92 | return render 93 | 94 | 95 | @pytest.fixture() 96 | def httpie_stderr() -> io.StringIO: 97 | """Return captured standard error stream of HTTPie.""" 98 | 99 | return io.StringIO() 100 | 101 | 102 | @pytest.fixture() 103 | def httpie_run(httpie_stderr: io.StringIO, httpie_config_dir: pathlib.Path) -> HttpieRunT: 104 | """Run HTTPie from within this process.""" 105 | 106 | def main(args: t.List[t.Union[str, bytes]]) -> int: 107 | args = ["http", "--ignore-stdin", *args] 108 | env = httpie.context.Environment(stderr=httpie_stderr, config_dir=httpie_config_dir) 109 | return httpie.core.main(args, env=env) 110 | 111 | return main 112 | 113 | 114 | @responses.activate 115 | def test_basic_auth_plugin(httpie_run: HttpieRunT) -> None: 116 | """The plugin neither breaks nor overwrites existing auth plugins.""" 117 | 118 | httpie_run(["-A", "basic", "-a", "user:p@ss", "https://yoda.ua"]) 119 | 120 | assert len(responses.calls) == 1 121 | request = responses.calls[0].request 122 | 123 | assert request.url == "https://yoda.ua/" 124 | assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" 125 | 126 | 127 | @responses.activate 128 | def test_store_auth_deactivated_by_default(httpie_run: HttpieRunT) -> None: 129 | """The plugin is deactivated by default.""" 130 | 131 | httpie_run(["https://yoda.ua"]) 132 | 133 | assert len(responses.calls) == 1 134 | request = responses.calls[0].request 135 | 136 | assert request.url == "https://yoda.ua/" 137 | assert "Authorization" not in request.headers 138 | 139 | 140 | @responses.activate 141 | def test_store_auth_basic(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 142 | """The plugin works for HTTP basic auth.""" 143 | 144 | store_set( 145 | bindings=[ 146 | { 147 | "auth_type": "basic", 148 | "auth": "user:p@ss", 149 | "resources": ["https://yoda.ua"], 150 | } 151 | ] 152 | ) 153 | httpie_run(["-A", "store", "https://yoda.ua"]) 154 | 155 | assert len(responses.calls) == 1 156 | request = responses.calls[0].request 157 | 158 | assert request.url == "https://yoda.ua/" 159 | assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" 160 | 161 | 162 | @responses.activate 163 | @pytest.mark.parametrize("auth", ["user", "user:"]) 164 | def test_store_auth_basic_no_password( 165 | httpie_run: HttpieRunT, 166 | store_set: StoreSetT, 167 | auth: str, 168 | ) -> None: 169 | """The plugin works for HTTP basic auth even w/o password.""" 170 | 171 | store_set( 172 | bindings=[ 173 | { 174 | "auth_type": "basic", 175 | "auth": auth, 176 | "resources": ["https://yoda.ua"], 177 | } 178 | ] 179 | ) 180 | httpie_run(["-A", "store", "https://yoda.ua"]) 181 | 182 | assert len(responses.calls) == 1 183 | request = responses.calls[0].request 184 | 185 | assert request.url == "https://yoda.ua/" 186 | assert request.headers["Authorization"] == b"Basic dXNlcjo=" 187 | 188 | 189 | @responses.activate 190 | def test_store_auth_basic_no_username(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 191 | """The plugin works for HTTP basic auth even w/o username.""" 192 | 193 | store_set( 194 | bindings=[ 195 | { 196 | "auth_type": "basic", 197 | "auth": ":p@ss", 198 | "resources": ["https://yoda.ua"], 199 | } 200 | ] 201 | ) 202 | httpie_run(["-A", "store", "https://yoda.ua"]) 203 | 204 | assert len(responses.calls) == 1 205 | request = responses.calls[0].request 206 | 207 | assert request.url == "https://yoda.ua/" 208 | assert request.headers["Authorization"] == b"Basic OnBAc3M=" 209 | 210 | 211 | @responses.activate 212 | def test_store_auth_basic_secret_provider( 213 | httpie_run: HttpieRunT, 214 | store_set: StoreSetT, 215 | tmp_path: pathlib.Path, 216 | ) -> None: 217 | """The plugin retrieves secrets from secret provider for HTTP basic auth.""" 218 | 219 | secrettxt = tmp_path.joinpath("secret.txt") 220 | secrettxt.write_text("p@ss", encoding="UTF-8") 221 | 222 | store_set( 223 | bindings=[ 224 | { 225 | "auth_type": "basic", 226 | "auth": "user:$PASSWORD", 227 | "resources": ["https://yoda.ua"], 228 | } 229 | ], 230 | secrets={ 231 | "PASSWORD": { 232 | "provider": "sh", 233 | "script": f"cat {secrettxt}", 234 | } 235 | }, 236 | ) 237 | httpie_run(["-A", "store", "https://yoda.ua"]) 238 | 239 | assert len(responses.calls) == 1 240 | request = responses.calls[0].request 241 | 242 | assert request.url == "https://yoda.ua/" 243 | assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" 244 | 245 | 246 | @responses.activate 247 | def test_store_auth_digest(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 248 | """The plugin works for HTTP digest auth.""" 249 | 250 | responses.add( 251 | responses.GET, 252 | "https://yoda.ua", 253 | status=401, 254 | headers={ 255 | "WWW-Authenticate": ( 256 | "Digest realm=auth.yoda.ua" 257 | ',qop="auth,auth-int"' 258 | ",nonce=dcd98b7102dd2f0e8b11d0f600bfb0c093" 259 | ",opaque=5ccc069c403ebaf9f0171e9517f40e41" 260 | ) 261 | }, 262 | ) 263 | 264 | store_set( 265 | bindings=[ 266 | { 267 | "auth_type": "digest", 268 | "auth": "user:p@ss", 269 | "resources": ["https://yoda.ua"], 270 | } 271 | ], 272 | ) 273 | httpie_run(["-A", "store", "https://yoda.ua"]) 274 | 275 | assert len(responses.calls) == 2 276 | request = responses.calls[0].request 277 | 278 | assert request.url == "https://yoda.ua/" 279 | assert "Authorization" not in request.headers 280 | 281 | request = responses.calls[1].request 282 | assert request.url == "https://yoda.ua/" 283 | assert request.headers["Authorization"] == _DigestAuthHeader( 284 | { 285 | "username": "user", 286 | "realm": "auth.yoda.ua", 287 | "nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093", 288 | "uri": "/", 289 | "opaque": "5ccc069c403ebaf9f0171e9517f40e41", 290 | "qop": "auth", 291 | "nc": "00000001", 292 | # Both 'response' and 'cnonce' are time-based, thus there's no 293 | # reliable way to check their values without mocking time module. 294 | # Since we do not test here produced "digest", but ensure a proper 295 | # auth method is used, checking these values using regular 296 | # expression should be enough. 297 | "response": _RegExp(r"^[0-9a-fA-F]{32}$"), 298 | "cnonce": _RegExp(r"^[0-9a-fA-F]{16}$"), 299 | } 300 | ) 301 | 302 | 303 | @responses.activate 304 | @pytest.mark.parametrize("auth", ["user", "user:"]) 305 | def test_store_auth_digest_no_password( 306 | httpie_run: HttpieRunT, 307 | store_set: StoreSetT, 308 | auth: str, 309 | ) -> None: 310 | """The plugin works for HTTP digest auth even w/o password.""" 311 | 312 | responses.add( 313 | responses.GET, 314 | "https://yoda.ua", 315 | status=401, 316 | headers={ 317 | "WWW-Authenticate": ( 318 | "Digest realm=auth.yoda.ua" 319 | ',qop="auth,auth-int"' 320 | ",nonce=dcd98b7102dd2f0e8b11d0f600bfb0c093" 321 | ",opaque=5ccc069c403ebaf9f0171e9517f40e41" 322 | ) 323 | }, 324 | ) 325 | 326 | store_set( 327 | bindings=[ 328 | { 329 | "auth_type": "digest", 330 | "auth": auth, 331 | "resources": ["https://yoda.ua"], 332 | } 333 | ], 334 | ) 335 | httpie_run(["-A", "store", "https://yoda.ua"]) 336 | 337 | assert len(responses.calls) == 2 338 | request = responses.calls[0].request 339 | 340 | assert request.url == "https://yoda.ua/" 341 | assert "Authorization" not in request.headers 342 | 343 | request = responses.calls[1].request 344 | assert request.url == "https://yoda.ua/" 345 | assert request.headers["Authorization"] == _DigestAuthHeader( 346 | { 347 | "username": "user", 348 | "realm": "auth.yoda.ua", 349 | "nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093", 350 | "uri": "/", 351 | "opaque": "5ccc069c403ebaf9f0171e9517f40e41", 352 | "qop": "auth", 353 | "nc": "00000001", 354 | # Both 'response' and 'cnonce' are time-based, thus there's no 355 | # reliable way to check their values without mocking time module. 356 | # Since we do not test here produced "digest", but ensure a proper 357 | # auth method is used, checking these values using regular 358 | # expression should be enough. 359 | "response": _RegExp(r"^[0-9a-fA-F]{32}$"), 360 | "cnonce": _RegExp(r"^[0-9a-fA-F]{16}$"), 361 | } 362 | ) 363 | 364 | 365 | @responses.activate 366 | def test_store_auth_digest_secret_provider( 367 | httpie_run: HttpieRunT, 368 | store_set: StoreSetT, 369 | tmp_path: pathlib.Path, 370 | ) -> None: 371 | """The plugin retrieves secrets from secret provider for HTTP digest auth.""" 372 | 373 | secrettxt = tmp_path.joinpath("secret.txt") 374 | secrettxt.write_text("p@ss", encoding="UTF-8") 375 | 376 | responses.add( 377 | responses.GET, 378 | "https://yoda.ua", 379 | status=401, 380 | headers={ 381 | "WWW-Authenticate": ( 382 | "Digest realm=auth.yoda.ua" 383 | ',qop="auth,auth-int"' 384 | ",nonce=dcd98b7102dd2f0e8b11d0f600bfb0c093" 385 | ",opaque=5ccc069c403ebaf9f0171e9517f40e41" 386 | ) 387 | }, 388 | ) 389 | 390 | store_set( 391 | bindings=[ 392 | { 393 | "auth_type": "digest", 394 | "auth": "user:$PASSWORD", 395 | "resources": ["https://yoda.ua"], 396 | } 397 | ], 398 | secrets={ 399 | "PASSWORD": { 400 | "provider": "sh", 401 | "script": f"cat {secrettxt}", 402 | } 403 | }, 404 | ) 405 | httpie_run(["-A", "store", "https://yoda.ua"]) 406 | 407 | assert len(responses.calls) == 2 408 | request = responses.calls[0].request 409 | 410 | assert request.url == "https://yoda.ua/" 411 | assert "Authorization" not in request.headers 412 | 413 | request = responses.calls[1].request 414 | assert request.url == "https://yoda.ua/" 415 | assert request.headers["Authorization"] == _DigestAuthHeader( 416 | { 417 | "username": "user", 418 | "realm": "auth.yoda.ua", 419 | "nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093", 420 | "uri": "/", 421 | "opaque": "5ccc069c403ebaf9f0171e9517f40e41", 422 | "qop": "auth", 423 | "nc": "00000001", 424 | # Both 'response' and 'cnonce' are time-based, thus there's no 425 | # reliable way to check their values without mocking time module. 426 | # Since we do not test here produced "digest", but ensure a proper 427 | # auth method is used, checking these values using regular 428 | # expression should be enough. 429 | "response": _RegExp(r"^[0-9a-fA-F]{32}$"), 430 | "cnonce": _RegExp(r"^[0-9a-fA-F]{16}$"), 431 | } 432 | ) 433 | 434 | 435 | @responses.activate 436 | def test_store_auth_bearer(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 437 | """The plugin works for HTTP bearer auth.""" 438 | 439 | store_set( 440 | bindings=[ 441 | { 442 | "auth_type": "bearer", 443 | "auth": "token-can-be-anything", 444 | "resources": ["https://yoda.ua"], 445 | } 446 | ], 447 | ) 448 | httpie_run(["-A", "store", "https://yoda.ua"]) 449 | 450 | assert len(responses.calls) == 1 451 | request = responses.calls[0].request 452 | 453 | assert request.url == "https://yoda.ua/" 454 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 455 | 456 | 457 | @responses.activate 458 | def test_store_auth_bearer_secret_provider( 459 | httpie_run: HttpieRunT, 460 | store_set: StoreSetT, 461 | tmp_path: pathlib.Path, 462 | ) -> None: 463 | """The plugin retrieves secrets from secret provider for HTTP bearer auth.""" 464 | 465 | secrettxt = tmp_path.joinpath("secret.txt") 466 | secrettxt.write_text("token-can-be-anything", encoding="UTF-8") 467 | 468 | store_set( 469 | bindings=[ 470 | { 471 | "auth_type": "bearer", 472 | "auth": "$TOKEN", 473 | "resources": ["https://yoda.ua"], 474 | } 475 | ], 476 | secrets={ 477 | "TOKEN": { 478 | "provider": "sh", 479 | "script": f"cat {secrettxt}", 480 | }, 481 | }, 482 | ) 483 | httpie_run(["-A", "store", "https://yoda.ua"]) 484 | 485 | assert len(responses.calls) == 1 486 | request = responses.calls[0].request 487 | 488 | assert request.url == "https://yoda.ua/" 489 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 490 | 491 | 492 | @responses.activate 493 | def test_store_auth_header(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 494 | """The plugin works for HTTP header auth.""" 495 | 496 | store_set( 497 | bindings=[ 498 | { 499 | "auth_type": "header", 500 | "auth": "X-Auth:value-can-be:anything", 501 | "resources": ["https://yoda.ua"], 502 | } 503 | ] 504 | ) 505 | httpie_run(["-A", "store", "https://yoda.ua"]) 506 | 507 | assert len(responses.calls) == 1 508 | request = responses.calls[0].request 509 | 510 | assert request.url == "https://yoda.ua/" 511 | assert request.headers["X-Auth"] == "value-can-be:anything" 512 | 513 | 514 | @responses.activate 515 | def test_store_auth_header_secret_provider( 516 | httpie_run: HttpieRunT, 517 | store_set: StoreSetT, 518 | tmp_path: pathlib.Path, 519 | ) -> None: 520 | """The plugin retrieves secrets from secret provider for HTTP header auth.""" 521 | 522 | secrettxt = tmp_path.joinpath("secret.txt") 523 | secrettxt.write_text("value-can-be:anything", encoding="UTF-8") 524 | 525 | store_set( 526 | bindings=[ 527 | { 528 | "auth_type": "header", 529 | "auth": "X-Auth:$SECRET", 530 | "resources": ["https://yoda.ua"], 531 | } 532 | ], 533 | secrets={ 534 | "SECRET": { 535 | "provider": "sh", 536 | "script": f"cat {secrettxt}", 537 | }, 538 | }, 539 | ) 540 | httpie_run(["-A", "store", "https://yoda.ua"]) 541 | 542 | assert len(responses.calls) == 1 543 | request = responses.calls[0].request 544 | 545 | assert request.url == "https://yoda.ua/" 546 | assert request.headers["X-Auth"] == "value-can-be:anything" 547 | 548 | 549 | @responses.activate 550 | def test_store_auth_3rd_party_plugin( 551 | httpie_run: HttpieRunT, 552 | store_set: StoreSetT, 553 | ) -> None: 554 | """The plugin works for third-party auth plugin.""" 555 | 556 | store_set( 557 | bindings=[ 558 | { 559 | "auth_type": "hmac", 560 | "auth": "secret:rice", 561 | "resources": ["https://yoda.ua"], 562 | } 563 | ], 564 | ) 565 | 566 | # The 'Date' request header is supplied to make sure that produced HMAC 567 | # is always the same. 568 | httpie_run(["-A", "store", "https://yoda.ua", "Date: Wed, 08 May 2024 00:00:00 GMT"]) 569 | 570 | assert len(responses.calls) == 1 571 | request = responses.calls[0].request 572 | 573 | assert request.url == "https://yoda.ua/" 574 | assert request.headers["Authorization"] == "HMAC dGPPAQGIQ4KYgxuZm45G8pUspKI2wx/XjwMBpoMi3Gk=" 575 | 576 | 577 | @responses.activate 578 | def test_store_auth_3rd_party_plugin_secret_provider( 579 | httpie_run: HttpieRunT, 580 | store_set: StoreSetT, 581 | tmp_path: pathlib.Path, 582 | ) -> None: 583 | """The plugin retrieves secrets from secret provider for third-party auth plugin.""" 584 | 585 | secrettxt = tmp_path.joinpath("secret.txt") 586 | secrettxt.write_text("rice", encoding="UTF-8") 587 | 588 | store_set( 589 | bindings=[ 590 | { 591 | "auth_type": "hmac", 592 | "auth": "secret:$HMAC_SECRET", 593 | "resources": ["https://yoda.ua"], 594 | } 595 | ], 596 | secrets={ 597 | "HMAC_SECRET": { 598 | "provider": "sh", 599 | "script": f"cat {secrettxt}", 600 | } 601 | }, 602 | ) 603 | 604 | # The 'Date' request header is supplied to make sure that produced HMAC 605 | # is always the same. 606 | httpie_run(["-A", "store", "https://yoda.ua", "Date: Wed, 08 May 2024 00:00:00 GMT"]) 607 | 608 | assert len(responses.calls) == 1 609 | request = responses.calls[0].request 610 | 611 | assert request.url == "https://yoda.ua/" 612 | assert request.headers["Authorization"] == "HMAC dGPPAQGIQ4KYgxuZm45G8pUspKI2wx/XjwMBpoMi3Gk=" 613 | 614 | 615 | @responses.activate 616 | def test_store_auth_composite_bearer_header( 617 | httpie_run: HttpieRunT, 618 | store_set: StoreSetT, 619 | ) -> None: 620 | """The plugin works for composite auth.""" 621 | 622 | store_set( 623 | bindings=[ 624 | { 625 | "auth_type": "composite", 626 | "auth": [ 627 | { 628 | "auth_type": "bearer", 629 | "auth": "token-can-be-anything", 630 | }, 631 | { 632 | "auth_type": "header", 633 | "auth": "X-Auth:secret-can-be-anything", 634 | }, 635 | ], 636 | "resources": ["https://yoda.ua"], 637 | } 638 | ] 639 | ) 640 | httpie_run(["-A", "store", "https://yoda.ua"]) 641 | 642 | assert len(responses.calls) == 1 643 | request = responses.calls[0].request 644 | 645 | assert request.url == "https://yoda.ua/" 646 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 647 | assert request.headers["X-Auth"] == "secret-can-be-anything" 648 | 649 | 650 | @responses.activate 651 | def test_store_auth_composite_header_header( 652 | httpie_run: HttpieRunT, 653 | store_set: StoreSetT, 654 | ) -> None: 655 | """The plugin supports usage of the same auth provider twice.""" 656 | 657 | store_set( 658 | bindings=[ 659 | { 660 | "auth_type": "composite", 661 | "auth": [ 662 | { 663 | "auth_type": "header", 664 | "auth": "X-Secret:secret-can-be-anything", 665 | }, 666 | { 667 | "auth_type": "header", 668 | "auth": "X-Auth:secret-can-be-anything", 669 | }, 670 | ], 671 | "resources": ["https://yoda.ua"], 672 | } 673 | ] 674 | ) 675 | httpie_run(["-A", "store", "https://yoda.ua"]) 676 | 677 | assert len(responses.calls) == 1 678 | request = responses.calls[0].request 679 | 680 | assert request.url == "https://yoda.ua/" 681 | assert request.headers["X-Secret"] == "secret-can-be-anything" 682 | assert request.headers["X-Auth"] == "secret-can-be-anything" 683 | 684 | 685 | @responses.activate 686 | def test_store_auth_composite_bearer_header_secret_provider( 687 | httpie_run: HttpieRunT, 688 | store_set: StoreSetT, 689 | tmp_path: pathlib.Path, 690 | ) -> None: 691 | """The plugin retrieves secrets from secret providers for composite auth.""" 692 | 693 | tokentxt, secrettxt = tmp_path.joinpath("token.txt"), tmp_path.joinpath("secret.txt") 694 | tokentxt.write_text("token-can-be-anything", encoding="UTF-8") 695 | secrettxt.write_text("secret-can-be-anything", encoding="UTF-8") 696 | 697 | store_set( 698 | bindings=[ 699 | { 700 | "auth_type": "composite", 701 | "auth": [ 702 | { 703 | "auth_type": "bearer", 704 | "auth": "$TOKEN", 705 | }, 706 | { 707 | "auth_type": "header", 708 | "auth": "X-Auth:$SECRET", 709 | }, 710 | ], 711 | "resources": ["https://yoda.ua"], 712 | } 713 | ], 714 | secrets={ 715 | "TOKEN": { 716 | "provider": "sh", 717 | "script": f"cat {tokentxt}", 718 | }, 719 | "SECRET": { 720 | "provider": "sh", 721 | "script": f"cat {secrettxt}", 722 | }, 723 | }, 724 | ) 725 | httpie_run(["-A", "store", "https://yoda.ua"]) 726 | 727 | assert len(responses.calls) == 1 728 | request = responses.calls[0].request 729 | 730 | assert request.url == "https://yoda.ua/" 731 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 732 | assert request.headers["X-Auth"] == "secret-can-be-anything" 733 | 734 | 735 | @responses.activate 736 | @pytest.mark.parametrize("auth_type", ["basic", "digest", "bearer", "header", "composite"]) 737 | def test_store_auth_required( 738 | httpie_run: HttpieRunT, 739 | store_set: StoreSetT, 740 | httpie_stderr: io.StringIO, 741 | auth_type: str, 742 | ) -> None: 743 | """The plugin raises error if auth is missing but required.""" 744 | 745 | store_set( 746 | bindings=[ 747 | { 748 | "auth_type": auth_type, 749 | "resources": ["https://yoda.ua"], 750 | } 751 | ] 752 | ) 753 | httpie_run(["-A", "store", "https://yoda.ua"]) 754 | 755 | error_message = ( 756 | f"http: error: ValueError: Broken '{auth_type}' authentication entry: missing 'auth'." 757 | ) 758 | 759 | if _is_windows: 760 | # The error messages on Windows doesn't contain class names before 761 | # method names, thus we have to cut them out. 762 | error_message = re.sub(r"ValueError: \w+\.", "ValueError: ", error_message) 763 | 764 | assert len(responses.calls) == 0 765 | assert httpie_stderr.getvalue().strip() == error_message 766 | 767 | 768 | @responses.activate 769 | @pytest.mark.parametrize( 770 | "resource", 771 | [ 772 | "https://yoda.ua", 773 | "https://yoda.ua/", 774 | "https://yoda.ua/v1", 775 | "https://yoda.ua/v1/", 776 | ], 777 | ) 778 | def test_store_lookup_subresource( 779 | httpie_run: HttpieRunT, 780 | store_set: StoreSetT, 781 | resource: str, 782 | ) -> None: 783 | """The plugin uses subresource checking to find authentication binding.""" 784 | 785 | store_set( 786 | bindings=[ 787 | { 788 | "auth_type": "bearer", 789 | "auth": "token-can-be-anything", 790 | "resources": [resource], 791 | } 792 | ] 793 | ) 794 | httpie_run(["-A", "store", "https://yoda.ua/v1/subresource"]) 795 | 796 | assert len(responses.calls) == 1 797 | request = responses.calls[0].request 798 | 799 | assert request.url == "https://yoda.ua/v1/subresource" 800 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 801 | 802 | 803 | @responses.activate 804 | def test_store_lookup_scheme_case_insensitive( 805 | httpie_run: HttpieRunT, 806 | store_set: StoreSetT, 807 | ) -> None: 808 | """The plugin uses case insensitive scheme comparison.""" 809 | 810 | store_set( 811 | bindings=[ 812 | { 813 | "auth_type": "bearer", 814 | "auth": "token-can-be-anything", 815 | "resources": ["HttPs://yoda.ua"], 816 | } 817 | ] 818 | ) 819 | httpie_run(["-A", "store", "https://yoda.ua/v1/subresource"]) 820 | 821 | assert len(responses.calls) == 1 822 | request = responses.calls[0].request 823 | 824 | assert request.url == "https://yoda.ua/v1/subresource" 825 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 826 | 827 | 828 | @responses.activate 829 | def test_store_lookup_hostname_case_insensitive( 830 | httpie_run: HttpieRunT, 831 | store_set: StoreSetT, 832 | ) -> None: 833 | """The plugin uses case insensitive hostname comparison.""" 834 | 835 | store_set( 836 | bindings=[ 837 | { 838 | "auth_type": "bearer", 839 | "auth": "token-can-be-anything", 840 | "resources": ["https://yoda.ua"], 841 | } 842 | ] 843 | ) 844 | httpie_run(["-A", "store", "https://yoda.ua/v1/subresource"]) 845 | 846 | assert len(responses.calls) == 1 847 | request = responses.calls[0].request 848 | 849 | assert request.url == "https://yoda.ua/v1/subresource" 850 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 851 | 852 | 853 | @responses.activate 854 | def test_store_lookup_path_case_sensitive( 855 | httpie_run: HttpieRunT, 856 | store_set: StoreSetT, 857 | httpie_stderr: io.StringIO, 858 | ) -> None: 859 | """The plugin uses case sensitive path comparison.""" 860 | 861 | store_set( 862 | bindings=[ 863 | { 864 | "auth_type": "bearer", 865 | "auth": "token-can-be-anything", 866 | "resources": ["https://yoda.ua/v1/"], 867 | } 868 | ] 869 | ) 870 | httpie_run(["-A", "store", "https://yoda.ua/V1/subresource"]) 871 | 872 | assert len(responses.calls) == 0 873 | assert httpie_stderr.getvalue().strip() == ( 874 | "http: error: LookupError: No binding found for 'https://yoda.ua/V1/subresource'." 875 | ) 876 | 877 | 878 | @responses.activate 879 | def test_store_lookup_1st_matched_wins(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 880 | """The plugin uses auth of first matched binding.""" 881 | 882 | store_set( 883 | bindings=[ 884 | { 885 | "auth_type": "bearer", 886 | "auth": "token-can-be-anything", 887 | "resources": ["https://yoda.ua"], 888 | }, 889 | { 890 | "auth_type": "basic", 891 | "auth": "user:p@ss", 892 | "resources": ["https://yoda.ua/v2"], 893 | }, 894 | ] 895 | ) 896 | httpie_run(["-A", "store", "https://yoda.ua/v2/the-force"]) 897 | 898 | assert len(responses.calls) == 1 899 | request = responses.calls[0].request 900 | 901 | assert request.url == "https://yoda.ua/v2/the-force" 902 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 903 | 904 | 905 | @responses.activate 906 | def test_store_lookup_many_bindings(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 907 | """The plugin works with many URLs and bindings.""" 908 | 909 | responses.add(responses.GET, "https://yoda.ua/about/", status=200) 910 | responses.add(responses.GET, "https://skywalker.com", status=200) 911 | 912 | store_set( 913 | bindings=[ 914 | { 915 | "auth_type": "bearer", 916 | "auth": "token-can-be-anything", 917 | "resources": ["https://yoda.ua"], 918 | }, 919 | { 920 | "auth_type": "basic", 921 | "auth": "user:p@ss", 922 | "resources": ["https://skywalker.com"], 923 | }, 924 | ] 925 | ) 926 | httpie_run(["-A", "store", "https://yoda.ua/about/"]) 927 | httpie_run(["-A", "store", "https://skywalker.com"]) 928 | assert len(responses.calls) == 2 929 | 930 | request = responses.calls[0].request 931 | assert request.url == "https://yoda.ua/about/" 932 | assert request.headers["Authorization"] == "Bearer token-can-be-anything" 933 | 934 | request = responses.calls[1].request 935 | assert request.url == "https://skywalker.com/" 936 | assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" 937 | 938 | 939 | @responses.activate 940 | @pytest.mark.parametrize( 941 | ("resource", "url"), 942 | [ 943 | pytest.param("http://yoda.ua/", "https://yoda.ua/", id="scheme"), 944 | pytest.param("https://yoda.ua/", "https://another.com/", id="hostname"), 945 | pytest.param("https://yoda.ua/v1/", "https://yoda.ua/v2/foo", id="path"), 946 | ], 947 | ) 948 | def test_store_lookup_not_found( 949 | httpie_run: HttpieRunT, 950 | store_set: StoreSetT, 951 | resource: str, 952 | url: str, 953 | httpie_stderr: io.StringIO, 954 | ) -> None: 955 | """The plugin raises error if no auth bindings found.""" 956 | 957 | store_set( 958 | bindings=[ 959 | { 960 | "auth_type": "bearer", 961 | "auth": "token-can-be-anything", 962 | "resources": [resource], 963 | } 964 | ] 965 | ) 966 | httpie_run(["-A", "store", url]) 967 | 968 | assert len(responses.calls) == 0 969 | assert httpie_stderr.getvalue().strip() == ( 970 | f"http: error: LookupError: No binding found for '{url}'." 971 | ) 972 | 973 | 974 | @responses.activate 975 | def test_store_lookup_missing_resource_scheme( 976 | httpie_run: HttpieRunT, 977 | store_set: StoreSetT, 978 | httpie_stderr: io.StringIO, 979 | ) -> None: 980 | """The plugin raises error if an auth binding missing scheme.""" 981 | 982 | store_set( 983 | bindings=[ 984 | { 985 | "auth_type": "bearer", 986 | "auth": "token-can-be-anything", 987 | "resources": ["yoda.ua"], 988 | } 989 | ] 990 | ) 991 | httpie_run(["-A", "store", "https://yoda.ua"]) 992 | 993 | assert len(responses.calls) == 0 994 | assert httpie_stderr.getvalue().strip() == ( 995 | "http: error: ValueError: Broken binding: missing schema in 'yoda.ua'." 996 | ) 997 | 998 | 999 | @responses.activate 1000 | def test_store_lookup_missing_resource_hostname( 1001 | httpie_run: HttpieRunT, 1002 | store_set: StoreSetT, 1003 | httpie_stderr: io.StringIO, 1004 | ) -> None: 1005 | """The plugin raises error if an auth binding missing hostname.""" 1006 | 1007 | store_set( 1008 | bindings=[ 1009 | { 1010 | "auth_type": "bearer", 1011 | "auth": "token-can-be-anything", 1012 | "resources": ["https:///"], 1013 | } 1014 | ] 1015 | ) 1016 | httpie_run(["-A", "store", "https://yoda.ua"]) 1017 | 1018 | assert len(responses.calls) == 0 1019 | assert httpie_stderr.getvalue().strip() == ( 1020 | "http: error: ValueError: Broken binding: missing hostname in 'https:///'." 1021 | ) 1022 | 1023 | 1024 | @responses.activate 1025 | def test_store_lookup_by_id(httpie_run: HttpieRunT, store_set: StoreSetT) -> None: 1026 | """The plugin uses a given credential ID as a hint for 2+ matches.""" 1027 | 1028 | store_set( 1029 | bindings=[ 1030 | { 1031 | "id": "yoda", 1032 | "auth_type": "bearer", 1033 | "auth": "i-am-yoda", 1034 | "resources": ["https://yoda.ua"], 1035 | }, 1036 | { 1037 | "id": "luke", 1038 | "auth_type": "bearer", 1039 | "auth": "i-am-skywalker", 1040 | "resources": ["https://yoda.ua"], 1041 | }, 1042 | ] 1043 | ) 1044 | httpie_run(["-A", "store", "https://yoda.ua/about/"]) 1045 | httpie_run(["-A", "store", "-a", "luke", "https://yoda.ua/about/"]) 1046 | assert len(responses.calls) == 2 1047 | 1048 | request = responses.calls[0].request 1049 | assert request.url == "https://yoda.ua/about/" 1050 | assert request.headers["Authorization"] == "Bearer i-am-yoda" 1051 | 1052 | request = responses.calls[1].request 1053 | assert request.url == "https://yoda.ua/about/" 1054 | assert request.headers["Authorization"] == "Bearer i-am-skywalker" 1055 | 1056 | 1057 | @responses.activate 1058 | def test_store_lookup_by_id_error( 1059 | httpie_run: HttpieRunT, 1060 | store_set: StoreSetT, 1061 | httpie_stderr: io.StringIO, 1062 | ) -> None: 1063 | """The plugin raises error if no auth binding found.""" 1064 | 1065 | store_set( 1066 | bindings=[ 1067 | { 1068 | "id": "yoda", 1069 | "auth_type": "bearer", 1070 | "auth": "i-am-yoda", 1071 | "resources": ["https://yoda.ua"], 1072 | }, 1073 | { 1074 | "id": "luke", 1075 | "auth_type": "bearer", 1076 | "auth": "i-am-skywalker", 1077 | "resources": ["https://yoda.ua"], 1078 | }, 1079 | ] 1080 | ) 1081 | 1082 | httpie_run(["-A", "store", "-a", "vader", "https://yoda.ua/about/"]) 1083 | assert len(responses.calls) == 0 1084 | assert httpie_stderr.getvalue().strip() == ( 1085 | "http: error: LookupError: No binding found for 'https://yoda.ua/about/'." 1086 | ) 1087 | 1088 | 1089 | @responses.activate 1090 | def test_store_lookup_missing_secret( 1091 | httpie_run: HttpieRunT, 1092 | store_set: StoreSetT, 1093 | httpie_stderr: io.StringIO, 1094 | ) -> None: 1095 | """The plugin uses case sensitive path comparison.""" 1096 | 1097 | store_set( 1098 | bindings=[ 1099 | { 1100 | "auth_type": "bearer", 1101 | "auth": "$TOKEN", 1102 | "resources": ["https://yoda.ua/"], 1103 | } 1104 | ], 1105 | ) 1106 | httpie_run(["-A", "store", "https://yoda.ua/"]) 1107 | 1108 | assert len(responses.calls) == 0 1109 | assert httpie_stderr.getvalue().strip() == ( 1110 | "http: error: ValueError: Broken authentication entry: missing secret: TOKEN." 1111 | ) 1112 | 1113 | 1114 | @responses.activate 1115 | @pytest.mark.skipif(_is_windows, reason="no support for permissions on windows") 1116 | @pytest.mark.parametrize( 1117 | "mode", 1118 | [ 1119 | pytest.param(0o700, id="0700"), 1120 | pytest.param(0o600, id="0600"), 1121 | pytest.param(0o500, id="0500"), 1122 | pytest.param(0o400, id="0400"), 1123 | ], 1124 | ) 1125 | def test_store_permissions_safe( 1126 | httpie_run: HttpieRunT, 1127 | store_set: StoreSetT, 1128 | mode: int, 1129 | ) -> None: 1130 | """The plugin doesn't complain if auth store has safe permissions.""" 1131 | 1132 | store_set( 1133 | bindings=[ 1134 | { 1135 | "auth_type": "basic", 1136 | "auth": "user:p@ss", 1137 | "resources": ["https://yoda.ua"], 1138 | } 1139 | ], 1140 | mode=mode, 1141 | ) 1142 | httpie_run(["-A", "store", "https://yoda.ua"]) 1143 | 1144 | assert len(responses.calls) == 1 1145 | request = responses.calls[0].request 1146 | 1147 | assert request.url == "https://yoda.ua/" 1148 | assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" 1149 | 1150 | 1151 | @responses.activate 1152 | @pytest.mark.skipif(_is_windows, reason="no support for permissions on windows") 1153 | @pytest.mark.parametrize( 1154 | "mode", 1155 | [ 1156 | pytest.param(0o607, id="0607"), 1157 | pytest.param(0o606, id="0606"), 1158 | pytest.param(0o605, id="0605"), 1159 | pytest.param(0o604, id="0604"), 1160 | pytest.param(0o603, id="0603"), 1161 | pytest.param(0o602, id="0602"), 1162 | pytest.param(0o601, id="0601"), 1163 | pytest.param(0o670, id="0670"), 1164 | pytest.param(0o660, id="0660"), 1165 | pytest.param(0o650, id="0650"), 1166 | pytest.param(0o640, id="0640"), 1167 | pytest.param(0o630, id="0630"), 1168 | pytest.param(0o620, id="0620"), 1169 | pytest.param(0o610, id="0610"), 1170 | ], 1171 | ) 1172 | def test_store_permissions_unsafe( 1173 | httpie_run: HttpieRunT, 1174 | store_set: StoreSetT, 1175 | mode: int, 1176 | httpie_stderr: io.StringIO, 1177 | auth_store_path: pathlib.Path, 1178 | ) -> None: 1179 | """The plugin complains if auth store has unsafe permissions.""" 1180 | 1181 | store_set(bindings=[], mode=mode) 1182 | httpie_run(["-A", "store", "https://yoda.ua"]) 1183 | 1184 | assert httpie_stderr.getvalue().strip() == ( 1185 | f"http: error: PermissionError: Permissions {mode:04o} for " 1186 | f"'{auth_store_path}' are too open. Authentication store MUST NOT " 1187 | f"be accessible by others." 1188 | ) 1189 | 1190 | 1191 | @responses.activate 1192 | @pytest.mark.skipif(_is_windows, reason="no support for permissions on windows") 1193 | @pytest.mark.parametrize( 1194 | "mode", 1195 | [ 1196 | pytest.param(0o300, id="0300"), 1197 | pytest.param(0o200, id="0200"), 1198 | pytest.param(0o100, id="0100"), 1199 | ], 1200 | ) 1201 | def test_store_permissions_not_enough( 1202 | httpie_run: HttpieRunT, 1203 | store_set: StoreSetT, 1204 | mode: int, 1205 | httpie_stderr: io.StringIO, 1206 | auth_store_path: pathlib.Path, 1207 | ) -> None: 1208 | """The plugin complains if auth store has unsafe permissions.""" 1209 | 1210 | store_set(bindings=[], mode=mode) 1211 | httpie_run(["-A", "store", "https://yoda.ua"]) 1212 | 1213 | assert httpie_stderr.getvalue().strip() == ( 1214 | f"http: error: PermissionError: Permissions {mode:04o} for " 1215 | f"'{auth_store_path}' are too close. Authentication store MUST be " 1216 | f"readabe by you." 1217 | ) 1218 | 1219 | 1220 | @responses.activate 1221 | def test_store_autocreation_when_missing( 1222 | httpie_run: HttpieRunT, 1223 | auth_store_path: pathlib.Path, 1224 | ) -> None: 1225 | """The auth store is created when missing with some examples.""" 1226 | 1227 | httpie_run(["-A", "store", "https://pie.dev/basic-auth/batman/I@mTheN1ght"]) 1228 | httpie_run(["-A", "store", "https://pie.dev/bearer"]) 1229 | 1230 | assert auth_store_path.exists() 1231 | assert json.loads(auth_store_path.read_text()) == AuthStore.DEFAULT_AUTH_STORE 1232 | 1233 | request = responses.calls[0].request 1234 | assert request.url == "https://pie.dev/basic-auth/batman/I@mTheN1ght" 1235 | assert request.headers["Authorization"] == b"Basic YmF0bWFuOklAbVRoZU4xZ2h0" 1236 | 1237 | request = responses.calls[1].request 1238 | assert request.url == "https://pie.dev/bearer" 1239 | assert request.headers["Authorization"] == "Bearer 000000000000000000000000deadc0de" 1240 | --------------------------------------------------------------------------------