From 719bcd7ab568ac46349d1878c80db7dc9a78a5af Mon Sep 17 00:00:00 2001 From: Jayesh Velayudhan <jayesh@softwareheritage.org> Date: Wed, 8 Feb 2023 11:23:54 +0100 Subject: [PATCH] Add a starlette auth backend for bearer tokens accepts Keycloak server details and an aiocache cache instance returns a starlette SimpleUser along with permissions --- mypy.ini | 6 ++ requirements-starlette.txt | 3 + setup.py | 1 + swh/auth/starlette/__init__.py | 0 swh/auth/starlette/backends.py | 114 +++++++++++++++++++++++ swh/auth/tests/starlette/__init__.py | 0 swh/auth/tests/starlette/test_backend.py | 82 ++++++++++++++++ tox.ini | 3 + 8 files changed, 209 insertions(+) create mode 100644 requirements-starlette.txt create mode 100644 swh/auth/starlette/__init__.py create mode 100644 swh/auth/starlette/backends.py create mode 100644 swh/auth/tests/starlette/__init__.py create mode 100644 swh/auth/tests/starlette/test_backend.py diff --git a/mypy.ini b/mypy.ini index 59d49e2..8c47c45 100644 --- a/mypy.ini +++ b/mypy.ini @@ -21,3 +21,9 @@ ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True + +[mypy-starlette.*] +ignore_missing_imports = True + +[mypy-aiocache.*] +ignore_missing_imports = True diff --git a/requirements-starlette.txt b/requirements-starlette.txt new file mode 100644 index 0000000..c418118 --- /dev/null +++ b/requirements-starlette.txt @@ -0,0 +1,3 @@ +starlette +httpx +aiocache diff --git a/setup.py b/setup.py index 0fcb566..01c11f1 100755 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ setup( use_scm_version=True, extras_require={ "django": parse_requirements("django"), + "starlette": parse_requirements("starlette"), "testing": parse_requirements("test"), }, include_package_data=True, diff --git a/swh/auth/starlette/__init__.py b/swh/auth/starlette/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/starlette/backends.py b/swh/auth/starlette/backends.py new file mode 100644 index 0000000..632b87f --- /dev/null +++ b/swh/auth/starlette/backends.py @@ -0,0 +1,114 @@ +# Copyright (C) 2023 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from datetime import datetime +import hashlib +from typing import Any, Dict, Optional, Tuple + +from aiocache.base import BaseCache +from starlette.authentication import ( + AuthCredentials, + AuthenticationBackend, + AuthenticationError, + SimpleUser, +) +from starlette.requests import HTTPConnection + +from swh.auth.keycloak import ( + ExpiredSignatureError, + KeycloakError, + KeycloakOpenIDConnect, + keycloak_error_message, +) + + +class BearerTokenAuthBackend(AuthenticationBackend): + """ + Starlette authentication backend using Keycloak OpenID Connect authorization + + An Keycloak server, realm and a cache to store access tokens must be provided + """ + + def __init__( + self, server_url: str, realm_name: str, client_id: str, cache: BaseCache + ): + """ + Args: + server_url: Keycloak URL + realm_name: Keycloak realm name + client_id: Keycloak client ID + cache: An aiocache cache instance + """ + self.client_id = client_id + self.oidc_client = KeycloakOpenIDConnect( + server_url=server_url, + realm_name=realm_name, + client_id=client_id, + ) + self.cache = cache + + def _get_token_from_header(self, auth_header: str) -> str: + try: + auth_type, bearer_token = auth_header.split(" ", 1) + except ValueError: + raise AuthenticationError("Invalid auth header") + if auth_type != "Bearer": + raise AuthenticationError("Invalid or unsupported authorization type") + return bearer_token + + def _get_token_cache_key(self, refresh_token) -> str: + hasher = hashlib.sha1() + hasher.update(refresh_token.encode("ascii")) + return f"api_token_{hasher.hexdigest()}" + + def _get_new_access_token(self, refresh_token: str) -> Dict[str, Any]: + try: + access_token = self.oidc_client.refresh_token(refresh_token) + except KeycloakError as e: + raise AuthenticationError( + "Invalid or expired user token", keycloak_error_message(e) + ) + return access_token + + def _decode_token(self, access_token: str) -> Optional[Dict[str, Any]]: + if not access_token: + return None + try: + decoded_token = self.oidc_client.decode_token(access_token) + except (KeycloakError, UnicodeEncodeError, ExpiredSignatureError): + # token is eitehr too old or an invalid one + decoded_token = None + return decoded_token + + async def authenticate( + self, conn: HTTPConnection + ) -> Optional[Tuple[AuthCredentials, SimpleUser]]: + auth_header = conn.headers.get("Authorization") + if auth_header is None: + # anonymous user + return None + refresh_token = self._get_token_from_header(auth_header) + # get the cache key + cache_key = self._get_token_cache_key(refresh_token) + # read access token from the cache + access_token = await self.cache.get(cache_key) + decoded_token = self._decode_token(access_token) + if not access_token or not decoded_token: + access_token = self._get_new_access_token(refresh_token)["access_token"] + decoded_token = self._decode_token(access_token) + if not decoded_token: + raise AuthenticationError("Access token failed to be decoded") + exp = datetime.fromtimestamp(decoded_token["exp"]) + ttl = int(exp.timestamp() - datetime.now().timestamp()) + await self.cache.set(cache_key, access_token, ttl=ttl) + # set user scopes + realm_access = decoded_token.get("realm_access", {}) + user_scopes = realm_access.get("roles", []) + resource_access = decoded_token.get("resource_access", {}) + client_resource_access = resource_access.get(self.client_id, {}) + user_scopes += client_resource_access.get("roles", []) + return AuthCredentials(scopes=user_scopes), SimpleUser( + decoded_token["preferred_username"] + ) diff --git a/swh/auth/tests/starlette/__init__.py b/swh/auth/tests/starlette/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/tests/starlette/test_backend.py b/swh/auth/tests/starlette/test_backend.py new file mode 100644 index 0000000..e92e808 --- /dev/null +++ b/swh/auth/tests/starlette/test_backend.py @@ -0,0 +1,82 @@ +# Copyright (C) 2023 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from aiocache import Cache +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from swh.auth.starlette import backends +from swh.auth.tests.sample_data import USER_INFO + + +@pytest.fixture +def app_with_auth_backend(keycloak_oidc): + backend = backends.BearerTokenAuthBackend( + server_url="https://example.com", + realm_name="example", + client_id="example", + cache=Cache(), # Dummy cache + ) + backend.oidc_client = keycloak_oidc + middleware = [ + Middleware( + AuthenticationMiddleware, + backend=backend, + ) + ] + + def homepage(request): + if request.user.is_authenticated: + return PlainTextResponse("Hello " + request.user.username) + return PlainTextResponse("Hello") + + app = Starlette(routes=[Route("/", homepage)], middleware=middleware) + return app + + +@pytest.fixture +def client(app_with_auth_backend): + return TestClient(app_with_auth_backend) + + +def test_anonymous_access(client): + response = client.get("/") + assert response.status_code == 200 + assert response.text == "Hello" + + +def test_invalid_auth_header(client): + client.headers = {"Authorization": "invalid"} + response = client.get("/") + assert response.status_code == 400 + assert response.text == "Invalid auth header" + + +def test_invalid_auth_type(client): + client.headers = {"Authorization": "Basic invalid"} + response = client.get("/") + assert response.status_code == 400 + assert response.text == "Invalid or unsupported authorization type" + + +def test_invalid_refresh_token(client, keycloak_oidc): + keycloak_oidc.set_auth_success(False) + client.headers = {"Authorization": "Bearer invalid-valid-token"} + response = client.get("/") + assert response.status_code == 400 + assert "Invalid or expired user token" in response.text + + +def test_success_token(client, keycloak_oidc): + client.headers = {"Authorization": "Bearer valid-token"} + response = client.get("/") + + assert response.status_code == 200 + assert response.text == f'Hello {USER_INFO["preferred_username"]}' diff --git a/tox.ini b/tox.ini index 9ef987f..cc87906 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist=black,flake8,mypy,py3 extras = testing django + starlette deps = pytest-cov dev: pdbpp @@ -47,6 +48,7 @@ usedevelop = true extras = django testing + starlette deps = # fetch and install swh-docs in develop mode -e git+https://forge.softwareheritage.org/source/swh-docs#egg=swh.docs @@ -67,6 +69,7 @@ usedevelop = true extras = django testing + starlette deps = # install swh-docs in develop mode -e ../swh-docs -- GitLab