Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add third-party auth plugins support #28

Merged
merged 1 commit into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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