From 003c3385f07535224566c2a2213e09d502e287ab Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 21:56:45 +0000 Subject: [PATCH 01/21] openid auth module --- pyaviso/authentication/openid_auth.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 pyaviso/authentication/openid_auth.py diff --git a/pyaviso/authentication/openid_auth.py b/pyaviso/authentication/openid_auth.py new file mode 100644 index 0000000..0c0e779 --- /dev/null +++ b/pyaviso/authentication/openid_auth.py @@ -0,0 +1,23 @@ +# (C) Copyright 1996- ECMWF. +# +# This software is licensed under the terms of the Apache Licence Version 2.0 +# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. +# In applying this licence, ECMWF does not waive the privileges and immunities +# granted to it by virtue of its status as an intergovernmental organisation +# nor does it submit to any jurisdiction. + +class OpenidAuth: + """ + OpenidAuth implements an OpenID authentication flow. + + It returns a Bearer header (using the shared secret from config.password) and adds + an extra header "X-Auth-Type" with the value "openid". + """ + def __init__(self, config): + self.config = config + + def header(self): + return { + "Authorization": f"Bearer {self.config.password}", + "X-Auth-Type": "openid" + } \ No newline at end of file From 817d76fc8caa82cd30b08f4fc6fbd10f581d80e6 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 21:57:34 +0000 Subject: [PATCH 02/21] plain auth support --- pyaviso/authentication/plain_auth.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 pyaviso/authentication/plain_auth.py diff --git a/pyaviso/authentication/plain_auth.py b/pyaviso/authentication/plain_auth.py new file mode 100644 index 0000000..4c357d4 --- /dev/null +++ b/pyaviso/authentication/plain_auth.py @@ -0,0 +1,24 @@ +# (C) Copyright 1996- ECMWF. +# +# This software is licensed under the terms of the Apache Licence Version 2.0 +# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. +# In applying this licence, ECMWF does not waive the privileges and immunities +# granted to it by virtue of its status as an intergovernmental organisation +# nor does it submit to any jurisdiction. + +import base64 + +class PlainAuth: + """ + PlainAuth implements Basic authentication. + """ + def __init__(self, config): + self.config = config + + def header(self): + credentials = f"{self.config.username}:{self.config.password}" + encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + return { + "Authorization": f"Basic {encoded}", + "X-Auth-Type": "plain" + } From e2fe02bd568b45fe18954a06fd1488c7e57603cc Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 21:58:23 +0000 Subject: [PATCH 03/21] enum update for new auth modules --- pyaviso/authentication/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyaviso/authentication/__init__.py b/pyaviso/authentication/__init__.py index 17f75ab..172ed8e 100644 --- a/pyaviso/authentication/__init__.py +++ b/pyaviso/authentication/__init__.py @@ -16,6 +16,8 @@ class AuthType(Enum): """ ECMWF = ("ecmwf_auth", "EcmwfAuth") + OPENID = ("openid_auth", "OpenidAuth") + PLAIN = ("plain_auth", "PlainAuth") ETCD = ("etcd_auth", "EtcdAuth") NONE = ("none_auth", "NoneAuth") From 8804a1537e5005d960a471616592bf277f45c202 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 21:59:04 +0000 Subject: [PATCH 04/21] ecmwf-api now uses bearer instead of email key --- pyaviso/authentication/ecmwf_auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyaviso/authentication/ecmwf_auth.py b/pyaviso/authentication/ecmwf_auth.py index 650073e..c1616e7 100644 --- a/pyaviso/authentication/ecmwf_auth.py +++ b/pyaviso/authentication/ecmwf_auth.py @@ -21,5 +21,4 @@ def __init__(self, config: UserConfig): self._username = config.username def header(self): - header = {"Authorization": f"EmailKey {self.username}:{self.password}"} - return header + return {"Authorization": f"Bearer {self._password}", "X-Auth-Type": "ecmwf"} From aaaecf8fbc5a367ff2b27dc29e79be00b081608e Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:00:11 +0000 Subject: [PATCH 05/21] update for direct run --- pyaviso/cli_aviso.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyaviso/cli_aviso.py b/pyaviso/cli_aviso.py index 825d641..c176457 100644 --- a/pyaviso/cli_aviso.py +++ b/pyaviso/cli_aviso.py @@ -352,7 +352,7 @@ def notify(parameters: str, configuration: conf.UserConfig): cli.add_command(notify) if __name__ == "__main__": - listen() + cli() def _parse_inline_params(params: str) -> Dict[str, any]: From 7978c016eb60201e73e5892a9ca4fd1083fbbfab Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:01:46 +0000 Subject: [PATCH 06/21] new authentication implementation that uses auth-o-tron --- .../auth/aviso_auth/authentication.py | 387 +++++++++++++----- 1 file changed, 275 insertions(+), 112 deletions(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 73369cc..ddb3007 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -1,166 +1,329 @@ -# (C) Copyright 1996- ECMWF. -# -# This software is licensed under the terms of the Apache Licence Version 2.0 -# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. -# In applying this licence, ECMWF does not waive the privileges and immunities -# granted to it by virtue of its status as an intergovernmental organisation -# nor does it submit to any jurisdiction. +# aviso_auth/authentication.py +import logging import random import time - import requests -from aviso_monitoring.collector.time_collector import TimeCollector -from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType +import base64 +import jwt -from . import logger -from .custom_exceptions import ( - AuthenticationUnavailableException, - InternalSystemError, +from aviso_auth.custom_exceptions import ( TokenNotValidException, + InternalSystemError, ) +from aviso_monitoring.collector.time_collector import TimeCollector +from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType +logger = logging.getLogger("aviso-auth") MAX_N_TRIES = 25 - class Authenticator: UNAUTHORISED_RESPONSE_HEADER = { - "WWW-Authenticate": "EmailKey realm='ecmwf',info='Authenticate with ECMWF API credentials :'" + "WWW-Authenticate": "Bearer realm='auth-o-tron',info='Provide a valid token'" } def __init__(self, config, cache=None): - self.url = config.authentication_server["url"] - self.req_timeout = config.authentication_server["req_timeout"] + """ + Initialize the Authenticator. + Loads authentication server settings from the configuration. + Uses Flask-Caching if 'cache' is provided to memoize token validation. + If monitoring is enabled (authentication_server["monitor"] = True), + wraps the authentication method in a TimeCollector. + """ + logger.debug("Initializing Authenticator with config: %s", config) + self.config = config + self.url = config.authentication_server.get("url", "") + self.req_timeout = config.authentication_server.get("req_timeout", 10) + logger.debug("Authentication server URL: %s, timeout: %s", self.url, self.req_timeout) - # assign explicitly a decorator to provide cache for _token_to_username - if cache: - self._token_to_username = cache.memoize(timeout=config.authentication_server["cache_timeout"])( - self._token_to_username_impl - ) - else: - self._token_to_username = self._token_to_username_impl + self._cached_providers = None - # assign explicitly a decorator to monitor the authentication - if config.authentication_server["monitor"]: + self.cache = cache + + if config.authentication_server.get("monitor"): self.timer = TimeCollector( - config.monitoring, tlm_type=AvisoAuthMetricType.auth_resp_time.name, tlm_name="att" + config.monitoring, + tlm_type=AvisoAuthMetricType.auth_resp_time.name, + tlm_name="att" ) + logger.debug("Monitoring enabled for authentication; using timed_authenticate") self.authenticate = self.timed_authenticate else: self.authenticate = self.authenticate_impl + + if self.cache: + logger.debug("Using memoized token validator with cache timeout = %s", + config.authentication_server.get("cache_timeout", 300)) + # Wrap _validate_token_uncached with memoize + self.validate_token_cached = self.cache.memoize( + timeout=config.authentication_server.get("cache_timeout", 300) + )(self._validate_token_uncached) + else: + logger.debug("No cache provided; validation calls are uncached.") + self.validate_token_cached = self._validate_token_uncached + + def timed_authenticate(self, request): """ - This method is an explicit decorator of the authenticate_impl method to provide time performance monitoring + Wraps the authenticate_impl with a time collector. """ - return self.timer(self.authenticate_impl, args=request) + logger.debug("timed_authenticate: Starting timed authentication") + return self.timer(self.authenticate_impl, args=(request,)) + def authenticate_impl(self, request): """ - This method verifies the token in the request header corresponds to a valid user - :param request: - :return: - - the username if token is valid - - TokenNotValidException if the server returns 403 - - InternalSystemError for all the other cases + Main authentication flow. + + Steps: + 1. Extract the Authorization and X-Auth-Type headers. + 2. Map the X-Auth-Type value to the expected provider type. + 3. Ensure a matching provider exists (via get_providers). + 4. Extract the token from the Authorization header. + 5. Validate the token (cached). + 6. Extract user information (username, realm). + 7. Return the username. """ - if request.environ is None or request.environ.get("HTTP_AUTHORIZATION") is None: - logger.debug(f"Authorization header absent {request.environ}") - raise TokenNotValidException("Authorization header not found") + logger.debug("authenticate_impl: Starting authentication process") - # validate the authorization header - auth_header = request.environ.get("HTTP_AUTHORIZATION") - try: - auth_type, credentials = auth_header.split(" ", 1) - auth_email, auth_token = credentials.split(":", 1) - except ValueError: - logger.debug(f"Authorization header not recognised {auth_header}") - raise TokenNotValidException("Could not read authorization header, expected 'Authorization: :'") + # Step 1: Extract headers. + auth_header, x_auth_type = self.extract_auth_headers(request) + + # Step 2: Map incoming auth type. + expected_provider_type = self.map_auth_type(x_auth_type) + + # Step 3: Ensure a matching provider exists. + self.get_matching_provider(expected_provider_type) + + # Step 4: Extract the token from the Authorization header. + token = self.extract_token(auth_header, x_auth_type) - # validate the token - username, email = self._token_to_username(auth_token) + # Optionally, gather client IP for logging: + client_ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown" - # validate the email - if auth_email.casefold() != email.casefold(): - logger.debug(f"Emails not matching {auth_email.casefold()}, {email.casefold()}") - raise TokenNotValidException("Invalid email associate to the token.") + # Step 5: Validate the token. (NEW: using the memoized version!) + resp = self.validate_token_cached(token, x_auth_type, client_ip=client_ip) - logger.info(f"User {username} correctly authenticated, client IP: {request.headers.get('X-Forwarded-For')}") + # Step 6: Extract user information. + username, realm = self._token_to_username_impl(resp) + + # Step 7: Return the username. + logger.debug("authenticate_impl: Returning username: %s", username) return username - def _token_to_username_impl(self, token): + + def extract_auth_headers(self, request): """ - This method verifies the token corresponds to a valid user. - Access this method by self._token_to_username - :param token: - :return: - - the username and email if token is valid - - InternalSystemError for all the other cases + Extracts the Authorization header and the X-Auth-Type header. """ - logger.debug(f"Request authentication for token {token}") + if not hasattr(request, "environ"): + logger.error("Request missing environ attribute") + raise TokenNotValidException("Invalid request: no environ attribute") + auth_header = request.environ.get("HTTP_AUTHORIZATION") + if not auth_header: + logger.error("Missing Authorization header in request") + raise TokenNotValidException("Missing Authorization header") + logger.debug("Extracted Authorization header: %s", auth_header) - resp = self.wait_for_resp(token) + x_auth_type = request.headers.get("X-Auth-Type") + if not x_auth_type: + logger.error("Missing X-Auth-Type header in request") + raise TokenNotValidException("Missing X-Auth-Type header") + logger.debug("Extracted X-Auth-Type header: %s", x_auth_type) - # just in case requests does not always raise an error - if resp.status_code != 200: - message = ( - f"Not able to authenticate token {token} to {self.url}, status {resp.status_code}, " - f"{resp.reason}, {resp.content.decode()}" - ) - logger.error(message) - raise InternalSystemError(f"Error in authenticating token {token}, please contact the support team") + return auth_header, x_auth_type - # we got a 200, extract the username and email - resp_body = resp.json() - if resp_body.get("uid") is None: - logger.error(f"Not able to find username in: {resp_body}") - raise InternalSystemError(f"Error in authenticating token {token}, please contact the support team") - # get the username - username = resp_body.get("uid") + def map_auth_type(self, x_auth_type): + """ + Maps the X-Auth-Type to the expected provider type. + """ + mapping = { + "ecmwf": "ecmwf-api", + "plain": "plain", + "openid": "openid-offline" + } + expected = mapping.get(x_auth_type.lower()) + if not expected: + logger.error("Unknown X-Auth-Type value: %s", x_auth_type) + raise TokenNotValidException(f"Unknown auth type: {x_auth_type}") + logger.debug("Mapped X-Auth-Type '%s' to expected provider type '%s'", x_auth_type, expected) + return expected - if resp_body.get("email") is None: - logger.error(f"Not able to find email in: {resp_body}") - raise InternalSystemError(f"Error in authenticating token {token}, please contact the support team") - email = resp_body.get("email") + def get_providers(self): + """ + Retrieves providers from auth-o-tron /providers, caches them locally. + """ + if self._cached_providers is not None: + logger.debug("Using cached providers data: %s", self._cached_providers) + return self._cached_providers + + providers_url = f"{self.url}/providers" + logger.debug("Querying providers from auth-o-tron at: %s", providers_url) + try: + resp = requests.get(providers_url, timeout=self.req_timeout) + logger.debug("Received providers response, status: %s", resp.status_code) + resp.raise_for_status() + providers_data = resp.json() + logger.debug("Providers data: %s", providers_data) + self._cached_providers = providers_data + return providers_data + except Exception as e: + logger.error("Error querying providers endpoint: %s", e, exc_info=True) + raise InternalSystemError("Failed to retrieve providers from auth-o-tron") - logger.debug(f"Token correctly validated for user {username}, email {email}") - return username, email + def get_matching_provider(self, expected_provider_type): + """ + Check if the retrieved providers contain the given expected_provider_type. + """ + providers = self.get_providers() + for provider in providers.get("providers", []): + p_type = provider.get("type", "").lower() + logger.debug("Checking provider: %s", provider) + if p_type == expected_provider_type: + logger.debug("Matched provider: %s", provider) + return provider + logger.error("No provider found for expected auth type: %s", expected_provider_type) + raise TokenNotValidException(f"No provider available for auth type matching '{expected_provider_type}'") - def wait_for_resp(self, token): + def extract_token(self, auth_header, x_auth_type): """ - This methods helps in cases of 429, too many requests at the same time, by spacing in time the requests - :param token: - :return: response to token validation - - TokenNotValidException if the server returns 403 - - AuthenticationUnavailableException if unreachable + Splits the Authorization header and ensures the correct scheme based on x_auth_type. """ + try: + scheme, token = auth_header.split(" ", 1) + except Exception as e: + logger.error("Failed to parse Authorization header: %s", e, exc_info=True) + raise TokenNotValidException("Invalid Authorization header format") + + expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" + if scheme.lower() != expected_scheme: + logger.error("Expected '%s' scheme, got: %s", expected_scheme.capitalize(), scheme) + raise TokenNotValidException("Unsupported authorization scheme") + logger.debug("extract_token: Extracted token: %s", token) + return token + + + def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): + """ + The real token validation logic that calls auth-o-tron /authenticate. + This method is NOT decorated. The memoized version wraps it if caching is set. + """ + if x_auth_type.lower() in ["ecmwf", "openid"]: + auth_header = f"Bearer {token}" + elif x_auth_type.lower() == "plain": + auth_header = f"Basic {token}" + else: + logger.warning("validate_token: Unknown auth type: %s", x_auth_type) + raise TokenNotValidException(f"Unknown auth type: {x_auth_type}") + + headers = {"Authorization": auth_header} + auth_url = f"{self.url}/authenticate" + logger.debug( + "Calling auth-o-tron /authenticate at %s [auth_type=%s, client_ip=%s]", + auth_url, x_auth_type, client_ip + ) + n_tries = 0 while n_tries < MAX_N_TRIES: + logger.debug("validate_token: Attempt %d [auth_type=%s, ip=%s]", n_tries + 1, x_auth_type, client_ip) try: - resp = requests.get(self.url, headers={"X-ECMWF-Key": token}, timeout=self.req_timeout) - if resp.status_code == 429: # Too many request just retry in a bit + resp = requests.get(auth_url, headers=headers, timeout=self.req_timeout) + logger.debug("validate_token: Received response with status %d", resp.status_code) + if resp.status_code == 429: + logger.debug("validate_token: Rate limited (429), sleeping") time.sleep(random.uniform(1, 5)) n_tries += 1 + continue + + resp.raise_for_status() # 4xx or 5xx => HTTPError + logger.debug("validate_token: Token validated successfully [auth_type=%s, ip=%s]", x_auth_type, client_ip) + return resp + + except requests.exceptions.HTTPError as err: + status_code = resp.status_code + if status_code in [401, 403]: + logger.warning( + "validate_token: %d Unauthorized/forbidden [auth_type=%s, ip=%s, reason=%.200s]", + status_code, x_auth_type, client_ip, resp.text + ) + raise TokenNotValidException("Invalid credentials or unauthorized token") + if status_code == 408 or (500 <= status_code < 600): + logger.warning( + "validate_token: Temporary HTTP error %d [auth_type=%s, ip=%s], reason=%s, retrying", + status_code, x_auth_type, client_ip, resp.reason + ) + n_tries += 1 + time.sleep(random.uniform(1, 5)) else: - # raise an error for any other case - resp.raise_for_status() - # or just exit as we have a good result - break - except requests.exceptions.HTTPError as errh: - message = f"Not able to authenticate token {token} from {self.url}, {str(errh)}" - if resp.status_code == 403: - logger.debug(message) - raise TokenNotValidException(f"Token {token} not valid") - if resp.status_code == 408 or (resp.status_code >= 500 and resp.status_code < 600): - logger.warning(message) - raise AuthenticationUnavailableException(f"Error in authenticating token {token}") - else: - logger.error(message) - raise InternalSystemError(f"Error in authenticating token {token}, please contact the support team") + logger.error( + "validate_token: Unexpected HTTP error %d [auth_type=%s, ip=%s], reason=%s", + status_code, x_auth_type, client_ip, resp.reason + ) + raise InternalSystemError("Unexpected HTTP error during token validation") + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as err: - logger.warning(f"Not able to authenticate token {token}, {str(err)}") - raise AuthenticationUnavailableException(f"Error in authenticating token {token}") + logger.warning( + "validate_token: Connection/Timeout error on attempt %d [auth_type=%s, ip=%s]: %s", + n_tries + 1, x_auth_type, client_ip, err + ) + n_tries += 1 + time.sleep(random.uniform(1, 5)) + except Exception as e: - logger.exception(e) - raise InternalSystemError(f"Error in authenticating token {token}, please contact the support team") - return resp + logger.error( + "validate_token: Unexpected error on attempt %d [auth_type=%s, ip=%s]: %s", + n_tries + 1, x_auth_type, client_ip, e + ) + raise InternalSystemError("Unexpected error during token validation") + + logger.error("validate_token: Exceeded maximum attempts (%d) [auth_type=%s, ip=%s]", + MAX_N_TRIES, x_auth_type, client_ip) + raise InternalSystemError("Exceeded maximum token validation attempts") + + + def validate_token(self, token, x_auth_type, client_ip="unknown"): + """ + Public wrapper that calls the memoized validation function (if caching is enabled) + or the uncached version if no cache. This function name is the one + used in authenticate_impl for consistency. + """ + return self.validate_token_cached(token, x_auth_type, client_ip=client_ip) + + def _token_to_username_impl(self, resp): + """ + Extracts user info from the auth-o-tron /authenticate response. + Decodes a JWT from the response header "authorization". + Logs an INFO message for successful authentication. + """ + auth_header = resp.headers.get("authorization") + if not auth_header: + logger.error("auth-o-tron response missing 'authorization' header") + raise InternalSystemError("Invalid response from auth-o-tron: missing authorization header") + + parts = auth_header.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + logger.error("Invalid authorization header format: %s", auth_header) + raise InternalSystemError("Invalid response from auth-o-tron: incorrect authorization header format") + + jwt_token = parts[1].strip() + if not jwt_token: + logger.error("JWT token is empty in authorization header") + raise InternalSystemError("Invalid response from auth-o-tron: empty JWT token") + + logger.debug("Extracted JWT token from response header: %s", jwt_token) + try: + payload = jwt.decode(jwt_token, options={"verify_signature": False}) + logger.debug("Decoded JWT payload: %s", payload) + except Exception as e: + logger.error("Failed to decode JWT token. Raw token: '%s'. Error: %s", jwt_token, e) + raise InternalSystemError("Invalid JWT returned from auth-o-tron") + + username = payload.get("username") + if not username: + logger.error("JWT payload missing 'username': %s", payload) + raise InternalSystemError("Token validation error: username missing") + + realm = payload.get("realm", "unknown") + logger.info("User '%s' successfully authenticated with realm '%s'", username, realm) + return username, realm \ No newline at end of file From b7e983b3788111b74787e1ddf4892642dab67ad7 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:02:30 +0000 Subject: [PATCH 07/21] default value updates --- aviso-server/auth/aviso_auth/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aviso-server/auth/aviso_auth/config.py b/aviso-server/auth/aviso_auth/config.py index 3f8ed6f..c362b04 100644 --- a/aviso-server/auth/aviso_auth/config.py +++ b/aviso-server/auth/aviso_auth/config.py @@ -86,14 +86,14 @@ def __init__( def _create_default_config() -> Dict[str, any]: # authentication_server authentication_server = {} - authentication_server["url"] = "https://api.ecmwf.int/v1/who-am-i" + authentication_server["url"] = "http://0.0.0.0:8080" authentication_server["req_timeout"] = 60 # seconds authentication_server["cache_timeout"] = 86400 # 1 day in seconds authentication_server["monitor"] = False # authorisation_server authorisation_server = {} - authorisation_server["url"] = "https://127.0..0.1:8080" + authorisation_server["url"] = "http://127.0.0.1:8080" authorisation_server["req_timeout"] = 60 # seconds authorisation_server["cache_timeout"] = 86400 # 1 day in seconds authorisation_server["open_keys"] = ["/ec/mars", "/ec/config/aviso"] From 4b482f3bef5635f9a72171e46253d9f0a6d0e0c3 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:03:44 +0000 Subject: [PATCH 08/21] minor refactor --- aviso-server/auth/aviso_auth/frontend.py | 118 +++++++++++++---------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/aviso-server/auth/aviso_auth/frontend.py b/aviso-server/auth/aviso_auth/frontend.py index 3657bec..ef180f4 100644 --- a/aviso-server/auth/aviso_auth/frontend.py +++ b/aviso-server/auth/aviso_auth/frontend.py @@ -7,7 +7,6 @@ # nor does it submit to any jurisdiction. import json -import logging import aviso_auth.custom_exceptions as custom import gunicorn.app.base @@ -17,13 +16,14 @@ from aviso_auth.backend_adapter import BackendAdapter from aviso_auth.config import Config from aviso_monitoring import __version__ as monitoring_version + + from aviso_monitoring.collector.count_collector import UniqueCountCollector from aviso_monitoring.collector.time_collector import TimeCollector from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType + from flask import Flask, Response, render_template, request from flask_caching import Cache -from gunicorn import glogging -from six import iteritems class Frontend: @@ -31,30 +31,42 @@ def __init__(self, config: Config): self.config = config self.handler = self.create_handler() self.handler.cache = Cache(self.handler, config=config.cache) - # we need to initialise our components and timer here if this app runs in Flask, - # if instead it runs in Gunicorn the hook post_worker_init will take over, and these components will not be used + + # For direct runs (e.g. Flask "server_type"): + # We'll initialize the app-level components now. self.init_components() def init_components(self): """ - This method initialise a set of components and timers that are valid globally at application level or per worker + Initializes the Authenticator, Authoriser, BackendAdapter, + and sets up time-collectors or counters as needed. """ + # Create the authenticator (with caching if provided) self.authenticator = Authenticator(self.config, self.handler.cache) self.authoriser = Authoriser(self.config, self.handler.cache) self.backend = BackendAdapter(self.config) - # this is a time collector for the whole request + + # A time collector for measuring entire request durations (via timed_process_request()). self.timer = TimeCollector(self.config.monitoring, tlm_type=AvisoAuthMetricType.auth_resp_time.name) + + # A UniqueCountCollector for counting user accesses. This is used in process_request(). self.user_counter = UniqueCountCollector( self.config.monitoring, tlm_type=AvisoAuthMetricType.auth_users_counter.name ) + logger.debug("All components initialized: Authenticator, Authoriser, BackendAdapter, timers, counters") + def create_handler(self) -> Flask: handler = Flask(__name__) handler.title = "aviso-auth" - # We need to bind the logger of aviso to the one of app + + # Bind aviso_auth logger to the Flask app logger. logger.handlers = handler.logger.handlers def json_response(m, code, header=None): + """ + Utility for building JSON response. + """ h = {"Content-Type": "application/json"} if header: h.update(header) @@ -67,7 +79,12 @@ def invalid_input(e): @handler.errorhandler(custom.TokenNotValidException) def token_not_valid(e): + """ + Return a 401 and attach the + "WWW-Authenticate" header from Authenticator. + """ logger.debug(f"Authentication failed: {e}") + return json_response(e, 401, self.authenticator.UNAUTHORISED_RESPONSE_HEADER) @handler.errorhandler(custom.ForbiddenDestinationException) @@ -106,52 +123,66 @@ def default_error_handler(e): @handler.route("/", methods=["GET"]) def index(): + """ + Simple index route that renders an index.html template + (if shipping a front-end). + Otherwise, can return a basic message. + """ return render_template("index.html") @handler.route(self.config.backend["route"], methods=["POST"]) def root(): - logger.info(f"New request received from {request.headers.get('X-Forwarded-For')}, content: {request.data}") - + """ + The main route for your proxying or backend forwarding logic. + """ + logger.info( + f"New request received from {request.headers.get('X-Forwarded-For')}, " f"content: {request.data}" + ) resp_content = timed_process_request() - - # forward back the response return Response(resp_content) def process_request(): - # authenticate request and count the users + """ + The main request processing flow: + 1. Authenticate + 2. Authorise + 3. Forward to backend + """ + # (1) Authenticate request and increment user counter username = self.user_counter(self.authenticator.authenticate, args=request) logger.debug("Request successfully authenticated") - # authorise request + # (2) Authorise request valid = self.authoriser.is_authorised(username, request) if not valid: - raise custom.ForbiddenDestinationException("User not allowed to access to the resource") + raise custom.ForbiddenDestinationException("User not allowed to access the resource") logger.debug("Request successfully authorised") - # forward request to backend + # (3) Forward request to backend resp_content = self.backend.forward(request) logger.info("Request completed") - return resp_content def timed_process_request(): """ - This method allows time the process_request function + Wraps process_request in a time collector (self.timer). """ return self.timer(process_request) return handler def run_server(self): + """ + Launches the server using either Flask's built-in server or Gunicorn. + """ logger.info( - f"Running aviso-auth - version {__version__} on server {self.config.frontend['server_type']}, \ - aviso_monitoring module v.{monitoring_version}" + f"Running aviso-auth - version {__version__} on server {self.config.frontend['server_type']}, " + f"aviso_monitoring module v.{monitoring_version}" ) logger.info(f"Configuration loaded: {self.config}") if self.config.frontend["server_type"] == "flask": - # flask internal server for non-production environments - # should only be used for testing and debugging + # Not recommended for production, but good for dev/test self.handler.run( debug=self.config.debug, host=self.config.frontend["host"], @@ -171,58 +202,41 @@ def run_server(self): def post_worker_init(self, worker): """ - This method is called just after a worker has initialized the application. - It is a Gunicorn server hook. Gunicorn spawns this app over multiple workers as processes. - This method ensures that there is only one set of components and timer running per worker. Without this hook - the components and timers are created at application level but not at worker level and then at every request a - timers will be created detached from the main transmitter threads. - This would result in no telemetry collected. + Called just after a worker initializes the application in Gunicorn. + Re-initializes any components that need a separate instance per worker process. """ logger.debug("Initialising components per worker") self.init_components() -def main(): - # initialising the user configuration configuration - config = Config() - - # create the frontend class and run it - frontend = Frontend(config) - frontend.run_server() - - class GunicornServer(gunicorn.app.base.BaseApplication): + """ + Gunicorn server wrapper. + """ + def __init__(self, app, options=None): self.options = options or {} self.application = app - super(GunicornServer, self).__init__() + super().__init__() def load_config(self): + from six import iteritems + config = dict( [(key, value) for key, value in iteritems(self.options) if key in self.cfg.settings and value is not None] ) for key, value in iteritems(config): self.cfg.set(key.lower(), value) - # this approach does not support custom filters, therefore it's better to disable it - # self.cfg.set('logger_class', GunicornServer.CustomLogger) - def load(self): return self.application - class CustomLogger(glogging.Logger): - """Custom logger for Gunicorn log messages.""" - - def setup(self, cfg): - """Configure Gunicorn application logging configuration.""" - super().setup(cfg) - formatter = logging.getLogger().handlers[0].formatter - - # Override Gunicorn's `error_log` configuration. - self._set_handler(self.error_log, cfg.errorlog, formatter) +def main(): + config = Config() + frontend = Frontend(config) + frontend.run_server() -# when running directly from this file if __name__ == "__main__": main() From 8a9552c2bbe046c208aa8715e7290ca92630ecc4 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:05:26 +0000 Subject: [PATCH 09/21] black --- aviso-server/auth/aviso_auth/__init__.py | 2 +- .../auth/aviso_auth/authentication.py | 78 ++++++++++--------- pyaviso/authentication/openid_auth.py | 9 +-- pyaviso/authentication/plain_auth.py | 7 +- 4 files changed, 50 insertions(+), 46 deletions(-) diff --git a/aviso-server/auth/aviso_auth/__init__.py b/aviso-server/auth/aviso_auth/__init__.py index e58107a..516a1e4 100644 --- a/aviso-server/auth/aviso_auth/__init__.py +++ b/aviso-server/auth/aviso_auth/__init__.py @@ -10,7 +10,7 @@ # version number for the application. -__version__ = "0.4.0" +__version__ = "0.5.0" # setting application logger logger = logging.getLogger("aviso-auth") diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index ddb3007..1f00a13 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -17,10 +17,9 @@ logger = logging.getLogger("aviso-auth") MAX_N_TRIES = 25 + class Authenticator: - UNAUTHORISED_RESPONSE_HEADER = { - "WWW-Authenticate": "Bearer realm='auth-o-tron',info='Provide a valid token'" - } + UNAUTHORISED_RESPONSE_HEADER = {"WWW-Authenticate": "Bearer realm='auth-o-tron',info='Provide a valid token'"} def __init__(self, config, cache=None): """ @@ -42,19 +41,18 @@ def __init__(self, config, cache=None): if config.authentication_server.get("monitor"): self.timer = TimeCollector( - config.monitoring, - tlm_type=AvisoAuthMetricType.auth_resp_time.name, - tlm_name="att" + config.monitoring, tlm_type=AvisoAuthMetricType.auth_resp_time.name, tlm_name="att" ) logger.debug("Monitoring enabled for authentication; using timed_authenticate") self.authenticate = self.timed_authenticate else: self.authenticate = self.authenticate_impl - if self.cache: - logger.debug("Using memoized token validator with cache timeout = %s", - config.authentication_server.get("cache_timeout", 300)) + logger.debug( + "Using memoized token validator with cache timeout = %s", + config.authentication_server.get("cache_timeout", 300), + ) # Wrap _validate_token_uncached with memoize self.validate_token_cached = self.cache.memoize( timeout=config.authentication_server.get("cache_timeout", 300) @@ -63,7 +61,6 @@ def __init__(self, config, cache=None): logger.debug("No cache provided; validation calls are uncached.") self.validate_token_cached = self._validate_token_uncached - def timed_authenticate(self, request): """ Wraps the authenticate_impl with a time collector. @@ -71,7 +68,6 @@ def timed_authenticate(self, request): logger.debug("timed_authenticate: Starting timed authentication") return self.timer(self.authenticate_impl, args=(request,)) - def authenticate_impl(self, request): """ Main authentication flow. @@ -112,7 +108,6 @@ def authenticate_impl(self, request): logger.debug("authenticate_impl: Returning username: %s", username) return username - def extract_auth_headers(self, request): """ Extracts the Authorization header and the X-Auth-Type header. @@ -138,11 +133,7 @@ def map_auth_type(self, x_auth_type): """ Maps the X-Auth-Type to the expected provider type. """ - mapping = { - "ecmwf": "ecmwf-api", - "plain": "plain", - "openid": "openid-offline" - } + mapping = {"ecmwf": "ecmwf-api", "plain": "plain", "openid": "openid-offline"} expected = mapping.get(x_auth_type.lower()) if not expected: logger.error("Unknown X-Auth-Type value: %s", x_auth_type) @@ -203,7 +194,6 @@ def extract_token(self, auth_header, x_auth_type): logger.debug("extract_token: Extracted token: %s", token) return token - def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): """ The real token validation logic that calls auth-o-tron /authenticate. @@ -220,8 +210,7 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): headers = {"Authorization": auth_header} auth_url = f"{self.url}/authenticate" logger.debug( - "Calling auth-o-tron /authenticate at %s [auth_type=%s, client_ip=%s]", - auth_url, x_auth_type, client_ip + "Calling auth-o-tron /authenticate at %s [auth_type=%s, client_ip=%s]", auth_url, x_auth_type, client_ip ) n_tries = 0 @@ -237,7 +226,9 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): continue resp.raise_for_status() # 4xx or 5xx => HTTPError - logger.debug("validate_token: Token validated successfully [auth_type=%s, ip=%s]", x_auth_type, client_ip) + logger.debug( + "validate_token: Token validated successfully [auth_type=%s, ip=%s]", x_auth_type, client_ip + ) return resp except requests.exceptions.HTTPError as err: @@ -245,27 +236,39 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): if status_code in [401, 403]: logger.warning( "validate_token: %d Unauthorized/forbidden [auth_type=%s, ip=%s, reason=%.200s]", - status_code, x_auth_type, client_ip, resp.text + status_code, + x_auth_type, + client_ip, + resp.text, ) raise TokenNotValidException("Invalid credentials or unauthorized token") if status_code == 408 or (500 <= status_code < 600): logger.warning( "validate_token: Temporary HTTP error %d [auth_type=%s, ip=%s], reason=%s, retrying", - status_code, x_auth_type, client_ip, resp.reason + status_code, + x_auth_type, + client_ip, + resp.reason, ) n_tries += 1 time.sleep(random.uniform(1, 5)) else: logger.error( "validate_token: Unexpected HTTP error %d [auth_type=%s, ip=%s], reason=%s", - status_code, x_auth_type, client_ip, resp.reason + status_code, + x_auth_type, + client_ip, + resp.reason, ) raise InternalSystemError("Unexpected HTTP error during token validation") except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as err: logger.warning( "validate_token: Connection/Timeout error on attempt %d [auth_type=%s, ip=%s]: %s", - n_tries + 1, x_auth_type, client_ip, err + n_tries + 1, + x_auth_type, + client_ip, + err, ) n_tries += 1 time.sleep(random.uniform(1, 5)) @@ -273,19 +276,22 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): except Exception as e: logger.error( "validate_token: Unexpected error on attempt %d [auth_type=%s, ip=%s]: %s", - n_tries + 1, x_auth_type, client_ip, e + n_tries + 1, + x_auth_type, + client_ip, + e, ) raise InternalSystemError("Unexpected error during token validation") - logger.error("validate_token: Exceeded maximum attempts (%d) [auth_type=%s, ip=%s]", - MAX_N_TRIES, x_auth_type, client_ip) + logger.error( + "validate_token: Exceeded maximum attempts (%d) [auth_type=%s, ip=%s]", MAX_N_TRIES, x_auth_type, client_ip + ) raise InternalSystemError("Exceeded maximum token validation attempts") - def validate_token(self, token, x_auth_type, client_ip="unknown"): """ Public wrapper that calls the memoized validation function (if caching is enabled) - or the uncached version if no cache. This function name is the one + or the uncached version if no cache. This function name is the one used in authenticate_impl for consistency. """ return self.validate_token_cached(token, x_auth_type, client_ip=client_ip) @@ -300,17 +306,17 @@ def _token_to_username_impl(self, resp): if not auth_header: logger.error("auth-o-tron response missing 'authorization' header") raise InternalSystemError("Invalid response from auth-o-tron: missing authorization header") - + parts = auth_header.split(" ", 1) if len(parts) != 2 or parts[0].lower() != "bearer": logger.error("Invalid authorization header format: %s", auth_header) raise InternalSystemError("Invalid response from auth-o-tron: incorrect authorization header format") - + jwt_token = parts[1].strip() if not jwt_token: logger.error("JWT token is empty in authorization header") raise InternalSystemError("Invalid response from auth-o-tron: empty JWT token") - + logger.debug("Extracted JWT token from response header: %s", jwt_token) try: payload = jwt.decode(jwt_token, options={"verify_signature": False}) @@ -318,12 +324,12 @@ def _token_to_username_impl(self, resp): except Exception as e: logger.error("Failed to decode JWT token. Raw token: '%s'. Error: %s", jwt_token, e) raise InternalSystemError("Invalid JWT returned from auth-o-tron") - + username = payload.get("username") if not username: logger.error("JWT payload missing 'username': %s", payload) raise InternalSystemError("Token validation error: username missing") - + realm = payload.get("realm", "unknown") logger.info("User '%s' successfully authenticated with realm '%s'", username, realm) - return username, realm \ No newline at end of file + return username, realm diff --git a/pyaviso/authentication/openid_auth.py b/pyaviso/authentication/openid_auth.py index 0c0e779..8074461 100644 --- a/pyaviso/authentication/openid_auth.py +++ b/pyaviso/authentication/openid_auth.py @@ -6,18 +6,17 @@ # granted to it by virtue of its status as an intergovernmental organisation # nor does it submit to any jurisdiction. + class OpenidAuth: """ OpenidAuth implements an OpenID authentication flow. - + It returns a Bearer header (using the shared secret from config.password) and adds an extra header "X-Auth-Type" with the value "openid". """ + def __init__(self, config): self.config = config def header(self): - return { - "Authorization": f"Bearer {self.config.password}", - "X-Auth-Type": "openid" - } \ No newline at end of file + return {"Authorization": f"Bearer {self.config.password}", "X-Auth-Type": "openid"} diff --git a/pyaviso/authentication/plain_auth.py b/pyaviso/authentication/plain_auth.py index 4c357d4..be6ee75 100644 --- a/pyaviso/authentication/plain_auth.py +++ b/pyaviso/authentication/plain_auth.py @@ -8,17 +8,16 @@ import base64 + class PlainAuth: """ PlainAuth implements Basic authentication. """ + def __init__(self, config): self.config = config def header(self): credentials = f"{self.config.username}:{self.config.password}" encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") - return { - "Authorization": f"Basic {encoded}", - "X-Auth-Type": "plain" - } + return {"Authorization": f"Basic {encoded}", "X-Auth-Type": "plain"} From b37c16cda9ec1d20704e2b208d8fc0b45e0320ce Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:06:00 +0000 Subject: [PATCH 10/21] isort --- aviso-server/auth/aviso_auth/authentication.py | 8 ++++---- aviso-server/auth/aviso_auth/frontend.py | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 1f00a13..85a59af 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -1,15 +1,15 @@ # aviso_auth/authentication.py +import base64 import logging import random import time -import requests -import base64 -import jwt +import jwt +import requests from aviso_auth.custom_exceptions import ( - TokenNotValidException, InternalSystemError, + TokenNotValidException, ) from aviso_monitoring.collector.time_collector import TimeCollector from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType diff --git a/aviso-server/auth/aviso_auth/frontend.py b/aviso-server/auth/aviso_auth/frontend.py index ef180f4..e1aebcb 100644 --- a/aviso-server/auth/aviso_auth/frontend.py +++ b/aviso-server/auth/aviso_auth/frontend.py @@ -16,12 +16,9 @@ from aviso_auth.backend_adapter import BackendAdapter from aviso_auth.config import Config from aviso_monitoring import __version__ as monitoring_version - - from aviso_monitoring.collector.count_collector import UniqueCountCollector from aviso_monitoring.collector.time_collector import TimeCollector from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType - from flask import Flask, Response, render_template, request from flask_caching import Cache From 6b1856cb0d0f21a31c7f6e339a6a80b3b2638580 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 4 Mar 2025 22:20:39 +0000 Subject: [PATCH 11/21] error handling fix --- aviso-server/auth/aviso_auth/authentication.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 85a59af..b7fecef 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -1,16 +1,18 @@ -# aviso_auth/authentication.py +# (C) Copyright 1996- ECMWF. +# +# This software is licensed under the terms of the Apache Licence Version 2.0 +# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. +# In applying this licence, ECMWF does not waive the privileges and immunities +# granted to it by virtue of its status as an intergovernmental organisation +# nor does it submit to any jurisdiction. -import base64 import logging import random import time import jwt import requests -from aviso_auth.custom_exceptions import ( - InternalSystemError, - TokenNotValidException, -) +from aviso_auth.custom_exceptions import InternalSystemError, TokenNotValidException from aviso_monitoring.collector.time_collector import TimeCollector from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType @@ -231,7 +233,7 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): ) return resp - except requests.exceptions.HTTPError as err: + except requests.exceptions.HTTPError: status_code = resp.status_code if status_code in [401, 403]: logger.warning( From 7aa04f815574b0dfcc5752c9ab3be85d1cbbd270 Mon Sep 17 00:00:00 2001 From: sametd Date: Thu, 6 Mar 2025 15:50:53 +0000 Subject: [PATCH 12/21] returning correct 401 header --- aviso-server/auth/aviso_auth/frontend.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/aviso-server/auth/aviso_auth/frontend.py b/aviso-server/auth/aviso_auth/frontend.py index e1aebcb..a02d2f1 100644 --- a/aviso-server/auth/aviso_auth/frontend.py +++ b/aviso-server/auth/aviso_auth/frontend.py @@ -77,12 +77,29 @@ def invalid_input(e): @handler.errorhandler(custom.TokenNotValidException) def token_not_valid(e): """ - Return a 401 and attach the - "WWW-Authenticate" header from Authenticator. + Return a 401 and attach a dynamic WWW-Authenticate header if present in the exception message. + If not, fall back to the default header from the Authenticator. """ logger.debug(f"Authentication failed: {e}") - return json_response(e, 401, self.authenticator.UNAUTHORISED_RESPONSE_HEADER) + # Try to extract a dynamic www-authenticate header from the exception message. + # We assume the exception message is formatted like: + # "Invalid credentials or unauthorized token; www-authenticate:
" + msg = str(e) + header = {} + if "www-authenticate:" in msg.lower(): + try: + # Split on "www-authenticate:" and take the remainder. + dynamic_value = msg.split("www-authenticate:")[1].strip() + header["WWW-Authenticate"] = dynamic_value + logger.debug("Using dynamic WWW-Authenticate header: %s", dynamic_value) + except Exception as parse_err: + logger.error("Failed to parse dynamic WWW-Authenticate header: %s", parse_err) + header = self.authenticator.UNAUTHORISED_RESPONSE_HEADER + else: + header = self.authenticator.UNAUTHORISED_RESPONSE_HEADER + + return json_response(e, 401, header) @handler.errorhandler(custom.ForbiddenDestinationException) def forbidden_destination(e): From 2a43413d71e187934505bd83633377742a44552d Mon Sep 17 00:00:00 2001 From: sametd Date: Thu, 6 Mar 2025 15:51:49 +0000 Subject: [PATCH 13/21] removed provider matching, correct 401 header with fallback --- .../auth/aviso_auth/authentication.py | 141 ++++++------------ 1 file changed, 45 insertions(+), 96 deletions(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index b7fecef..2f4be86 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -21,15 +21,17 @@ class Authenticator: - UNAUTHORISED_RESPONSE_HEADER = {"WWW-Authenticate": "Bearer realm='auth-o-tron',info='Provide a valid token'"} + # Following is the fallback response header in case of an unauthorized response + # from the authentication server does not provide a WWW-Authenticate header. + UNAUTHORISED_RESPONSE_HEADER = {"WWW-Authenticate": 'Bearer realm="auth-o-tron"'} def __init__(self, config, cache=None): """ Initialize the Authenticator. - Loads authentication server settings from the configuration. - Uses Flask-Caching if 'cache' is provided to memoize token validation. - If monitoring is enabled (authentication_server["monitor"] = True), - wraps the authentication method in a TimeCollector. + - Loads the authentication server URL and request timeout from the configuration. + - If a cache is provided, token validation is memoized to avoid repeated calls. + - If monitoring is enabled (authentication_server["monitor"] is True), + wraps the authenticate method with a TimeCollector. """ logger.debug("Initializing Authenticator with config: %s", config) self.config = config @@ -37,158 +39,99 @@ def __init__(self, config, cache=None): self.req_timeout = config.authentication_server.get("req_timeout", 10) logger.debug("Authentication server URL: %s, timeout: %s", self.url, self.req_timeout) - self._cached_providers = None - self.cache = cache + # Setup monitoring if enabled. if config.authentication_server.get("monitor"): self.timer = TimeCollector( config.monitoring, tlm_type=AvisoAuthMetricType.auth_resp_time.name, tlm_name="att" ) - logger.debug("Monitoring enabled for authentication; using timed_authenticate") + logger.debug("Monitoring enabled; using timed_authenticate") self.authenticate = self.timed_authenticate else: self.authenticate = self.authenticate_impl + # Wrap the token validation function with caching if available. if self.cache: logger.debug( "Using memoized token validator with cache timeout = %s", config.authentication_server.get("cache_timeout", 300), ) - # Wrap _validate_token_uncached with memoize self.validate_token_cached = self.cache.memoize( timeout=config.authentication_server.get("cache_timeout", 300) )(self._validate_token_uncached) else: - logger.debug("No cache provided; validation calls are uncached.") + logger.debug("No cache provided; using uncached token validation") self.validate_token_cached = self._validate_token_uncached def timed_authenticate(self, request): """ - Wraps the authenticate_impl with a time collector. + Wraps the authenticate_impl method with a TimeCollector. """ logger.debug("timed_authenticate: Starting timed authentication") return self.timer(self.authenticate_impl, args=(request,)) def authenticate_impl(self, request): """ - Main authentication flow. - - Steps: + Main authentication flow: 1. Extract the Authorization and X-Auth-Type headers. - 2. Map the X-Auth-Type value to the expected provider type. - 3. Ensure a matching provider exists (via get_providers). - 4. Extract the token from the Authorization header. - 5. Validate the token (cached). - 6. Extract user information (username, realm). - 7. Return the username. + 2. Extract the token from the Authorization header. + 3. Gather the client IP (for logging). + 4. Validate the token (cached). + 5. Decode the JWT from the validation response to extract username and realm. + 6. Return the username. """ logger.debug("authenticate_impl: Starting authentication process") # Step 1: Extract headers. auth_header, x_auth_type = self.extract_auth_headers(request) - # Step 2: Map incoming auth type. - expected_provider_type = self.map_auth_type(x_auth_type) - - # Step 3: Ensure a matching provider exists. - self.get_matching_provider(expected_provider_type) - - # Step 4: Extract the token from the Authorization header. + # Step 2: Extract the token from the Authorization header. token = self.extract_token(auth_header, x_auth_type) - # Optionally, gather client IP for logging: + # Step 3: Get the client IP. client_ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown" - # Step 5: Validate the token. (NEW: using the memoized version!) + # Step 4: Validate the token (cached). resp = self.validate_token_cached(token, x_auth_type, client_ip=client_ip) - # Step 6: Extract user information. + # Step 5: Decode the JWT to extract user information. username, realm = self._token_to_username_impl(resp) - # Step 7: Return the username. logger.debug("authenticate_impl: Returning username: %s", username) return username def extract_auth_headers(self, request): """ - Extracts the Authorization header and the X-Auth-Type header. + Extracts the HTTP_AUTHORIZATION header from request.environ and the custom X-Auth-Type header. """ if not hasattr(request, "environ"): logger.error("Request missing environ attribute") raise TokenNotValidException("Invalid request: no environ attribute") auth_header = request.environ.get("HTTP_AUTHORIZATION") if not auth_header: - logger.error("Missing Authorization header in request") + logger.error("Missing Authorization header") raise TokenNotValidException("Missing Authorization header") logger.debug("Extracted Authorization header: %s", auth_header) x_auth_type = request.headers.get("X-Auth-Type") if not x_auth_type: - logger.error("Missing X-Auth-Type header in request") + logger.error("Missing X-Auth-Type header") raise TokenNotValidException("Missing X-Auth-Type header") logger.debug("Extracted X-Auth-Type header: %s", x_auth_type) return auth_header, x_auth_type - def map_auth_type(self, x_auth_type): - """ - Maps the X-Auth-Type to the expected provider type. - """ - mapping = {"ecmwf": "ecmwf-api", "plain": "plain", "openid": "openid-offline"} - expected = mapping.get(x_auth_type.lower()) - if not expected: - logger.error("Unknown X-Auth-Type value: %s", x_auth_type) - raise TokenNotValidException(f"Unknown auth type: {x_auth_type}") - logger.debug("Mapped X-Auth-Type '%s' to expected provider type '%s'", x_auth_type, expected) - return expected - - def get_providers(self): - """ - Retrieves providers from auth-o-tron /providers, caches them locally. - """ - if self._cached_providers is not None: - logger.debug("Using cached providers data: %s", self._cached_providers) - return self._cached_providers - - providers_url = f"{self.url}/providers" - logger.debug("Querying providers from auth-o-tron at: %s", providers_url) - try: - resp = requests.get(providers_url, timeout=self.req_timeout) - logger.debug("Received providers response, status: %s", resp.status_code) - resp.raise_for_status() - providers_data = resp.json() - logger.debug("Providers data: %s", providers_data) - self._cached_providers = providers_data - return providers_data - except Exception as e: - logger.error("Error querying providers endpoint: %s", e, exc_info=True) - raise InternalSystemError("Failed to retrieve providers from auth-o-tron") - - def get_matching_provider(self, expected_provider_type): - """ - Check if the retrieved providers contain the given expected_provider_type. - """ - providers = self.get_providers() - for provider in providers.get("providers", []): - p_type = provider.get("type", "").lower() - logger.debug("Checking provider: %s", provider) - if p_type == expected_provider_type: - logger.debug("Matched provider: %s", provider) - return provider - logger.error("No provider found for expected auth type: %s", expected_provider_type) - raise TokenNotValidException(f"No provider available for auth type matching '{expected_provider_type}'") - def extract_token(self, auth_header, x_auth_type): """ - Splits the Authorization header and ensures the correct scheme based on x_auth_type. + Parses the Authorization header to extract the token. + For "plain" auth, expects a Basic scheme; otherwise, expects Bearer. """ try: scheme, token = auth_header.split(" ", 1) except Exception as e: logger.error("Failed to parse Authorization header: %s", e, exc_info=True) raise TokenNotValidException("Invalid Authorization header format") - expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" if scheme.lower() != expected_scheme: logger.error("Expected '%s' scheme, got: %s", expected_scheme.capitalize(), scheme) @@ -198,15 +141,17 @@ def extract_token(self, auth_header, x_auth_type): def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): """ - The real token validation logic that calls auth-o-tron /authenticate. - This method is NOT decorated. The memoized version wraps it if caching is set. + Implements the actual token validation by calling auth-o-tron /authenticate. + For "ecmwf" and "openid", a Bearer header is used. + For "plain", a Basic header is used. + Retries on temporary errors. """ if x_auth_type.lower() in ["ecmwf", "openid"]: auth_header = f"Bearer {token}" elif x_auth_type.lower() == "plain": auth_header = f"Basic {token}" else: - logger.warning("validate_token: Unknown auth type: %s", x_auth_type) + logger.warning("Unknown auth type: %s", x_auth_type) raise TokenNotValidException(f"Unknown auth type: {x_auth_type}") headers = {"Authorization": auth_header} @@ -227,7 +172,7 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): n_tries += 1 continue - resp.raise_for_status() # 4xx or 5xx => HTTPError + resp.raise_for_status() # Raises HTTPError for 4xx/5xx statuses logger.debug( "validate_token: Token validated successfully [auth_type=%s, ip=%s]", x_auth_type, client_ip ) @@ -235,15 +180,20 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): except requests.exceptions.HTTPError: status_code = resp.status_code + # Use dynamic www-authenticate from the response header. + www_authenticate = resp.headers.get("www-authenticate", "Not provided") if status_code in [401, 403]: logger.warning( - "validate_token: %d Unauthorized/forbidden [auth_type=%s, ip=%s, reason=%.200s]", + "validate_token: %d Unauthorized [auth_type=%s, ip=%s, www-authenticate=%s, reason=%.200s]", status_code, x_auth_type, client_ip, + www_authenticate, resp.text, ) - raise TokenNotValidException("Invalid credentials or unauthorized token") + raise TokenNotValidException( + f"Invalid credentials or unauthorized token; www-authenticate: {www_authenticate}" + ) if status_code == 408 or (500 <= status_code < 600): logger.warning( "validate_token: Temporary HTTP error %d [auth_type=%s, ip=%s], reason=%s, retrying", @@ -274,7 +224,6 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): ) n_tries += 1 time.sleep(random.uniform(1, 5)) - except Exception as e: logger.error( "validate_token: Unexpected error on attempt %d [auth_type=%s, ip=%s]: %s", @@ -292,17 +241,17 @@ def _validate_token_uncached(self, token, x_auth_type, client_ip="unknown"): def validate_token(self, token, x_auth_type, client_ip="unknown"): """ - Public wrapper that calls the memoized validation function (if caching is enabled) - or the uncached version if no cache. This function name is the one - used in authenticate_impl for consistency. + Public wrapper that calls the memoized token validation function (if caching is enabled), + or the uncached version otherwise. """ return self.validate_token_cached(token, x_auth_type, client_ip=client_ip) def _token_to_username_impl(self, resp): """ Extracts user info from the auth-o-tron /authenticate response. - Decodes a JWT from the response header "authorization". - Logs an INFO message for successful authentication. + Expects a JWT in the "authorization" header (format: "Bearer "), + decodes the JWT without signature verification, and extracts the "username" and "realm". + Logs an INFO-level message on successful authentication. """ auth_header = resp.headers.get("authorization") if not auth_header: From 6b059d78a862fd72f30dac09c0588d6eed949822 Mon Sep 17 00:00:00 2001 From: sametd Date: Thu, 6 Mar 2025 16:03:49 +0000 Subject: [PATCH 14/21] EmailKey is mapped to Bearer to maintain backward compatibility --- aviso-server/auth/aviso_auth/authentication.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 2f4be86..72a9e9e 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -125,13 +125,20 @@ def extract_auth_headers(self, request): def extract_token(self, auth_header, x_auth_type): """ Parses the Authorization header to extract the token. - For "plain" auth, expects a Basic scheme; otherwise, expects Bearer. + For "plain" auth, expects a Basic scheme; for all other auth types, expects Bearer. + Legacy clients sending "EmailKey" are automatically mapped to "Bearer". """ try: scheme, token = auth_header.split(" ", 1) except Exception as e: logger.error("Failed to parse Authorization header: %s", e, exc_info=True) raise TokenNotValidException("Invalid Authorization header format") + + # Map legacy "EmailKey" scheme to "Bearer" + if scheme.lower() == "emailkey": + logger.debug("Mapping legacy 'EmailKey' scheme to 'Bearer'") + scheme = "Bearer" + expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" if scheme.lower() != expected_scheme: logger.error("Expected '%s' scheme, got: %s", expected_scheme.capitalize(), scheme) From 8a8999a04c1bf81a1da9aab9e86fba2462802a33 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 25 Mar 2025 13:15:11 +0000 Subject: [PATCH 15/21] requirements version bumps --- aviso-server/auth/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aviso-server/auth/requirements.txt b/aviso-server/auth/requirements.txt index b845813..9d87343 100644 --- a/aviso-server/auth/requirements.txt +++ b/aviso-server/auth/requirements.txt @@ -1,8 +1,9 @@ PyYAML>=5.1.2 python-json-logger>=0.1.11 requests>=2.23.0 -gunicorn>=20.0.4 +gunicorn>=23.0.0 flask>=1.1.2 Flask-Caching>=1.8.0 six>=1.15.0 rfc5424-logging-handler>=1.4.3 +PyJWT>=2.10.1 \ No newline at end of file From 30bdfad0baadde00eff2ca2cd545b6d815251418 Mon Sep 17 00:00:00 2001 From: sametd Date: Tue, 25 Mar 2025 16:20:51 +0000 Subject: [PATCH 16/21] removal of python 3.8 check and addition of 3.12 --- .github/workflows/check-publish.yml | 2 +- .github/workflows/check.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-publish.yml b/.github/workflows/check-publish.yml index 0ffd9f3..f4dc7bd 100644 --- a/.github/workflows/check-publish.yml +++ b/.github/workflows/check-publish.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: platform: ["ubuntu-latest", "macos-13"] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] name: Python ${{ matrix.python-version }} on ${{ matrix.platform }} runs-on: ${{ matrix.platform }} diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 29d11d9..9745564 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -25,7 +25,7 @@ jobs: fail-fast: false matrix: platform: ["ubuntu-latest","macos-13"] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] name: Python ${{ matrix.python-version }} on ${{ matrix.platform }} runs-on: ${{ matrix.platform }} From fddc0bcaff57f59bc68ebd4b5f96f356a2acc5dc Mon Sep 17 00:00:00 2001 From: sametd Date: Wed, 26 Mar 2025 20:40:20 +0000 Subject: [PATCH 17/21] openid doesn't need username --- pyaviso/engine/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyaviso/engine/engine.py b/pyaviso/engine/engine.py index e6ab495..88fda4f 100644 --- a/pyaviso/engine/engine.py +++ b/pyaviso/engine/engine.py @@ -209,7 +209,7 @@ def push_with_status( """ # create the status payload status = { - "etcd_user": self.auth.username, + "etcd_user": getattr(self.auth, "username", None), "message": message, "unix_user": getpass.getuser(), "aviso_version": __version__, From da7c59999f0f7654c251cb4c6e08a362bcf03fd5 Mon Sep 17 00:00:00 2001 From: sametd Date: Wed, 26 Mar 2025 21:36:59 +0000 Subject: [PATCH 18/21] auth is patched to be compatible with the old aviso client --- .../auth/aviso_auth/authentication.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 72a9e9e..09fe270 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -104,21 +104,30 @@ def authenticate_impl(self, request): def extract_auth_headers(self, request): """ Extracts the HTTP_AUTHORIZATION header from request.environ and the custom X-Auth-Type header. + If Authorization header uses EmailKey scheme, X-Auth-Type is assumed to be "ecmwf" even if missing. """ if not hasattr(request, "environ"): logger.error("Request missing environ attribute") raise TokenNotValidException("Invalid request: no environ attribute") + auth_header = request.environ.get("HTTP_AUTHORIZATION") if not auth_header: logger.error("Missing Authorization header") raise TokenNotValidException("Missing Authorization header") logger.debug("Extracted Authorization header: %s", auth_header) - x_auth_type = request.headers.get("X-Auth-Type") - if not x_auth_type: - logger.error("Missing X-Auth-Type header") - raise TokenNotValidException("Missing X-Auth-Type header") - logger.debug("Extracted X-Auth-Type header: %s", x_auth_type) + # Check if this is an EmailKey authorization before requiring X-Auth-Type + if auth_header.lower().startswith("emailkey "): + # For EmailKey, assume X-Auth-Type is "ecmwf" if not provided + x_auth_type = request.headers.get("X-Auth-Type", "ecmwf") + logger.debug("EmailKey detected: Using X-Auth-Type '%s'", x_auth_type) + else: + # For other auth schemes, X-Auth-Type is required + x_auth_type = request.headers.get("X-Auth-Type") + if not x_auth_type: + logger.error("Missing X-Auth-Type header") + raise TokenNotValidException("Missing X-Auth-Type header") + logger.debug("Extracted X-Auth-Type header: %s", x_auth_type) return auth_header, x_auth_type @@ -126,7 +135,8 @@ def extract_token(self, auth_header, x_auth_type): """ Parses the Authorization header to extract the token. For "plain" auth, expects a Basic scheme; for all other auth types, expects Bearer. - Legacy clients sending "EmailKey" are automatically mapped to "Bearer". + Legacy clients sending "EmailKey" are automatically mapped to "Bearer" and + X-Auth-Type is assumed to be "ecmwf". """ try: scheme, token = auth_header.split(" ", 1) @@ -134,10 +144,14 @@ def extract_token(self, auth_header, x_auth_type): logger.error("Failed to parse Authorization header: %s", e, exc_info=True) raise TokenNotValidException("Invalid Authorization header format") - # Map legacy "EmailKey" scheme to "Bearer" + # Map legacy "EmailKey" scheme to "Bearer" and ensure X-Auth-Type is "ecmwf" if scheme.lower() == "emailkey": logger.debug("Mapping legacy 'EmailKey' scheme to 'Bearer'") scheme = "Bearer" + # Ensure X-Auth-Type is "ecmwf" when EmailKey is used + if x_auth_type.lower() != "ecmwf": + logger.debug("EmailKey detected but X-Auth-Type is '%s', overriding to 'ecmwf'", x_auth_type) + x_auth_type = "ecmwf" expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" if scheme.lower() != expected_scheme: From 704efe17b5339f9d14b1af1b2e04659d7ec3566c Mon Sep 17 00:00:00 2001 From: sametd Date: Wed, 26 Mar 2025 21:57:35 +0000 Subject: [PATCH 19/21] token fix for emailkey --- aviso-server/auth/aviso_auth/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 09fe270..1086a6c 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -152,6 +152,7 @@ def extract_token(self, auth_header, x_auth_type): if x_auth_type.lower() != "ecmwf": logger.debug("EmailKey detected but X-Auth-Type is '%s', overriding to 'ecmwf'", x_auth_type) x_auth_type = "ecmwf" + token = token.split(":")[-1] expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" if scheme.lower() != expected_scheme: From 27a92a0d635b480914d381124514fe582f144e25 Mon Sep 17 00:00:00 2001 From: sametd Date: Wed, 26 Mar 2025 22:05:38 +0000 Subject: [PATCH 20/21] token fix for emailkey --- aviso-server/auth/aviso_auth/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aviso-server/auth/aviso_auth/authentication.py b/aviso-server/auth/aviso_auth/authentication.py index 1086a6c..46e6f90 100644 --- a/aviso-server/auth/aviso_auth/authentication.py +++ b/aviso-server/auth/aviso_auth/authentication.py @@ -148,11 +148,11 @@ def extract_token(self, auth_header, x_auth_type): if scheme.lower() == "emailkey": logger.debug("Mapping legacy 'EmailKey' scheme to 'Bearer'") scheme = "Bearer" + token = token.split(":")[-1].strip() # Extract the token part # Ensure X-Auth-Type is "ecmwf" when EmailKey is used if x_auth_type.lower() != "ecmwf": logger.debug("EmailKey detected but X-Auth-Type is '%s', overriding to 'ecmwf'", x_auth_type) x_auth_type = "ecmwf" - token = token.split(":")[-1] expected_scheme = "basic" if x_auth_type.lower() == "plain" else "bearer" if scheme.lower() != expected_scheme: From e2f46e6a54a1ac5eafb9abcc0ce9626455f2775e Mon Sep 17 00:00:00 2001 From: sametd Date: Mon, 12 May 2025 07:53:14 +0000 Subject: [PATCH 21/21] version bump --- pyaviso/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyaviso/version.py b/pyaviso/version.py index 923280c..c4359c3 100644 --- a/pyaviso/version.py +++ b/pyaviso/version.py @@ -1,2 +1,2 @@ # version number for the application -__version__ = "1.0.0" +__version__ = "1.1.0"