diff --git a/asab/config.py b/asab/config.py index 332de054..94d9c7f4 100644 --- a/asab/config.py +++ b/asab/config.py @@ -125,7 +125,7 @@ class ConfigParser(configparser.ConfigParser): "auth": { # URL location containing the authorization server's public JWK keys # (often found at "/.well-known/jwks.json") - "public_keys_url": "", + "public_keys_url": "http://localhost:8900/.well-known/jwks.json", # The "enabled" option switches authentication and authorization # on, off or activates mock mode. The default value is True (on). diff --git a/asab/web/auth/providers/key_provider.py b/asab/web/auth/providers/key_provider.py index 3af56a40..ce4246fd 100644 --- a/asab/web/auth/providers/key_provider.py +++ b/asab/web/auth/providers/key_provider.py @@ -33,7 +33,7 @@ def __init__(self, app, auth_provider, public_key: jwcrypto.jwk.JWK | jwcrypto.j self.PublicKeySet = public_key else: raise ValueError("Invalid public_key type.") - + self._set_ready(True) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index c529800b..ef2d8184 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -24,7 +24,6 @@ from ...abc.service import Service from ...config import Config from ...exceptions import NotAuthenticatedError -from ...api.discovery import NotDiscoveredError from ...utils import string_to_boolean from ...contextvars import Tenant, Authz @@ -36,101 +35,64 @@ # -# Used for mock authorization -MOCK_USERINFO_DEFAULT = { - # Token issuer - "iss": "auth.test.loc", - # Token issued at (timestamp) - "iat": int(time.time()), - # Token expires at (timestamp) - "exp": int(time.time()) + 5 * 365 * 24 * 3600, - # Authorized party - "azp": "my-asab-app", - # Audience - "aud": "my-asab-app", - # Subject (Unique user ID) - "sub": "abc:xyz:799b53e0", - # Subject's preferred username - "preferred_username": "little-capybara", - # Subject's email - "email": "capybara1999@example.com", - # Authorized tenants and resources - "resources": { - # Globally authorized resources - "*": [ - "authz:superuser", - ], - # Resources authorized within the tenant "default" - "default": [ - "authz:superuser", - "some-test-data:access", - ], - }, - # List of tenants that the user is a member of. - # These tenants are NOT AUTHORIZED! - "tenants": ["default", "test-tenant", "another-tenant"] -} - class AuthService(Service): """ Provides authentication and authorization of incoming requests. """ - _PUBLIC_KEYS_URL_DEFAULT = "http://localhost:3081/.well-known/jwks.json" - def __init__(self, app, service_name="asab.AuthService"): super().__init__(app, service_name) - # To enable Service Discovery, initialize Api Service and call its initialize_zookeeper() method before AuthService initialization + if jwcrypto is None: + raise ModuleNotFoundError( + "You are trying to use asab.web.auth module without 'jwcrypto' installed. " + "Please run 'pip install jwcrypto' " + "or install asab with 'authz' optional dependency." + ) + self.DiscoveryService = self.App.get_service("asab.DiscoveryService") + self.Providers: list = [] + self._set_up_providers() + + # Try to auto-install authorization middleware + self._try_auto_install() + - self.PublicKeysUrl = Config.get("auth", "public_keys_url") or None - self.IntrospectionUrl = None - self.MockUserInfo = None - self.MockMode = False + def register_provider(self, provider_class, **kwargs): + provider = provider_class(self.App, self, **kwargs) + self.Providers.append(provider) + return provider - # Configure mock mode + + def _set_up_providers(self): enabled = Config.get("auth", "enabled", fallback=True) + public_keys_url = Config.get("auth", "public_keys_url") or None if enabled == "mock": - self.MockMode = True + introspection_url = Config.get("auth", "introspection_url", fallback=None) + if introspection_url: + Config.get("auth", "public_keys_url") or None + self.register_provider( + AccessTokenAuthProvider, + introspection_url=introspection_url + ).add_jwks_url(public_keys_url) + else: + self.register_provider( + MockAuthProvider, + auth_claims_path=Config.get("auth", "mock_user_info_path") + ) + return + elif string_to_boolean(enabled) is True: - self.MockMode = False + self.register_provider(IdTokenAuthProvider).add_jwks_url(public_keys_url) + return + else: raise ValueError( "Disabling AuthService is deprecated. " "For development pupropses use mock mode instead ([auth] enabled=mock)." ) - # Set up auth server keys URL - if self.MockMode is True: - self._prepare_mock_mode() - elif jwcrypto is None: - raise ModuleNotFoundError( - "You are trying to use asab.web.auth module without 'jwcrypto' installed. " - "Please run 'pip install jwcrypto' " - "or install asab with 'authz' optional dependency.") - elif not self.PublicKeysUrl and self.DiscoveryService is None: - self.PublicKeysUrl = self._PUBLIC_KEYS_URL_DEFAULT - L.warning( - "No 'public_keys_url' provided in [auth] config section. " - "Defaulting to {!r}.".format(self._PUBLIC_KEYS_URL_DEFAULT) - ) - - self.TrustedPublicKeys: jwcrypto.jwk.JWKSet = jwcrypto.jwk.JWKSet() - # Limit the frequency of auth server requests to save network traffic - self.AuthServerCheckCooldown = datetime.timedelta(minutes=5) - self.AuthServerLastSuccessfulCheck = None - - if self.PublicKeysUrl: - self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) - - self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} - self.App.PubSub.subscribe("Application.housekeeping!", self._delete_invalid_authorizations) - - # Try to auto-install authorization middleware - self._try_auto_install() - def get_authorized_tenant(self, request=None) -> typing.Optional[str]: """ @@ -243,171 +205,6 @@ def is_ready(self): return True - async def build_authorization(self, id_token: str) -> Authorization: - """ - Build authorization from ID token string. - - :param id_token: Base64-encoded JWToken from Authorization header - :return: Valid asab.web.auth.Authorization object - """ - # Try if the object already exists - authz = self.Authorizations.get(id_token) - if authz is not None: - try: - authz.require_valid() - except NotAuthenticatedError as e: - del self.Authorizations[id_token] - raise e - return authz - - # Create a new Authorization object and store it - if id_token == "MOCK" and self.MockMode is True: - authz = Authorization(self.MockUserInfo) - else: - userinfo = await self._get_userinfo_from_id_token(id_token) - authz = Authorization(userinfo) - - self.Authorizations[id_token] = authz - return authz - - - async def _delete_invalid_authorizations(self, message_name): - """ - Check for expired Authorization objects and delete them - """ - # Find expired - expired = [] - for key, authz in self.Authorizations.items(): - if not authz.is_valid(): - expired.append(key) - - # Delete expired - for key in expired: - del self.Authorizations[key] - - - async def get_bearer_token_from_authorization_header(self, request): - """ - Validate the Authorizetion header and extract the Bearer token value - """ - if self.MockMode is True: - if not self.IntrospectionUrl: - return "MOCK" - - # Send the request headers for introspection - async with aiohttp.ClientSession() as session: - async with session.post(self.IntrospectionUrl, headers=request.headers) as response: - if response.status != 200: - L.warning("Access token introspection failed.") - raise aiohttp.web.HTTPUnauthorized() - authorization_header = response.headers.get(aiohttp.hdrs.AUTHORIZATION) - - else: - authorization_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION) - if authorization_header is None: - L.warning("No Authorization header.") - raise aiohttp.web.HTTPUnauthorized() - try: - auth_type, token_value = authorization_header.split(" ", 1) - except ValueError: - L.warning("Cannot parse Authorization header.") - raise aiohttp.web.HTTPBadRequest() - if auth_type != "Bearer": - L.warning("Unsupported Authorization header type: {!r}".format(auth_type)) - raise aiohttp.web.HTTPUnauthorized() - return token_value - - - async def _fetch_public_keys_if_needed(self, *args, **kwargs): - """ - Check if public keys have been fetched from the authorization server and fetch them if not yet. - """ - # TODO: Refactor into Key Providers - # Add internal shared auth key - if self.DiscoveryService is not None: - if self.DiscoveryService.InternalAuthKey is not None: - self.TrustedPublicKeys.add(self.DiscoveryService.InternalAuthKey.public()) - else: - L.debug("Internal auth key is not ready yet.") - self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) - - if not self.PublicKeysUrl: - # Only internal authorization is supported - return - - # Either DiscoveryService or PublicKeysUrl must be defined - assert self.PublicKeysUrl is not None - - now = datetime.datetime.now(datetime.timezone.utc) - if self.AuthServerLastSuccessfulCheck is not None \ - and now < self.AuthServerLastSuccessfulCheck + self.AuthServerCheckCooldown: - # Public keys have been fetched recently - return - - - async def fetch_keys(session): - try: - async with session.get(self.PublicKeysUrl) as response: - if response.status != 200: - L.error("HTTP error while loading public keys.", struct_data={ - "status": response.status, - "url": self.PublicKeysUrl, - "text": await response.text(), - }) - return - try: - data = await response.json() - except json.JSONDecodeError: - L.error("JSON decoding error while loading public keys.", struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - try: - key_data = data["keys"].pop() - except (IndexError, KeyError): - L.error("Error while loading public keys: No public keys in server response.", struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - try: - public_key = jwcrypto.jwk.JWK(**key_data) - except Exception as e: - L.error("JWK decoding error while loading public keys: {}.".format(e), struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - except aiohttp.client_exceptions.ClientConnectorError as e: - L.error("Connection error while loading public keys: {}".format(e), struct_data={ - "url": self.PublicKeysUrl, - }) - return - return public_key - - if self.DiscoveryService is None: - async with aiohttp.ClientSession() as session: - public_key = await fetch_keys(session) - - else: - async with self.DiscoveryService.session() as session: - try: - public_key = await fetch_keys(session) - except NotDiscoveredError as e: - L.error("Service Discovery error while loading public keys: {}".format(e), struct_data={ - "url": self.PublicKeysUrl, - }) - return - - if public_key is None: - return - - self.TrustedPublicKeys.add(public_key) - self.AuthServerLastSuccessfulCheck = datetime.datetime.now(datetime.timezone.utc) - L.debug("Public key loaded.", struct_data={"url": self.PublicKeysUrl}) - - def _authorize_request(self, handler): """ Authenticate the request by the JWT ID token in the Authorization header. @@ -417,8 +214,17 @@ def _authorize_request(self, handler): async def _authorize_request_wrapper(*args, **kwargs): request = args[-1] - bearer_token = await self.get_bearer_token_from_authorization_header(request) - authz = await self.build_authorization(bearer_token) + # Authenticate and authorize request with first valid provider + for provider in self.Providers: + try: + authz = await provider.authorize(request) + break + except NotAuthenticatedError as e: + L.debug("Authorization failed.", struct_data={"provider": provider.__class__.__name__}) + continue + else: + L.warning("Cannot authenticate request: No valid authorization provider found.") + raise aiohttp.web.HTTPUnauthorized() # Authorize tenant context tenant = Tenant.get(None) @@ -499,31 +305,6 @@ def _set_handler_auth(self, route: aiohttp.web.AbstractRoute): route._handler = handler - async def _get_userinfo_from_id_token(self, bearer_token): - """ - Parse the bearer ID token and extract user info. - """ - if not self.is_ready(): - # Try to load the public keys again - if not self.TrustedPublicKeys["keys"]: - await self._fetch_public_keys_if_needed() - if not self.is_ready(): - L.error("Cannot authenticate request: Failed to load authorization server's public keys.") - raise aiohttp.web.HTTPUnauthorized() - - try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey): - # Authz server keys may have changed. Try to reload them. - await self._fetch_public_keys_if_needed() - - try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey) as e: - L.error("Cannot authenticate request: {}".format(str(e))) - raise NotAuthenticatedError() - - def _validate_wrapper_installation(self): """ Check if there is at least one web container with authorization installed @@ -580,63 +361,6 @@ def _try_auto_install(self): L.debug("WebContainer authorization wrapper will be installed automatically.") -def _get_id_token_claims(bearer_token: str, auth_server_public_key): - """ - Parse and validate JWT ID token and extract the claims (user info) - """ - assert jwcrypto is not None - try: - token = jwcrypto.jwt.JWT(jwt=bearer_token, key=auth_server_public_key) - except jwcrypto.jwt.JWTExpired: - L.warning("ID token expired.") - raise NotAuthenticatedError() - except jwcrypto.jwt.JWTMissingKey as e: - raise e - except jwcrypto.jws.InvalidJWSSignature as e: - raise e - except ValueError as e: - L.error( - "Failed to parse JWT ID token ({}). Please check if the Authorization header contains ID token.".format(e)) - raise aiohttp.web.HTTPBadRequest() - except jwcrypto.jws.InvalidJWSObject as e: - L.error( - "Failed to parse JWT ID token ({}). Please check if the Authorization header contains ID token.".format(e)) - raise aiohttp.web.HTTPBadRequest() - except Exception: - L.exception("Failed to parse JWT ID token. Please check if the Authorization header contains ID token.") - raise aiohttp.web.HTTPBadRequest() - - try: - token_claims = json.loads(token.claims) - except Exception: - L.exception("Failed to parse JWT token claims.") - raise aiohttp.web.HTTPBadRequest() - - return token_claims - - -def _get_id_token_claims_without_verification(bearer_token: str): - """ - Parse JWT ID token without validation and extract the claims (user info) - """ - try: - header, payload, signature = bearer_token.split(".") - except IndexError: - L.warning("Cannot parse ID token: Wrong number of '.'.") - raise aiohttp.web.HTTPBadRequest() - - try: - claims = json.loads(base64.b64decode(payload.encode("utf-8"))) - except binascii.Error: - L.warning("Cannot parse ID token: Payload is not base 64.") - raise aiohttp.web.HTTPBadRequest() - except json.JSONDecodeError: - L.warning("Cannot parse ID token: Payload cannot be parsed as JSON.") - raise aiohttp.web.HTTPBadRequest() - - return claims - - def _pass_user_info(handler): """ Add user info to the handler arguments