From 21c3a9cff848bb3ab99f7032901a8d2faa7e1b7a Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Tue, 3 Sep 2024 16:19:19 +0200 Subject: [PATCH] [Community] handle auto refresh jwt token --- .../supabase_backend_tests/.env.template | 1 + .../supabase_backend_tests/__init__.py | 4 ++ .../test_user_elements.py | 38 ++++++++++++++++++- octobot/cli.py | 16 ++++---- octobot/community/authentication.py | 36 ++++++++++++++++++ octobot/community/errors.py | 4 ++ .../community/supabase_backend/__init__.py | 2 + .../community_supabase_client.py | 17 +++++++-- 8 files changed, 105 insertions(+), 13 deletions(-) diff --git a/additional_tests/supabase_backend_tests/.env.template b/additional_tests/supabase_backend_tests/.env.template index c2c065c3e..35cda0699 100644 --- a/additional_tests/supabase_backend_tests/.env.template +++ b/additional_tests/supabase_backend_tests/.env.template @@ -4,6 +4,7 @@ SUPABASE_BACKEND_KEY= SUPABASE_BACKEND_CLIENT_1_EMAIL= SUPABASE_BACKEND_CLIENT_1_PASSWORD= SUPABASE_BACKEND_CLIENT_1_AUTH_KEY= +SUPABASE_BACKEND_CLIENT_1_EXPIRED_JWT_TOKEN= SUPABASE_BACKEND_CLIENT_2_EMAIL= SUPABASE_BACKEND_CLIENT_2_PASSWORD= diff --git a/additional_tests/supabase_backend_tests/__init__.py b/additional_tests/supabase_backend_tests/__init__.py index 6c085f833..4534fa512 100644 --- a/additional_tests/supabase_backend_tests/__init__.py +++ b/additional_tests/supabase_backend_tests/__init__.py @@ -221,6 +221,10 @@ def get_backend_client_auth_key(identifier): return os.getenv(f"SUPABASE_BACKEND_CLIENT_{identifier}_AUTH_KEY") +def get_backend_client_expired_jwt_token(identifier): + return os.getenv(f"SUPABASE_BACKEND_CLIENT_{identifier}_EXPIRED_JWT_TOKEN") + + def _get_backend_service_key(): return os.getenv(f"SUPABASE_BACKEND_SERVICE_KEY") diff --git a/additional_tests/supabase_backend_tests/test_user_elements.py b/additional_tests/supabase_backend_tests/test_user_elements.py index 2bd687af2..dfe3a198c 100644 --- a/additional_tests/supabase_backend_tests/test_user_elements.py +++ b/additional_tests/supabase_backend_tests/test_user_elements.py @@ -14,15 +14,20 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import time + +import mock +import postgrest import pytest import octobot_commons.configuration as commons_configuration import octobot_commons.authentication as authentication import octobot.community as community +import octobot.community.supabase_backend as supabase_backend +import octobot.community.errors as community_errors import octobot.community.supabase_backend.enums as supabase_backend_enums from additional_tests.supabase_backend_tests import authenticated_client_1, authenticated_client_2, \ admin_client, anon_client, get_backend_api_creds, skip_if_no_service_key, get_backend_client_creds, \ - get_backend_client_auth_key + get_backend_client_auth_key, get_backend_client_expired_jwt_token # All test coroutines will be treated as marked. @@ -162,3 +167,34 @@ async def test_sign_in_with_auth_token(): finally: if supabase_client: await supabase_client.aclose() + + +async def test_expired_jwt_token(authenticated_client_1): + initial_email = (await authenticated_client_1.get_user())[supabase_backend_enums.UserKeys.EMAIL.value] + + # refreshing session is working + await authenticated_client_1.refresh_session() + # does not raise + bots = await authenticated_client_1.fetch_bots() + assert (await authenticated_client_1.get_user())[supabase_backend_enums.UserKeys.EMAIL.value] == initial_email + + # use expired jwt token + expired_jwt_token = get_backend_client_expired_jwt_token(1) + + # simulate auth using this token + session = mock.Mock(access_token=expired_jwt_token) + authenticated_client_1._listen_to_auth_events( + "SIGNED_IN", session + ) + + # now raising "APIError: JWT expired" + with pytest.raises(postgrest.APIError): + await authenticated_client_1.fetch_bots() + + # now raising "APIError: JWT expired" which is converted into community_errors.SessionTokenExpiredError + with pytest.raises(community_errors.SessionTokenExpiredError): + with supabase_backend.error_describer(): + await authenticated_client_1.fetch_bots() + + + diff --git a/octobot/cli.py b/octobot/cli.py index 6cb0ba2d0..8bf7f162f 100644 --- a/octobot/cli.py +++ b/octobot/cli.py @@ -197,15 +197,13 @@ async def _get_authenticated_community_if_possible(config, logger): try: if not community_auth.is_initialized(): if constants.IS_CLOUD_ENV: - if constants.USER_ACCOUNT_EMAIL and constants.USER_AUTH_KEY: - try: - logger.debug("Attempting auth key authentication") - await community_auth.login( - constants.USER_ACCOUNT_EMAIL, None, auth_key=constants.USER_AUTH_KEY - ) - except authentication.AuthenticationError as err: - logger.info(f"Auth key auth failure ({err}). Trying other methods if available.") - if constants.USER_ACCOUNT_EMAIL and constants.USER_PASSWORD_TOKEN: + authenticated = False + try: + logger.debug("Attempting auth key authentication") + authenticated = await community_auth.auto_reauthenticate() + except authentication.AuthenticationError as err: + logger.info(f"Auth key auth failure ({err}). Trying other methods if available.") + if not authenticated and constants.USER_ACCOUNT_EMAIL and constants.USER_PASSWORD_TOKEN: try: logger.debug("Attempting password token authentication") await community_auth.login( diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index 3585e54aa..f041a3ff3 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -40,7 +40,31 @@ import octobot_trading.enums as trading_enums +def expired_session_retrier(func): + async def expired_session_retrier_wrapper(*args, **kwargs): + self = args[0] + try: + with supabase_backend.error_describer(): + return await func(*args, **kwargs) + except errors.SessionTokenExpiredError: + try: + with supabase_backend.error_describer(): + self.logger.info(f"Expired session, trying to refresh token.") + await self.supabase_client.refresh_session() + return await func(*args, **kwargs) + except errors.SessionTokenExpiredError as err: + if await self.auto_reauthenticate(): + self.logger.error( + f"Impossible to use default refresh token, using saved auth details instead." + ) + return await func(*args, **kwargs) + # can't refresh token: logout + self.logger.warning(f"Expired session, please re-authenticate. {err}") + await self.logout() + return expired_session_retrier_wrapper + def _bot_data_update(func): + @expired_session_retrier async def bot_data_update_wrapper(*args, raise_errors=False, **kwargs): self = args[0] if not self.is_logged_in_and_has_selected_bot(): @@ -49,6 +73,9 @@ async def bot_data_update_wrapper(*args, raise_errors=False, **kwargs): try: self.logger.debug(f"bot_data_update: {func.__name__} initiated.") return await func(*args, **kwargs) + except errors.SessionTokenExpiredError: + # requried by expired_session_retrier + raise except Exception as err: if raise_errors: raise err @@ -329,6 +356,15 @@ async def login( if self.is_logged_in(): await self.on_signed_in(minimal=minimal) + async def auto_reauthenticate(self) -> bool: + if constants.IS_CLOUD_ENV and constants.USER_ACCOUNT_EMAIL and constants.USER_AUTH_KEY: + self.logger.debug("Attempting auth key authentication") + await self.login( + constants.USER_ACCOUNT_EMAIL, None, auth_key=constants.USER_AUTH_KEY + ) + return self.is_logged_in() + return False + async def register(self, email, password): if self.must_be_authenticated_through_authenticator(): raise authentication.AuthenticationError("Creating a new account is not authorized on this environment.") diff --git a/octobot/community/errors.py b/octobot/community/errors.py index 66a58aaaa..e73b74857 100644 --- a/octobot/community/errors.py +++ b/octobot/community/errors.py @@ -24,6 +24,10 @@ class StatusCodeRequestError(RequestError): pass +class SessionTokenExpiredError(commons_authentication.AuthenticationError): + pass + + class BotError(commons_authentication.UnavailableError): pass diff --git a/octobot/community/supabase_backend/__init__.py b/octobot/community/supabase_backend/__init__.py index 563a3c84c..0f555bdb1 100644 --- a/octobot/community/supabase_backend/__init__.py +++ b/octobot/community/supabase_backend/__init__.py @@ -25,6 +25,7 @@ ) from octobot.community.supabase_backend import community_supabase_client from octobot.community.supabase_backend.community_supabase_client import ( + error_describer, CommunitySupabaseClient, HTTP_RETRY_COUNT, ) @@ -33,6 +34,7 @@ "SyncConfigurationStorage", "ASyncConfigurationStorage", "AuthenticatedAsyncSupabaseClient", + "error_describer", "CommunitySupabaseClient", "HTTP_RETRY_COUNT", ] diff --git a/octobot/community/supabase_backend/community_supabase_client.py b/octobot/community/supabase_backend/community_supabase_client.py index 2eded4209..f1aac67aa 100644 --- a/octobot/community/supabase_backend/community_supabase_client.py +++ b/octobot/community/supabase_backend/community_supabase_client.py @@ -22,8 +22,9 @@ import httpx import uuid import json - +import contextlib import aiohttp + import gotrue.errors import gotrue.types import postgrest @@ -56,6 +57,16 @@ HTTP_RETRY_COUNT = 5 +@contextlib.contextmanager +def error_describer(): + try: + yield + except postgrest.APIError as err: + if "jwt expired" in str(err).lower(): + raise errors.SessionTokenExpiredError(err) from err + raise + + def _httpx_retrier(f): async def httpx_retrier_wrapper(*args, **kwargs): resp = None @@ -160,9 +171,9 @@ async def restore_session(self): if not self.is_signed_in(): raise authentication.FailedAuthentication() - async def refresh_session(self): + async def refresh_session(self, refresh_token: typing.Union[str, None] = None): try: - await self.auth.refresh_session() + await self.auth.refresh_session(refresh_token=refresh_token) except gotrue.errors.AuthError as err: raise authentication.AuthenticationError(err) from err