Skip to content

Commit

Permalink
Add third-party auth plugins support
Browse files Browse the repository at this point in the history
The HTTPie ecosystem has myriads of third-party authentication plugins.
Even though they ain't as widespread as core ones, I see a huge
advantage if we can support those plugins too.

This patch adds support of third-party authentication plugins by
retrieving a proper authentication plugin via HTTPie's plugin manager,
instead of reimplementing authentication code for core plugins in this
project source tree.

There's another benefits of using authentication plugins here. According
to some HTTPie in-source comments, the HTTP basic auth from requests
library has some unicode issues, and those HTTPie mantains its own basic
auth implementation. By using authentication plugins directly, we can
get advantage of that implementation too.
  • Loading branch information
ikalnytskyi committed May 8, 2024
1 parent 35a5ace commit 23df99d
Show file tree
Hide file tree
Showing 4 changed files with 127 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)
120 changes: 91 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,86 @@ 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"])

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 +796,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 +926,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 23df99d

Please sign in to comment.