diff --git a/.pylintrc b/.pylintrc index 25629e81f..bd9c207ce 100644 --- a/.pylintrc +++ b/.pylintrc @@ -116,7 +116,11 @@ disable=raw-checker-failed, global-statement, too-many-lines, duplicate-code, - too-many-return-statements + too-many-return-statements, + logging-not-lazy, + logging-fstring-interpolation, + consider-using-f-string + # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/supertokens_python/recipe/accountlinking/interfaces.py b/supertokens_python/recipe/accountlinking/interfaces.py index 867aeec93..c5ccd1f4b 100644 --- a/supertokens_python/recipe/accountlinking/interfaces.py +++ b/supertokens_python/recipe/accountlinking/interfaces.py @@ -21,22 +21,10 @@ from supertokens_python.types import ( AccountLinkingUser, RecipeUserId, - ThirdPartyInfo, + AccountInfo, ) -class AccountInfo: - def __init__( - self, - email: Optional[str] = None, - phone_number: Optional[str] = None, - third_party: Optional[ThirdPartyInfo] = None, - ): - self.email = email - self.phone_number = phone_number - self.third_party = third_party - - class RecipeInterface(ABC): @abstractmethod async def get_users( diff --git a/supertokens_python/recipe/accountlinking/recipe.py b/supertokens_python/recipe/accountlinking/recipe.py index 0aa8e73f0..b6ddc021a 100644 --- a/supertokens_python/recipe/accountlinking/recipe.py +++ b/supertokens_python/recipe/accountlinking/recipe.py @@ -23,6 +23,12 @@ from supertokens_python.exceptions import SuperTokensError, raise_general_exception from .recipe_implementation import RecipeImplementation from supertokens_python.querier import Querier +from supertokens_python.logger import ( + log_debug_message, +) +from supertokens_python.process_state import PROCESS_STATE, ProcessState +from typing_extensions import Literal +from supertokens_python.recipe.emailverification.recipe import EmailVerificationRecipe from .types import ( RecipeLevelUser, @@ -30,17 +36,33 @@ ShouldNotAutomaticallyLink, AccountInfoWithRecipeIdAndUserId, InputOverrideConfig, + AccountInfoWithRecipeId, + AccountInfo, ) from .interfaces import RecipeInterface +from supertokens_python.recipe.emailverification.interfaces import ( + CreateEmailVerificationTokenOkResult, +) + if TYPE_CHECKING: from supertokens_python.supertokens import AppInfo - from supertokens_python.types import AccountLinkingUser + from supertokens_python.types import AccountLinkingUser, LoginMethod, RecipeUserId from supertokens_python.recipe.session import SessionContainer from supertokens_python.framework import BaseRequest, BaseResponse +class EmailChangeAllowedResult: + def __init__( + self, + allowed: bool, + reason: Literal["OK", "PRIMARY_USER_CONFLICT", "ACCOUNT_TAKEOVER_RISK"], + ): + self.allowed = allowed + self.reason = reason + + class AccountLinkingRecipe(RecipeModule): recipe_id = "accountlinking" __instance = None @@ -165,3 +187,542 @@ def reset(): ): raise_general_exception("calling testing function in non testing env") AccountLinkingRecipe.__instance = None + + async def get_primary_user_that_can_be_linked_to_recipe_user_id( + self, + tenant_id: str, + user: AccountLinkingUser, + user_context: Dict[str, Any], + ) -> Optional[AccountLinkingUser]: + # First we check if this user itself is a primary user or not. If it is, we return that. + if user.is_primary_user: + return user + + # Then, we try and find a primary user based on the email / phone number / third party ID. + users = await self.recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=user.login_methods[0], + do_union_of_account_info=True, + user_context=user_context, + ) + + log_debug_message( + "getPrimaryUserThatCanBeLinkedToRecipeUserId found %d matching users" + % len(users) + ) + primary_users = [u for u in users if u.is_primary_user] + log_debug_message( + "getPrimaryUserThatCanBeLinkedToRecipeUserId found %d matching primary users" + % len(primary_users) + ) + + if len(primary_users) > 1: + # This means that the new user has account info such that it's + # spread across multiple primary user IDs. In this case, even + # if we return one of them, it won't be able to be linked anyway + # cause if we did, it would mean 2 primary users would have the + # same account info. So we return None + + # This being said, with the current set of auth recipes, it should + # never come here - cause: + # ----> If the recipeuserid is a passwordless user, then it can have either a phone + # email or both. If it has just one of them, then anyway 2 primary users can't + # exist with the same phone number / email. If it has both, then the only way + # that it can have multiple primary users returned is if there is another passwordless + # primary user with the same phone number - which is not possible, cause phone + # numbers are unique across passwordless users. + # + # ----> If the input is a third party user, then it has third party info and an email. Now there can be able to primary user with the same email, but + # there can't be another thirdparty user with the same third party info (since that is unique). + # Nor can there an email password primary user with the same email along with another + # thirdparty primary user with the same email (since emails can't be the same across primary users). + # + # ----> If the input is an email password user, then it has an email. There can't be multiple primary users with the same email anyway. + raise Exception( + "You found a bug. Please report it on github.com/supertokens/supertokens-node" + ) + + return primary_users[0] if len(primary_users) > 0 else None + + async def get_oldest_user_that_can_be_linked_to_recipe_user( + self, + tenant_id: str, + user: AccountLinkingUser, + user_context: Dict[str, Any], + ) -> Optional[AccountLinkingUser]: + # First we check if this user itself is a primary user or not. If it is, we return that since it cannot be linked to anything else + if user.is_primary_user: + return user + + # Then, we try and find matching users based on the email / phone number / third party ID. + users = await self.recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=user.login_methods[0], + do_union_of_account_info=True, + user_context=user_context, + ) + + log_debug_message( + f"getOldestUserThatCanBeLinkedToRecipeUser found {len(users)} matching users" + ) + + # Finally select the oldest one + oldest_user = min(users, key=lambda u: u.time_joined) if users else None + return oldest_user + + async def is_sign_in_allowed( + self, + user: AccountLinkingUser, + account_info: Union[AccountInfoWithRecipeId, LoginMethod], + tenant_id: str, + session: Optional[SessionContainer], + sign_in_verifies_login_method: bool, + user_context: Dict[str, Any], + ) -> bool: + ProcessState.get_instance().add_state(PROCESS_STATE.IS_SIGN_IN_ALLOWED_CALLED) + if ( + user.is_primary_user + or user.login_methods[0].verified + or sign_in_verifies_login_method + ): + return True + + return await self.is_sign_in_up_allowed_helper( + account_info=account_info, + is_verified=user.login_methods[0].verified, + session=session, + tenant_id=tenant_id, + is_sign_in=True, + user=user, + user_context=user_context, + ) + + async def is_sign_up_allowed( + self, + new_user: AccountInfoWithRecipeId, + is_verified: bool, + session: Optional[SessionContainer], + tenant_id: str, + user_context: Dict[str, Any], + ) -> bool: + ProcessState.get_instance().add_state(PROCESS_STATE.IS_SIGN_UP_ALLOWED_CALLED) + if new_user.email is not None and new_user.phone_number is not None: + # We do this check cause below when we call list_users_by_account_info, + # we only pass in one of email or phone number + raise Exception("Please pass one of email or phone number, not both") + + return await self.is_sign_in_up_allowed_helper( + account_info=new_user, + is_verified=is_verified, + session=session, + tenant_id=tenant_id, + user_context=user_context, + user=None, + is_sign_in=False, + ) + + async def is_sign_in_up_allowed_helper( + self, + account_info: Union[AccountInfoWithRecipeId, LoginMethod], + is_verified: bool, + session: Optional[SessionContainer], + tenant_id: str, + is_sign_in: bool, + user: Optional[AccountLinkingUser], + user_context: Dict[str, Any], + ) -> bool: + ProcessState.get_instance().add_state( + PROCESS_STATE.IS_SIGN_IN_UP_ALLOWED_HELPER_CALLED + ) + + users = await self.recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=account_info, + do_union_of_account_info=True, + user_context=user_context, + ) + + if not users: + log_debug_message( + "isSignInUpAllowedHelper returning true because no user with given account info" + ) + return True + + if is_sign_in and user is None: + raise Exception( + "This should never happen: isSignInUpAllowedHelper called with isSignIn: true, user: None" + ) + + if ( + len(users) == 1 + and is_sign_in + and user is not None + and users[0].id == user.id + ): + log_debug_message( + "isSignInUpAllowedHelper returning true because this is sign in and there is only a single user with the given account info" + ) + return True + + primary_users = [u for u in users if u.is_primary_user] + + # pylint:disable=no-else-return + if not primary_users: + log_debug_message("isSignInUpAllowedHelper no primary user exists") + should_do_account_linking = ( + await self.config.should_do_automatic_account_linking( + AccountInfoWithRecipeIdAndUserId.from_account_info_or_login_method( + account_info + ), + None, + session, + tenant_id, + user_context, + ) + ) + + if isinstance(should_do_account_linking, ShouldNotAutomaticallyLink): + log_debug_message( + "isSignInUpAllowedHelper returning true because account linking is disabled" + ) + return True + + if not should_do_account_linking.should_require_verification: + log_debug_message( + "isSignInUpAllowedHelper returning true because dev does not require email verification" + ) + return True + + should_allow = True + for curr_user in users: + if session is not None and curr_user.id == session.get_user_id( + user_context + ): + # We do not consider the current session user to be conflicting + # This can be useful in cases where the current sign in will mark the session user as verified + continue + + this_iteration_is_verified = False + if account_info.email is not None: + if ( + curr_user.login_methods[0].has_same_email_as(account_info.email) + and curr_user.login_methods[0].verified + ): + log_debug_message( + "isSignInUpAllowedHelper found same email for another user and verified" + ) + this_iteration_is_verified = True + + if account_info.phone_number is not None: + if ( + curr_user.login_methods[0].has_same_phone_number_as( + account_info.phone_number + ) + and curr_user.login_methods[0].verified + ): + log_debug_message( + "isSignInUpAllowedHelper found same phone number for another user and verified" + ) + this_iteration_is_verified = True + + if not this_iteration_is_verified: + # even if one of the users is not verified, we do not allow sign up (see why above). + # Sure, this allows attackers to create email password accounts with an email + # to block actual users from signing up, but that's ok, since those + # users will just see an email already exists error and then will try another + # login method. They can also still just go through the password reset flow + # and then gain access to their email password account (which can then be verified). + log_debug_message( + "isSignInUpAllowedHelper returning false cause one of the other recipe level users is not verified" + ) + should_allow = False + break + + ProcessState.get_instance().add_state( + PROCESS_STATE.IS_SIGN_IN_UP_ALLOWED_NO_PRIMARY_USER_EXISTS + ) + log_debug_message(f"isSignInUpAllowedHelper returning {should_allow}") + return should_allow + else: + if len(primary_users) > 1: + raise Exception( + "You have found a bug. Please report to https://github.com/supertokens/supertokens-node/issues" + ) + + primary_user = primary_users[0] + log_debug_message("isSignInUpAllowedHelper primary user found") + + should_do_account_linking = ( + await self.config.should_do_automatic_account_linking( + AccountInfoWithRecipeIdAndUserId.from_account_info_or_login_method( + account_info + ), + primary_user, + session, + tenant_id, + user_context, + ) + ) + + if isinstance(should_do_account_linking, ShouldNotAutomaticallyLink): + log_debug_message( + "isSignInUpAllowedHelper returning true because account linking is disabled" + ) + return True + + if not should_do_account_linking.should_require_verification: + log_debug_message( + "isSignInUpAllowedHelper returning true because dev does not require email verification" + ) + return True + + if not is_verified: + log_debug_message( + "isSignInUpAllowedHelper returning false because new user's email is not verified, and primary user with the same email was found." + ) + return False + + if session is not None and primary_user.id == session.get_user_id( + user_context + ): + return True + + for login_method in primary_user.login_methods: + if login_method.email is not None: + if ( + login_method.has_same_email_as(account_info.email) + and login_method.verified + ): + log_debug_message( + "isSignInUpAllowedHelper returning true cause found same email for primary user and verified" + ) + return True + + if login_method.phone_number is not None: + if ( + login_method.has_same_phone_number_as(account_info.phone_number) + and login_method.verified + ): + log_debug_message( + "isSignInUpAllowedHelper returning true cause found same phone number for primary user and verified" + ) + return True + + log_debug_message( + "isSignInUpAllowedHelper returning false cause primary user does not have the same email or phone number that is verified" + ) + return False + + async def is_email_change_allowed( + self, + user: AccountLinkingUser, + new_email: str, + is_verified: bool, + session: Optional[SessionContainer], + user_context: Dict[str, Any], + ) -> EmailChangeAllowedResult: + """ + The purpose of this function is to check if a recipe user ID's email + can be changed or not. There are two conditions for when it can't be changed: + - If the recipe user is a primary user, then we need to check that the new email + doesn't belong to any other primary user. If it does, we disallow the change + since multiple primary user's can't have the same account info. + + - If the recipe user is NOT a primary user, and if is_verified is false, then + we check if there exists a primary user with the same email, and if it does + we disallow the email change cause if this email is changed, and an email + verification email is sent, then the primary user may end up clicking + on the link by mistake, causing account linking to happen which can result + in account take over if this recipe user is malicious. + """ + + for tenant_id in user.tenant_ids: + existing_users_with_new_email = ( + await self.recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=AccountInfo(email=new_email), + do_union_of_account_info=False, + user_context=user_context, + ) + ) + + other_users_with_new_email = [ + u for u in existing_users_with_new_email if u.id != user.id + ] + other_primary_user_for_new_email = [ + u for u in other_users_with_new_email if u.is_primary_user + ] + + if len(other_primary_user_for_new_email) > 1: + raise Exception( + "You found a bug. Please report it on github.com/supertokens/supertokens-core" + ) + + # pylint:disable=no-else-return + if user.is_primary_user: + if other_primary_user_for_new_email: + log_debug_message( + f"isEmailChangeAllowed: returning false cause email change will lead to two primary users having same email on {tenant_id}" + ) + return EmailChangeAllowedResult( + allowed=False, reason="PRIMARY_USER_CONFLICT" + ) + + if is_verified: + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is primary, new email is verified and doesn't belong to any other primary user" + ) + continue + + if any( + lm.has_same_email_as(new_email) and lm.verified + for lm in user.login_methods + ): + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is primary, new email is verified in another login method and doesn't belong to any other primary user" + ) + continue + + if not other_users_with_new_email: + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is primary and the new email doesn't belong to any other user (primary or non-primary)" + ) + continue + + should_do_account_linking = await self.config.should_do_automatic_account_linking( + AccountInfoWithRecipeIdAndUserId.from_account_info_or_login_method( + other_users_with_new_email[0].login_methods[0] + ), + user, + session, + tenant_id, + user_context, + ) + + if isinstance(should_do_account_linking, ShouldNotAutomaticallyLink): + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause linking is disabled" + ) + continue + + if not should_do_account_linking.should_require_verification: + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause linking doesn't require email verification" + ) + continue + + log_debug_message( + f"isEmailChangeAllowed: returning false because the user hasn't verified the new email address and there exists another user with it on {tenant_id} and linking requires verification" + ) + return EmailChangeAllowedResult( + allowed=False, reason="ACCOUNT_TAKEOVER_RISK" + ) + else: + if is_verified: + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is not a primary and new email is verified" + ) + continue + + if user.login_methods[0].has_same_email_as(new_email): + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is not a primary and new email is same as the older one" + ) + continue + + if other_primary_user_for_new_email: + should_do_account_linking = ( + await self.config.should_do_automatic_account_linking( + AccountInfoWithRecipeIdAndUserId( + recipe_id=user.login_methods[0].recipe_id, + email=user.login_methods[0].email, + recipe_user_id=user.login_methods[0].recipe_user_id, + phone_number=user.login_methods[0].phone_number, + third_party=user.login_methods[0].third_party, + ), + other_primary_user_for_new_email[0], + session, + tenant_id, + user_context, + ) + ) + + if isinstance( + should_do_account_linking, ShouldNotAutomaticallyLink + ): + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is not a primary there exists a primary user exists with the new email, but the dev does not have account linking enabled." + ) + continue + + if not should_do_account_linking.should_require_verification: + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is not a primary there exists a primary user exists with the new email, but the dev does not require email verification." + ) + continue + + log_debug_message( + "isEmailChangeAllowed: returning false cause input user is not a primary there exists a primary user exists with the new email." + ) + return EmailChangeAllowedResult( + allowed=False, reason="ACCOUNT_TAKEOVER_RISK" + ) + + log_debug_message( + f"isEmailChangeAllowed: can change on {tenant_id} cause input user is not a primary no primary user exists with the new email" + ) + continue + + log_debug_message( + "isEmailChangeAllowed: returning true cause email change can happen on all tenants the user is part of" + ) + return EmailChangeAllowedResult(allowed=True, reason="OK") + + # pylint:disable=no-self-use + async def verify_email_for_recipe_user_if_linked_accounts_are_verified( + self, + user: AccountLinkingUser, + recipe_user_id: RecipeUserId, + user_context: Dict[str, Any], + ) -> None: + try: + EmailVerificationRecipe.get_instance_or_throw() + except Exception: + # if email verification recipe is not initialized, we do a no-op + return + + if user.is_primary_user: + recipe_user_email: Optional[str] = None + is_already_verified = False + for lm in user.login_methods: + if lm.recipe_user_id.get_as_string() == recipe_user_id.get_as_string(): + recipe_user_email = lm.email + is_already_verified = lm.verified + break + + if recipe_user_email is not None: + if is_already_verified: + return + should_verify_email = False + for lm in user.login_methods: + if lm.has_same_email_as(recipe_user_email) and lm.verified: + should_verify_email = True + break + + if should_verify_email: + ev_recipe = EmailVerificationRecipe.get_instance_or_throw() + resp = await ev_recipe.recipe_implementation.create_email_verification_token( + tenant_id=user.tenant_ids[0], + user_id=recipe_user_id.get_as_string(), + email=recipe_user_email, + user_context=user_context, + ) + if isinstance(resp, CreateEmailVerificationTokenOkResult): + # we purposely pass in false below cause we don't want account + # linking to happen + await ev_recipe.recipe_implementation.verify_email_using_token( + tenant_id=user.tenant_ids[0], + token=resp.token, + # attempt_account_linking=False, + user_context=user_context, + ) diff --git a/supertokens_python/recipe/accountlinking/types.py b/supertokens_python/recipe/accountlinking/types.py index 49a6384db..e8520a605 100644 --- a/supertokens_python/recipe/accountlinking/types.py +++ b/supertokens_python/recipe/accountlinking/types.py @@ -25,6 +25,7 @@ RecipeUserId, ThirdPartyInfo, AccountLinkingUser, + LoginMethod, ) from supertokens_python.recipe.session import SessionContainer @@ -38,7 +39,9 @@ def __init__( third_party: Optional[ThirdPartyInfo] = None, ): super().__init__(email, phone_number, third_party) - self.recipe_id = recipe_id + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless" + ] = recipe_id class RecipeLevelUser(AccountInfoWithRecipeId): @@ -54,34 +57,47 @@ def __init__( super().__init__(recipe_id, email, phone_number, third_party) self.tenant_ids = tenant_ids self.time_joined = time_joined - self.recipe_id = recipe_id + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless" + ] = recipe_id -class AccountInfoWithRecipeIdAndUserId(RecipeLevelUser): +class AccountInfoWithRecipeIdAndUserId(AccountInfoWithRecipeId): def __init__( self, recipe_user_id: Optional[RecipeUserId], - tenant_ids: List[str], - time_joined: int, recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], email: Optional[str] = None, phone_number: Optional[str] = None, third_party: Optional[ThirdPartyInfo] = None, ): - super().__init__( - tenant_ids, time_joined, recipe_id, email, phone_number, third_party - ) + super().__init__(recipe_id, email, phone_number, third_party) self.recipe_user_id = recipe_user_id + @staticmethod + def from_account_info_or_login_method( + account_info: Union[AccountInfoWithRecipeId, LoginMethod], + ) -> AccountInfoWithRecipeIdAndUserId: + return AccountInfoWithRecipeIdAndUserId( + recipe_id=account_info.recipe_id, + email=account_info.email, + phone_number=account_info.phone_number, + third_party=account_info.third_party, + recipe_user_id=( + account_info.recipe_user_id + if isinstance(account_info, LoginMethod) + else None + ), + ) + class ShouldNotAutomaticallyLink: def __init__(self): - self.should_automatically_link = False + pass class ShouldAutomaticallyLink: def __init__(self, should_require_verification: bool): - self.should_automatically_link = True self.should_require_verification = should_require_verification diff --git a/supertokens_python/recipe/dashboard/api/userdetails/user_email_verify_get.py b/supertokens_python/recipe/dashboard/api/userdetails/user_email_verify_get.py index 54e0c2a9e..f0652bd94 100644 --- a/supertokens_python/recipe/dashboard/api/userdetails/user_email_verify_get.py +++ b/supertokens_python/recipe/dashboard/api/userdetails/user_email_verify_get.py @@ -24,7 +24,7 @@ async def handle_user_email_verify_get( raise_bad_input_exception("Missing required parameter 'userId'") try: - EmailVerificationRecipe.get_instance() + EmailVerificationRecipe.get_instance_or_throw() except Exception: return FeatureNotEnabledError() diff --git a/supertokens_python/recipe/emailverification/asyncio/__init__.py b/supertokens_python/recipe/emailverification/asyncio/__init__.py index c15dc121d..b0ead0877 100644 --- a/supertokens_python/recipe/emailverification/asyncio/__init__.py +++ b/supertokens_python/recipe/emailverification/asyncio/__init__.py @@ -47,7 +47,7 @@ async def create_email_verification_token( ]: if user_context is None: user_context = {} - recipe = EmailVerificationRecipe.get_instance() + recipe = EmailVerificationRecipe.get_instance_or_throw() if email is None: email_info = await recipe.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): @@ -67,7 +67,7 @@ async def verify_email_using_token( ): if user_context is None: user_context = {} - return await EmailVerificationRecipe.get_instance().recipe_implementation.verify_email_using_token( + return await EmailVerificationRecipe.get_instance_or_throw().recipe_implementation.verify_email_using_token( token, tenant_id, user_context ) @@ -80,7 +80,7 @@ async def is_email_verified( if user_context is None: user_context = {} - recipe = EmailVerificationRecipe.get_instance() + recipe = EmailVerificationRecipe.get_instance_or_throw() if email is None: email_info = await recipe.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): @@ -104,7 +104,7 @@ async def revoke_email_verification_tokens( if user_context is None: user_context = {} - recipe = EmailVerificationRecipe.get_instance() + recipe = EmailVerificationRecipe.get_instance_or_throw() if email is None: email_info = await recipe.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): @@ -114,7 +114,7 @@ async def revoke_email_verification_tokens( else: raise Exception("Unknown User ID provided without email") - return await EmailVerificationRecipe.get_instance().recipe_implementation.revoke_email_verification_tokens( + return await EmailVerificationRecipe.get_instance_or_throw().recipe_implementation.revoke_email_verification_tokens( user_id, email, tenant_id, user_context ) @@ -127,7 +127,7 @@ async def unverify_email( if user_context is None: user_context = {} - recipe = EmailVerificationRecipe.get_instance() + recipe = EmailVerificationRecipe.get_instance_or_throw() if email is None: email_info = await recipe.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): @@ -139,7 +139,7 @@ async def unverify_email( else: raise Exception("Unknown User ID provided without email") - return await EmailVerificationRecipe.get_instance().recipe_implementation.unverify_email( + return await EmailVerificationRecipe.get_instance_or_throw().recipe_implementation.unverify_email( user_id, email, user_context ) @@ -150,7 +150,7 @@ async def send_email( ): if user_context is None: user_context = {} - return await EmailVerificationRecipe.get_instance().email_delivery.ingredient_interface_impl.send_email( + return await EmailVerificationRecipe.get_instance_or_throw().email_delivery.ingredient_interface_impl.send_email( input_, user_context ) @@ -167,7 +167,7 @@ async def create_email_verification_link( if user_context is None: user_context = {} - recipe_instance = EmailVerificationRecipe.get_instance() + recipe_instance = EmailVerificationRecipe.get_instance_or_throw() app_info = recipe_instance.get_app_info() email_verification_token = await create_email_verification_token( @@ -203,7 +203,7 @@ async def send_email_verification_email( user_context = {} if email is None: - recipe_instance = EmailVerificationRecipe.get_instance() + recipe_instance = EmailVerificationRecipe.get_instance_or_throw() email_info = await recipe_instance.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): diff --git a/supertokens_python/recipe/emailverification/recipe.py b/supertokens_python/recipe/emailverification/recipe.py index e02dd06ef..6ef7d3b9f 100644 --- a/supertokens_python/recipe/emailverification/recipe.py +++ b/supertokens_python/recipe/emailverification/recipe.py @@ -241,7 +241,7 @@ def callback(): return func @staticmethod - def get_instance() -> EmailVerificationRecipe: + def get_instance_or_throw() -> EmailVerificationRecipe: if EmailVerificationRecipe.__instance is not None: return EmailVerificationRecipe.__instance raise_general_exception( @@ -305,7 +305,7 @@ def __init__(self): async def fetch_value( user_id: str, _tenant_id: str, user_context: Dict[str, Any] ) -> bool: - recipe = EmailVerificationRecipe.get_instance() + recipe = EmailVerificationRecipe.get_instance_or_throw() email_info = await recipe.get_email_for_user_id(user_id, user_context) if isinstance(email_info, GetEmailForUserIdOkResult): @@ -395,8 +395,10 @@ async def generate_email_verify_token_post( GenerateEmailVerifyTokenPostEmailAlreadyVerifiedError, ]: user_id = session.get_user_id(user_context) - email_info = await EmailVerificationRecipe.get_instance().get_email_for_user_id( - user_id, user_context + email_info = ( + await EmailVerificationRecipe.get_instance_or_throw().get_email_for_user_id( + user_id, user_context + ) ) tenant_id = session.get_tenant_id() diff --git a/supertokens_python/types.py b/supertokens_python/types.py index e54c2b589..6cecfc606 100644 --- a/supertokens_python/types.py +++ b/supertokens_python/types.py @@ -13,9 +13,10 @@ # under the License. from abc import ABC, abstractmethod -from typing import Any, Awaitable, Dict, List, TypeVar, Union +from typing import Any, Awaitable, Dict, List, TypeVar, Union, Optional from phonenumbers import format_number, parse # type: ignore import phonenumbers # type: ignore +from typing_extensions import Literal _T = TypeVar("_T") @@ -27,6 +28,11 @@ def __init__(self, recipe_user_id: str): def get_as_string(self) -> str: return self.recipe_user_id + def __eq__(self, other: Any) -> bool: + if isinstance(other, RecipeUserId): + return self.recipe_user_id == other.recipe_user_id + return False + class ThirdPartyInfo: def __init__(self, third_party_id: str, third_party_user_id: str): @@ -34,10 +40,22 @@ def __init__(self, third_party_id: str, third_party_user_id: str): self.user_id = third_party_user_id -class LoginMethod: +class AccountInfo: def __init__( self, - recipe_id: str, + email: Optional[str] = None, + phone_number: Optional[str] = None, + third_party: Optional[ThirdPartyInfo] = None, + ): + self.email = email + self.phone_number = phone_number + self.third_party = third_party + + +class LoginMethod(AccountInfo): + def __init__( + self, + recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], recipe_user_id: str, tenant_ids: List[str], email: Union[str, None], @@ -46,12 +64,12 @@ def __init__( time_joined: int, verified: bool, ): - self.recipe_id = recipe_id + super().__init__(email, phone_number, third_party) + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless" + ] = recipe_id self.recipe_user_id = RecipeUserId(recipe_user_id) - self.tenant_ids = tenant_ids - self.email = email - self.phone_number = phone_number - self.third_party = third_party + self.tenant_ids: List[str] = tenant_ids self.time_joined = time_joined self.verified = verified