Skip to content

Commit

Permalink
Merge pull request #28 from ikalnytskyi/feat/support-auth-plugins
Browse files Browse the repository at this point in the history
Add third-party auth plugins support
  • Loading branch information
ikalnytskyi authored May 8, 2024
2 parents 35a5ace + 60d889b commit 5aef1e7
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 50 deletions.
27 changes: 24 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
.........
Expand Down Expand Up @@ -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:<HMAC_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
------------------

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 11 additions & 18 deletions src/httpie_credential_store/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import collections.abc
import re

import httpie.plugins.registry
import requests.auth

from ._keychain import get_keychain
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
125 changes: 96 additions & 29 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 5aef1e7

Please sign in to comment.