diff --git a/README.rst b/README.rst index 6420f59..e753d0d 100644 --- a/README.rst +++ b/README.rst @@ -113,9 +113,9 @@ passing ``-a`` argument. Authentication providers ------------------------ -HTTPie Credential Store comes with the following authentication -providers out of box. - +HTTPie Credential Store supports both built-in and third-party HTTPie +authentication plugins as well as provides few authentication plugins +on its own. ``basic`` ......... @@ -228,6 +228,27 @@ where * ``providers`` is a list of auth providers to use simultaneously +``hmac`` +........ + +The 'HMAC' authentication is not built-in one and requires the ``httpie-hmac`` +plugin to be installed first. Its only purpose here is to serve as an example +of how to invoke third-party authentication plugins from the credentials store. + +.. code:: json + + { + "provider": "hmac", + "auth": "secret:" + } + +where + +* ``auth`` is a string with authentication payload passed that is normally + passed by a user via ``--auth``/``-a`` to HTTPie; each authentication plugin + may or may not require one + + Keychain providers ------------------ diff --git a/pyproject.toml b/pyproject.toml index a73ecf6..0b2e86f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ optional = true pytest = "^7.1" responses = "^0.20" pytest-github-actions-annotate-failures = "*" +httpie-hmac = "*" [tool.poetry.plugins."httpie.plugins.auth.v1"] credential-store = "httpie_credential_store:CredentialStoreAuthPlugin" diff --git a/src/httpie_credential_store/_auth.py b/src/httpie_credential_store/_auth.py index a9e1b12..da46979 100644 --- a/src/httpie_credential_store/_auth.py +++ b/src/httpie_credential_store/_auth.py @@ -4,6 +4,7 @@ import collections.abc import re +import httpie.plugins.registry import requests.auth from ._keychain import get_keychain @@ -39,24 +40,6 @@ def __call__(self, request): """Attach authentication to a given request.""" -class HTTPBasicAuth(requests.auth.HTTPBasicAuth, AuthProvider): - """Authentication via HTTP Basic scheme.""" - - name = "basic" - - def __init__(self, *, username, password): - super().__init__(username, get_secret(password)) - - -class HTTPDigestAuth(requests.auth.HTTPDigestAuth, AuthProvider): - """Authentication via HTTP Digest scheme.""" - - name = "digest" - - def __init__(self, *, username, password): - super().__init__(username, get_secret(password)) - - class HTTPHeaderAuth(requests.auth.AuthBase, AuthProvider): """Authentication via custom HTTP header.""" @@ -135,4 +118,14 @@ def __call__(self, request): def get_auth(provider, **kwargs): + try: + plugin_cls = httpie.plugins.registry.plugin_manager.get_auth_plugin(provider) + except KeyError: + pass + else: + plugin = plugin_cls() + plugin.raw_auth = get_secret(kwargs.pop("auth", None)) + kwargs = {k: get_secret(v) for k, v in kwargs.items()} + return plugin.get_auth(**kwargs) + return _PROVIDERS[provider](**kwargs) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 4e914d8..68b42dd 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -153,7 +153,7 @@ def test_creds_auth_basic(httpie_run, set_credentials, creds_auth_type): request = responses.calls[0].request assert request.url == "http://example.com/" - assert request.headers["Authorization"] == "Basic dXNlcjpwQHNz" + assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" @responses.activate @@ -184,7 +184,7 @@ def test_creds_auth_basic_keychain(httpie_run, set_credentials, creds_auth_type, request = responses.calls[0].request assert request.url == "http://example.com/" - assert request.headers["Authorization"] == "Basic dXNlcjpwQHNz" + assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" @responses.activate @@ -382,6 +382,68 @@ def test_creds_auth_header_keychain(httpie_run, set_credentials, creds_auth_type assert request.headers["X-Auth"] == "value-can-be-anything" +@responses.activate +def test_creds_auth_3rd_party_plugin(httpie_run, set_credentials, creds_auth_type): + """The plugin works for third-party auth plugin.""" + + set_credentials( + [ + { + "url": "http://example.com", + "auth": { + "provider": "hmac", + "auth": "secret:rice", + }, + } + ] + ) + + # The 'Date' request header is supplied to make sure that produced HMAC + # is always the same. + httpie_run(["-A", creds_auth_type, "http://example.com", "Date: Wed, 08 May 2024 00:00:00 GMT"]) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == "http://example.com/" + assert request.headers["Authorization"] == "HMAC dGPPAQGIQ4KYgxuZm45G8pUspKI2wx/XjwMBpoMi3Gk=" + + +@responses.activate +def test_creds_auth_3rd_party_plugin_keychain( + httpie_run, set_credentials, creds_auth_type, tmp_path +): + """The plugin retrieves secrets from keychain for third-party auth plugins.""" + + secrettxt = tmp_path.joinpath("secret.txt") + secrettxt.write_text("secret:rice", encoding="UTF-8") + + set_credentials( + [ + { + "url": "http://example.com", + "auth": { + "provider": "hmac", + "auth": { + "keychain": "shell", + "command": f"cat {secrettxt}", + }, + }, + } + ] + ) + + # The 'Date' request header is supplied to make sure that produced HMAC + # is always the same. + httpie_run(["-A", creds_auth_type, "http://example.com", "Date: Wed, 08 May 2024 00:00:00 GMT"]) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == "http://example.com/" + assert request.headers["Authorization"] == "HMAC dGPPAQGIQ4KYgxuZm45G8pUspKI2wx/XjwMBpoMi3Gk=" + + @responses.activate def test_creds_auth_multiple_token_header(httpie_run, set_credentials, creds_auth_type): """The plugin works for multiple auths.""" @@ -504,86 +566,91 @@ def test_creds_auth_multiple_token_header_keychain( @responses.activate @pytest.mark.parametrize( - ("auth", "error_pattern"), + ("auth", "error_message"), [ pytest.param( {"provider": "basic"}, - r"http: error: TypeError: (HTTPBasicAuth\.)?__init__\(\) missing 2 required " - r"keyword-only arguments: 'username' and 'password'", + "http: error: TypeError: BasicAuthPlugin.get_auth() missing 2 " + "required positional arguments: 'username' and 'password'", id="basic-both", ), pytest.param( {"provider": "basic", "username": "user"}, - r"http: error: TypeError: (HTTPBasicAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'password'", + "http: error: TypeError: BasicAuthPlugin.get_auth() missing 1 " + "required positional argument: 'password'", id="basic-passowrd", ), pytest.param( {"provider": "basic", "password": "p@ss"}, - r"http: error: TypeError: (HTTPBasicAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'username'", + "http: error: TypeError: BasicAuthPlugin.get_auth() missing 1 " + "required positional argument: 'username'", id="basic-username", ), pytest.param( {"provider": "digest"}, - r"http: error: TypeError: (HTTPDigestAuth\.)?__init__\(\) missing 2 required " - r"keyword-only arguments: 'username' and 'password'", + "http: error: TypeError: DigestAuthPlugin.get_auth() missing 2 " + "required positional arguments: 'username' and 'password'", id="digest-both", ), pytest.param( {"provider": "digest", "username": "user"}, - r"http: error: TypeError: (HTTPDigestAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'password'", + "http: error: TypeError: DigestAuthPlugin.get_auth() missing 1 " + "required positional argument: 'password'", id="digest-password", ), pytest.param( {"provider": "digest", "password": "p@ss"}, - r"http: error: TypeError: (HTTPDigestAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'username'", + "http: error: TypeError: DigestAuthPlugin.get_auth() missing 1 " + "required positional argument: 'username'", id="digest-username", ), pytest.param( {"provider": "token"}, - r"http: error: TypeError: (HTTPTokenAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'token'", + "http: error: TypeError: HTTPTokenAuth.__init__() missing 1 " + "required keyword-only argument: 'token'", id="token", ), pytest.param( {"provider": "header"}, - r"http: error: TypeError: (HTTPHeaderAuth\.)?__init__\(\) missing 2 required " - r"keyword-only arguments: 'name' and 'value'", + "http: error: TypeError: HTTPHeaderAuth.__init__() missing 2 " + "required keyword-only arguments: 'name' and 'value'", id="header-both", ), pytest.param( {"provider": "header", "name": "X-Auth"}, - r"http: error: TypeError: (HTTPHeaderAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'value'", + "http: error: TypeError: HTTPHeaderAuth.__init__() missing 1 " + "required keyword-only argument: 'value'", id="header-value", ), pytest.param( {"provider": "header", "value": "value-can-be-anything"}, - r"http: error: TypeError: (HTTPHeaderAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'name'", + "http: error: TypeError: HTTPHeaderAuth.__init__() missing 1 " + "required keyword-only argument: 'name'", id="header-name", ), pytest.param( {"provider": "multiple"}, - r"http: error: TypeError: (HTTPMultipleAuth\.)?__init__\(\) missing 1 required " - r"keyword-only argument: 'providers'", + "http: error: TypeError: HTTPMultipleAuth.__init__() missing 1 " + "required keyword-only argument: 'providers'", id="multiple", ), ], ) def test_creds_auth_missing( - httpie_run, set_credentials, httpie_stderr, auth, error_pattern, creds_auth_type + httpie_run, set_credentials, httpie_stderr, auth, error_message, creds_auth_type ): """The plugin raises error on wrong parameters.""" set_credentials([{"url": "http://example.com", "auth": auth}]) httpie_run(["-A", creds_auth_type, "http://example.com"]) + if _is_windows: + # The error messages on Windows doesn't contain class names before + # method names, thus we have to cut them out. + error_message = re.sub(r"TypeError: \w+\.", "TypeError: ", error_message) + assert len(responses.calls) == 0 - assert re.fullmatch(error_pattern, httpie_stderr.getvalue().strip()) + assert httpie_stderr.getvalue().strip() == error_message @responses.activate @@ -734,7 +801,7 @@ def test_creds_lookup_many_credentials(httpie_run, set_credentials, creds_auth_t request = responses.calls[1].request assert request.url == "http://skywalker.com/" - assert request.headers["Authorization"] == "Basic dXNlcjpwQHNz" + assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" @responses.activate @@ -864,7 +931,7 @@ def test_creds_permissions_safe(httpie_run, set_credentials, mode, creds_auth_ty request = responses.calls[0].request assert request.url == "http://example.com/" - assert request.headers["Authorization"] == "Basic dXNlcjpwQHNz" + assert request.headers["Authorization"] == b"Basic dXNlcjpwQHNz" @responses.activate