Skip to content

Commit

Permalink
fix: pr suggestion about unifying httpx auth tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau committed Dec 12, 2024
1 parent 502584b commit 83764a9
Show file tree
Hide file tree
Showing 7 changed files with 81 additions and 159 deletions.
3 changes: 1 addition & 2 deletions craft_store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@


from . import creds, endpoints, errors, models, publishergateway
from ._httpx_auth import CandidAuth, DeveloperTokenAuth
from .auth import Auth
from .base_client import BaseClient
from .candidauth import CandidAuth
from .developer_token_auth import DeveloperTokenAuth
from .http_client import HTTPClient
from .store_client import StoreClient
from .ubuntu_one_store_client import UbuntuOneStoreClient
Expand Down
46 changes: 32 additions & 14 deletions craft_store/candidauth.py → craft_store/_httpx_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,59 +14,77 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Client for making requests towards publisher gateway."""
"""Craft Store Authentication Store."""

import abc
import logging
from collections.abc import Generator
from logging import getLogger
from typing import Literal

import httpx

from craft_store import auth, creds, errors

logger = getLogger(__name__)
logger = logging.getLogger(__name__)


class CandidAuth(httpx.Auth):
"""Request authentication using developer token."""
class _TokenAuth(httpx.Auth, metaclass=abc.ABCMeta):
"""Base class for httpx token-based authenticators."""

def __init__(
self,
*,
auth: auth.Auth,
auth_type: Literal["bearer", "macaroon"] = "bearer",
self, *, auth: auth.Auth, auth_type: Literal["bearer", "macaroon"] = "bearer"
) -> None:
super().__init__()
self._token: str | None = None
self._auth = auth
self._auth_type = auth_type
self._token: str | None = None

def auth_flow(
self,
request: httpx.Request,
) -> Generator[httpx.Request, httpx.Response, None]:
"""Update request to include Authorization header."""
if self._token is None:
logger.debug("Getting candid macaroon from keyring")
logger.debug("Getting token from keyring")
self._token = self.get_token_from_keyring()

self._update_headers(request)
yield request

@abc.abstractmethod
def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting candid from credential storage")
return creds.unmarshal_candid_credentials(self._auth.get_credentials())

def _update_headers(self, request: httpx.Request) -> None:
"""Add token to the request."""
logger.debug("Adding ephemeral token to request headers")
if self._token is None:
raise errors.DeveloperTokenUnavailableError(
message="Candid token is not available"
message="Token is not available"
)
request.headers["Authorization"] = self._format_auth_header()

def _format_auth_header(self) -> str:
if self._auth_type == "bearer":
return f"Bearer {self._token}"
return f"Macaroon {self._token}"


class CandidAuth(_TokenAuth):
"""Candid based authentication class for httpx store clients."""

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting candid from credential storage")
return creds.unmarshal_candid_credentials(self._auth.get_credentials())


class DeveloperTokenAuth(_TokenAuth):
"""Developer token based authentication class for httpx store clients."""

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting developer token from credential storage")
return creds.DeveloperToken.model_validate_json(
self._auth.get_credentials()
).macaroon
2 changes: 1 addition & 1 deletion craft_store/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import keyring.backends.fail
import keyring.errors
from jaraco.classes import properties
from xdg import BaseDirectory # type: ignore[import]
from xdg import BaseDirectory # type: ignore[import-untyped]

from . import errors

Expand Down
75 changes: 0 additions & 75 deletions craft_store/developer_token_auth.py

This file was deleted.

8 changes: 5 additions & 3 deletions craft_store/publishergateway/_publishergw.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

import httpx

from craft_store import auth, candidauth, errors
from craft_store import errors
from craft_store._httpx_auth import CandidAuth
from craft_store.auth import Auth

from . import _request, _response

Expand All @@ -33,11 +35,11 @@ class PublisherGateway:
Each instance is only valid for one particular namespace.
"""

def __init__(self, base_url: str, namespace: str, auth: auth.Auth) -> None:
def __init__(self, base_url: str, namespace: str, auth: Auth) -> None:
self._namespace = namespace
self._client = httpx.Client(
base_url=base_url,
auth=candidauth.CandidAuth(auth=auth, auth_type="macaroon"),
auth=CandidAuth(auth=auth, auth_type="macaroon"),
)

@staticmethod
Expand Down
45 changes: 0 additions & 45 deletions tests/unit/test_candid_auth.py

This file was deleted.

61 changes: 42 additions & 19 deletions tests/unit/test_developer_auth.py → tests/unit/test_httpx_auth.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2024 Canonical Ltd.
# Copyright 2021,2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""Tests for authorizing requests using DeveloperTokenAuth."""
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from typing import Literal, cast

import httpx
import pytest
import pytest_httpx
import pytest_mock
from craft_store import Auth, DeveloperTokenAuth, creds
from craft_store.errors import CredentialsUnavailable, DeveloperTokenUnavailableError
from craft_store import CandidAuth, DeveloperTokenAuth, creds, errors
from craft_store.auth import Auth


@pytest.fixture
def candid_auth(mock_auth):
return CandidAuth(
auth=mock_auth,
)


def test_candid_get_token_from_keyring(mock_auth, candid_auth):
mock_auth.get_credentials.return_value = "{}"

assert candid_auth.get_token_from_keyring() == "{}"


def test_candid_auth_flow(mock_auth, candid_auth):
mock_auth.get_credentials.return_value = "{}"

request = httpx.Request("GET", "http://localhost")

next(candid_auth.auth_flow(request))

assert request.headers["Authorization"] == "Bearer {}"


@pytest.fixture
Expand Down Expand Up @@ -87,7 +108,7 @@ def test_auth_if_token_unavailable() -> None:
httpx_client = httpx.Client(auth=developer_token_auth)

with pytest.raises(
CredentialsUnavailable,
errors.CredentialsUnavailable,
match=f"No credentials found for {app_name!r} on {host!r}.",
):
httpx_client.request("GET", "https://fake-testcraft-url.localhost")
Expand All @@ -98,11 +119,13 @@ def test_auth_if_token_unset(
mocker: pytest_mock.MockerFixture,
) -> None:
# do not set token that is available in keyring
mocker.patch.object(developer_token_auth, "get_token_from_keyring")
mocker.patch.object(
developer_token_auth, "get_token_from_keyring", return_value=None
)
httpx_client = httpx.Client(auth=developer_token_auth)

with pytest.raises(
DeveloperTokenUnavailableError,
match="Developer token is not available",
errors.DeveloperTokenUnavailableError,
match="Token is not available",
):
httpx_client.request("GET", "https://fake-testcraft-url.localhost")

0 comments on commit 83764a9

Please sign in to comment.