diff --git a/.github/workflows/backend-sdk-testing.yml b/.github/workflows/backend-sdk-testing.yml index 08212eed2..428c2af6c 100644 --- a/.github/workflows/backend-sdk-testing.yml +++ b/.github/workflows/backend-sdk-testing.yml @@ -82,10 +82,11 @@ jobs: run: | source venv/bin/activate docker compose up --build --wait - python3 tests/test-server/app.py & + python3 tests/test-server/app.py &> python.log & - uses: supertokens/backend-sdk-testing-action@main with: version: ${{ matrix.fdi-version }} check-name-suffix: '[CDI=${{ matrix.cdi-version }}][Core=${{ steps.versions.outputs.coreVersionXy }}][FDI=${{ matrix.fdi-version }}][Py=${{ matrix.py-version }}][Node=${{ matrix.node-version }}]' path: backend-sdk-testing + app-server-logs: ${{ github.workspace }}/supertokens-python/python.log diff --git a/.github/workflows/lint-code.yml b/.github/workflows/lint-code.yml index af902ce40..69e986742 100644 --- a/.github/workflows/lint-code.yml +++ b/.github/workflows/lint-code.yml @@ -19,6 +19,11 @@ jobs: outputs: pyVersions: '["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]' + steps: + # Required to avoid errors in runs due to no steps + - name: Placeholder + run: echo "Placeholder" + lint-format: name: Check linting and formatting runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bf82bca..ae66e04ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +## [0.30.0] - 2025-05-27 +### Adds Webauthn (Passkeys) support +- Adds Webauthn recipe with support for: + - Registration, sign-in, and credential verification flows + - Account recovery +- Adds new API endpoints for WebAuthn operations: + - GET `/api/webauthn/email/exists` - Check if email exists in system + - POST `/api/webauthn/options/register` - Handle registration options + - POST `/api/webauthn/options/signin` - Handle sign-in options + - POST `/api/webauthn/signin` - Handle WebAuthn sign-in + - POST `/api/webauthn/signup` - Handle WebAuthn sign-up + - POST `/api/user/webauthn/reset` - Handle account recovery + - POST `/api/user/webauthn/reset/token` - Generate recovery tokens +- Adds WebAuthn support to account linking functionality: + - Support for linking users based on WebAuthn `credential_id` + - Updates `AccountInfo` type to `AccountInfoInput` with WebAuthn fields + - Adds `has_same_webauthn_info_as` method for credential comparison +- Adds FDI support for version `4.1` +- Recipe functions are directly importable from the Webauthn recipe module + - ```python + from supertokens_python.recipe.webauthn import sign_in + + await sign_in(...) # Async + sign_in.sync(...) # Sync + ``` + +### Breaking Changes +- Updates supported CDI version from `5.2` to `5.3` +- Changes `AccountInfo` to `AccountInfoInput` in various methods + - This is required to allow querying by a single Webauthn `credential_id`, while the Webauthn login method contains an array of `credential_ids` + - Affected functions: + - `supertokens_python.asyncio.list_users_by_account_info` + - `supertokens_python.syncio.list_users_by_account_info` + - `supertokens_python.recipe.accountlinking.interface.RecipeInterface.list_users_by_account_info` + - `supertokens_python.recipe.accountlinking.recipe_implementation.RecipeImplementation.list_users_by_account_info` + - `supertokens_python.recipe.passwordless.api.implementation.get_passwordless_user_by_account_info` + - `supertokens_python.recipe.passwordless.api.implementation.get_passwordless_user_by_account_info` + + ## [0.29.2] - 2025-05-19 - Fixes cookies being set without expiry in Django - Reverts timezone change from 0.28.0 and uses GMT diff --git a/coreDriverInterfaceSupported.json b/coreDriverInterfaceSupported.json index f6cea07ef..77f4d9ffd 100644 --- a/coreDriverInterfaceSupported.json +++ b/coreDriverInterfaceSupported.json @@ -1,6 +1,6 @@ { "_comment": "contains a list of core-driver interfaces branch names that this core supports", "versions": [ - "5.2" + "5.3" ] } diff --git a/dev-requirements.txt b/dev-requirements.txt index c4e1b09c9..e39220586 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -21,4 +21,5 @@ pyyaml==6.0.2 requests-mock==1.12.1 respx>=0.13.0, <1.0.0 uvicorn==0.32.0 +wasmtime==25.0.0 -e . diff --git a/frontendDriverInterfaceSupported.json b/frontendDriverInterfaceSupported.json index ee72e4d71..bbfbf964e 100644 --- a/frontendDriverInterfaceSupported.json +++ b/frontendDriverInterfaceSupported.json @@ -7,6 +7,7 @@ "2.0", "3.0", "3.1", - "4.0" + "4.0", + "4.1" ] } diff --git a/setup.py b/setup.py index 81783e273..e4a1568d5 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( name="supertokens_python", - version="0.29.2", + version="0.30.0", author="SuperTokens", license="Apache 2.0", author_email="team@supertokens.com", @@ -127,6 +127,7 @@ "pkce<1.1.0", "pyotp<3", "python-dateutil<3", + "pydantic>=2.10.6,<3.0.0", ], python_requires=">=3.8", include_package_data=True, diff --git a/supertokens_python/async_to_sync_wrapper.py b/supertokens_python/async_to_sync_wrapper.py index af97ca926..5eea33841 100644 --- a/supertokens_python/async_to_sync_wrapper.py +++ b/supertokens_python/async_to_sync_wrapper.py @@ -13,8 +13,20 @@ # under the License. import asyncio +from functools import update_wrapper from os import getenv -from typing import Any, Coroutine, TypeVar +from typing import ( + Any, + Callable, + Coroutine, + Generic, + TypeVar, +) + +from typing_extensions import ParamSpec + +Param = ParamSpec("Param") +RetType = TypeVar("RetType", covariant=True) _T = TypeVar("_T") @@ -43,3 +55,25 @@ def create_or_get_event_loop() -> asyncio.AbstractEventLoop: def sync(co: Coroutine[Any, Any, _T]) -> _T: loop = create_or_get_event_loop() return loop.run_until_complete(co) + + +class syncify(Generic[Param, RetType]): + """ + Decorator to allow async functions to be executed synchronously + using a `sync` attribute. + """ + + def __init__(self, func: Callable[Param, Coroutine[Any, Any, RetType]]): + update_wrapper(self, func) + self.func = func + + def __call__( + self, *args: Param.args, **kwargs: Param.kwargs + ) -> Coroutine[Any, Any, RetType]: + return self.func(*args, **kwargs) + + def sync(self, *args: Param.args, **kwargs: Param.kwargs) -> RetType: + """ + Synchronous version of the decorated function. + """ + return sync(self.func(*args, **kwargs)) diff --git a/supertokens_python/asyncio/__init__.py b/supertokens_python/asyncio/__init__.py index 472f2b372..aab0c4e8f 100644 --- a/supertokens_python/asyncio/__init__.py +++ b/supertokens_python/asyncio/__init__.py @@ -26,7 +26,8 @@ ) from supertokens_python.recipe.accountlinking.interfaces import GetUsersResult from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe -from supertokens_python.types import AccountInfo, User +from supertokens_python.types import User +from supertokens_python.types.base import AccountInfoInput async def get_users_oldest_first( @@ -159,7 +160,7 @@ async def update_or_delete_user_id_mapping_info( async def list_users_by_account_info( tenant_id: str, - account_info: AccountInfo, + account_info: AccountInfoInput, do_union_of_account_info: bool = False, user_context: Optional[Dict[str, Any]] = None, ) -> List[User]: diff --git a/supertokens_python/auth_utils.py b/supertokens_python/auth_utils.py index 2b8b04666..4b1893416 100644 --- a/supertokens_python/auth_utils.py +++ b/supertokens_python/auth_utils.py @@ -1,4 +1,4 @@ -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union from typing_extensions import Literal @@ -31,37 +31,18 @@ from supertokens_python.recipe.session.interfaces import SessionContainer from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo from supertokens_python.types import ( - AccountInfo, LoginMethod, RecipeUserId, User, ) +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput from supertokens_python.utils import log_debug_message from .asyncio import get_user - -class LinkingToSessionUserFailedError: - status: Literal["LINKING_TO_SESSION_USER_FAILED"] = "LINKING_TO_SESSION_USER_FAILED" - reason: Literal[ - "EMAIL_VERIFICATION_REQUIRED", - "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "INPUT_USER_IS_NOT_A_PRIMARY_USER", - ] - - def __init__( - self, - reason: Literal[ - "EMAIL_VERIFICATION_REQUIRED", - "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", - "INPUT_USER_IS_NOT_A_PRIMARY_USER", - ], - ): - self.reason = reason +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.types.base import WebauthnInfoInput class OkResponse: @@ -290,6 +271,7 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required( session: Optional[SessionContainer], check_credentials_on_tenant: Callable[[str], Awaitable[bool]], user_context: Dict[str, Any], + webauthn: Optional["WebauthnInfoInput"] = None, ) -> Optional[AuthenticatingUserInfo]: i = 0 while i < 300: @@ -303,8 +285,11 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required( ) existing_users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo( - email=email, phone_number=phone_number, third_party=third_party + account_info=AccountInfoInput( + email=email, + phone_number=phone_number, + third_party=third_party, + webauthn=webauthn, ), do_union_of_account_info=True, user_context=user_context, @@ -324,6 +309,7 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required( (email is not None and lm.has_same_email_as(email)) or lm.has_same_phone_number_as(phone_number) or lm.has_same_third_party_info_as(third_party) + or lm.has_same_webauthn_info_as(webauthn) ) ), None, diff --git a/supertokens_python/constants.py b/supertokens_python/constants.py index a11cadf94..5db52c805 100644 --- a/supertokens_python/constants.py +++ b/supertokens_python/constants.py @@ -14,8 +14,8 @@ from __future__ import annotations -SUPPORTED_CDI_VERSIONS = ["5.2"] -VERSION = "0.29.2" +SUPPORTED_CDI_VERSIONS = ["5.3"] +VERSION = "0.30.0" TELEMETRY = "/telemetry" USER_COUNT = "/users/count" USER_DELETE = "/user/remove" diff --git a/supertokens_python/recipe/accountlinking/interfaces.py b/supertokens_python/recipe/accountlinking/interfaces.py index 1b1f182ff..105b5bda5 100644 --- a/supertokens_python/recipe/accountlinking/interfaces.py +++ b/supertokens_python/recipe/accountlinking/interfaces.py @@ -18,9 +18,10 @@ from typing_extensions import Literal +from supertokens_python.types.base import AccountInfoInput + if TYPE_CHECKING: from supertokens_python.types import ( - AccountInfo, RecipeUserId, User, ) @@ -104,7 +105,7 @@ async def get_user( async def list_users_by_account_info( self, tenant_id: str, - account_info: AccountInfo, + account_info: AccountInfoInput, do_union_of_account_info: bool, user_context: Dict[str, Any], ) -> List[User]: diff --git a/supertokens_python/recipe/accountlinking/recipe.py b/supertokens_python/recipe/accountlinking/recipe.py index 9012bf89b..3b34801ca 100644 --- a/supertokens_python/recipe/accountlinking/recipe.py +++ b/supertokens_python/recipe/accountlinking/recipe.py @@ -27,11 +27,11 @@ from supertokens_python.querier import Querier from supertokens_python.recipe_module import APIHandled, RecipeModule from supertokens_python.supertokens import Supertokens +from supertokens_python.types.base import AccountInfoInput from .interfaces import RecipeInterface from .recipe_implementation import RecipeImplementation from .types import ( - AccountInfo, AccountInfoWithRecipeId, AccountInfoWithRecipeIdAndUserId, InputOverrideConfig, @@ -210,7 +210,15 @@ async def get_primary_user_that_can_be_linked_to_recipe_user_id( # 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], + account_info=AccountInfoInput( + email=user.login_methods[0].email, + phone_number=user.login_methods[0].phone_number, + third_party=user.login_methods[0].third_party, + # We don't need to list by (webauthn) credentialId because we are looking for + # a user to link to the current recipe user, but any search using the credentialId + # of the current user "will identify the same user" which is the current one. + webauthn=None, + ), do_union_of_account_info=True, user_context=user_context, ) @@ -266,7 +274,15 @@ async def get_oldest_user_that_can_be_linked_to_recipe_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], + account_info=AccountInfoInput( + email=user.login_methods[0].email, + phone_number=user.login_methods[0].phone_number, + third_party=user.login_methods[0].third_party, + # We don't need to list by (webauthn) credentialId because we are looking for + # a user to link to the current recipe user, but any search using the credentialId + # of the current user "will identify the same user" which is the current one. + webauthn=None, + ), do_union_of_account_info=True, user_context=user_context, ) @@ -346,7 +362,15 @@ async def is_sign_in_up_allowed_helper( users = await self.recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=account_info, + account_info=AccountInfoInput( + email=account_info.email, + phone_number=account_info.phone_number, + third_party=account_info.third_party, + # We don't need to list by (webauthn) credentialId because we are looking for + # a user to link to the current recipe user, but any search using the credentialId + # of the current user "will identify the same user" which is the current one. + webauthn=None, + ), do_union_of_account_info=True, user_context=user_context, ) @@ -548,7 +572,7 @@ async def is_email_change_allowed( existing_users_with_new_email = ( await self.recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo(email=new_email), + account_info=AccountInfoInput(email=new_email), do_union_of_account_info=False, user_context=user_context, ) diff --git a/supertokens_python/recipe/accountlinking/recipe_implementation.py b/supertokens_python/recipe/accountlinking/recipe_implementation.py index 268537b16..2eb9a89e6 100644 --- a/supertokens_python/recipe/accountlinking/recipe_implementation.py +++ b/supertokens_python/recipe/accountlinking/recipe_implementation.py @@ -19,6 +19,7 @@ from supertokens_python.normalised_url_path import NormalisedURLPath from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.base import AccountInfoInput from .interfaces import ( CanCreatePrimaryUserAccountInfoAlreadyAssociatedError, @@ -39,7 +40,7 @@ RecipeInterface, UnlinkAccountOkResult, ) -from .types import AccountInfo, AccountLinkingConfig, RecipeLevelUser +from .types import AccountLinkingConfig, RecipeLevelUser if TYPE_CHECKING: from supertokens_python.querier import Querier @@ -327,7 +328,7 @@ async def get_user( async def list_users_by_account_info( self, tenant_id: str, - account_info: AccountInfo, + account_info: AccountInfoInput, do_union_of_account_info: bool, user_context: Dict[str, Any], ) -> List[User]: @@ -343,6 +344,9 @@ async def list_users_by_account_info( params["thirdPartyId"] = account_info.third_party.id params["thirdPartyUserId"] = account_info.third_party.user_id + if account_info.webauthn: + params["webauthnCredentialId"] = account_info.webauthn.credential_id + response = await self.querier.send_get_request( NormalisedURLPath(f"/{tenant_id or 'public'}/users/by-accountinfo"), params, diff --git a/supertokens_python/recipe/accountlinking/types.py b/supertokens_python/recipe/accountlinking/types.py index 1a0882ba5..22977eabe 100644 --- a/supertokens_python/recipe/accountlinking/types.py +++ b/supertokens_python/recipe/accountlinking/types.py @@ -25,10 +25,11 @@ if TYPE_CHECKING: from supertokens_python.recipe.session import SessionContainer + from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo + from supertokens_python.recipe.webauthn.types.base import WebauthnInfo from supertokens_python.types import ( LoginMethod, RecipeUserId, - ThirdPartyInfo, User, ) @@ -36,15 +37,16 @@ class AccountInfoWithRecipeId(AccountInfo): def __init__( self, - recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], + recipe_id: Literal["emailpassword", "thirdparty", "passwordless", "webauthn"], email: Optional[str] = None, phone_number: Optional[str] = None, third_party: Optional[ThirdPartyInfo] = None, + webauthn: Optional[WebauthnInfo] = None, ): - super().__init__(email, phone_number, third_party) - self.recipe_id: Literal["emailpassword", "thirdparty", "passwordless"] = ( - recipe_id - ) + super().__init__(email, phone_number, third_party, webauthn=webauthn) + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless", "webauthn" + ] = recipe_id def to_json(self) -> Dict[str, Any]: return { @@ -58,17 +60,18 @@ def __init__( self, tenant_ids: List[str], time_joined: int, - recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], + recipe_id: Literal["emailpassword", "thirdparty", "passwordless", "webauthn"], email: Optional[str] = None, phone_number: Optional[str] = None, third_party: Optional[ThirdPartyInfo] = None, + webauthn: Optional[WebauthnInfo] = None, ): - super().__init__(recipe_id, email, phone_number, third_party) + super().__init__(recipe_id, email, phone_number, third_party, webauthn=webauthn) self.tenant_ids = tenant_ids self.time_joined = time_joined - self.recipe_id: Literal["emailpassword", "thirdparty", "passwordless"] = ( - recipe_id - ) + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless", "webauthn" + ] = recipe_id @staticmethod def from_login_method( @@ -81,6 +84,7 @@ def from_login_method( email=login_method.email, phone_number=login_method.phone_number, third_party=login_method.third_party, + webauthn=login_method.webauthn, ) @@ -88,12 +92,13 @@ class AccountInfoWithRecipeIdAndUserId(AccountInfoWithRecipeId): def __init__( self, recipe_user_id: Optional[RecipeUserId], - recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], + recipe_id: Literal["emailpassword", "thirdparty", "passwordless", "webauthn"], email: Optional[str] = None, phone_number: Optional[str] = None, third_party: Optional[ThirdPartyInfo] = None, + webauthn: Optional[WebauthnInfo] = None, ): - super().__init__(recipe_id, email, phone_number, third_party) + super().__init__(recipe_id, email, phone_number, third_party, webauthn=webauthn) self.recipe_user_id = recipe_user_id @staticmethod @@ -109,6 +114,7 @@ def from_account_info_or_login_method( email=account_info.email, phone_number=account_info.phone_number, third_party=account_info.third_party, + webauthn=account_info.webauthn, recipe_user_id=( account_info.recipe_user_id if isinstance(account_info, LM) else None ), diff --git a/supertokens_python/recipe/dashboard/api/api_key_protector.py b/supertokens_python/recipe/dashboard/api/api_key_protector.py index a66cb27fa..8e8fb903f 100644 --- a/supertokens_python/recipe/dashboard/api/api_key_protector.py +++ b/supertokens_python/recipe/dashboard/api/api_key_protector.py @@ -22,7 +22,7 @@ APIInterface, APIOptions, ) - from supertokens_python.types import APIResponse + from supertokens_python.types.response import APIResponse from supertokens_python.utils import ( send_200_response, diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/create_or_update_third_party_config.py b/supertokens_python/recipe/dashboard/api/multitenancy/create_or_update_third_party_config.py index 9206257b6..2a4f1e303 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/create_or_update_third_party_config.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/create_or_update_third_party_config.py @@ -27,7 +27,7 @@ from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe from supertokens_python.recipe.thirdparty import ProviderConfig from supertokens_python.recipe.thirdparty.providers.utils import do_post_request -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from supertokens_python.utils import encode_base64 from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/create_tenant.py b/supertokens_python/recipe/dashboard/api/multitenancy/create_tenant.py index c741afbe5..bd266001e 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/create_tenant.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/create_tenant.py @@ -19,7 +19,7 @@ from supertokens_python.recipe.multitenancy.interfaces import ( TenantConfigCreateOrUpdate, ) -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/delete_tenant.py b/supertokens_python/recipe/dashboard/api/multitenancy/delete_tenant.py index 7fa02ac4d..98f9082db 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/delete_tenant.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/delete_tenant.py @@ -17,7 +17,7 @@ from typing_extensions import Literal from supertokens_python.recipe.multitenancy.asyncio import delete_tenant -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/delete_third_party_config.py b/supertokens_python/recipe/dashboard/api/multitenancy/delete_third_party_config.py index 545636718..357496f8a 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/delete_third_party_config.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/delete_third_party_config.py @@ -28,7 +28,7 @@ from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe from supertokens_python.recipe.thirdparty import ProviderConfig -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/get_tenant_info.py b/supertokens_python/recipe/dashboard/api/multitenancy/get_tenant_info.py index b57c75095..768612d86 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/get_tenant_info.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/get_tenant_info.py @@ -26,7 +26,7 @@ find_and_create_provider_instance, merge_providers_from_core_and_static, ) -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions, CoreConfigFieldInfo from .utils import ( diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/get_third_party_config.py b/supertokens_python/recipe/dashboard/api/multitenancy/get_third_party_config.py index 6c705e1f1..bc0824dca 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/get_third_party_config.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/get_third_party_config.py @@ -32,7 +32,7 @@ merge_providers_from_core_and_static, ) from supertokens_python.recipe.thirdparty.providers.utils import do_get_request -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/list_all_tenants_with_login_methods.py b/supertokens_python/recipe/dashboard/api/multitenancy/list_all_tenants_with_login_methods.py index cd7fbb1c1..27e23fa62 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/list_all_tenants_with_login_methods.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/list_all_tenants_with_login_methods.py @@ -15,7 +15,7 @@ from typing import Any, Dict, List from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions from .utils import ( diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_core_config.py b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_core_config.py index 58eee7d1a..878ffa4a2 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_core_config.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_core_config.py @@ -19,7 +19,7 @@ from supertokens_python.exceptions import raise_bad_input_exception from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_first_factor.py b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_first_factor.py index c5c13ea6c..3887c59d1 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_first_factor.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_first_factor.py @@ -19,7 +19,7 @@ from supertokens_python.exceptions import raise_bad_input_exception from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions from .utils import ( diff --git a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_secondary_factor.py b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_secondary_factor.py index e5ed5038a..f2e48e2b2 100644 --- a/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_secondary_factor.py +++ b/supertokens_python/recipe/dashboard/api/multitenancy/update_tenant_secondary_factor.py @@ -20,7 +20,7 @@ from supertokens_python.recipe.multifactorauth.recipe import MultiFactorAuthRecipe from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions from .utils import ( diff --git a/supertokens_python/recipe/dashboard/api/user/create/emailpassword_user.py b/supertokens_python/recipe/dashboard/api/user/create/emailpassword_user.py index 93942c98c..cb8976c4e 100644 --- a/supertokens_python/recipe/dashboard/api/user/create/emailpassword_user.py +++ b/supertokens_python/recipe/dashboard/api/user/create/emailpassword_user.py @@ -8,7 +8,8 @@ SignUpOkResult, ) from supertokens_python.recipe.emailpassword.recipe import EmailPasswordRecipe -from supertokens_python.types import APIResponse, RecipeUserId, User +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.response import APIResponse class CreateEmailPasswordUserOkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/user/create/passwordless_user.py b/supertokens_python/recipe/dashboard/api/user/create/passwordless_user.py index e7461fcaf..fc8d15417 100644 --- a/supertokens_python/recipe/dashboard/api/user/create/passwordless_user.py +++ b/supertokens_python/recipe/dashboard/api/user/create/passwordless_user.py @@ -27,7 +27,8 @@ ) from supertokens_python.recipe.passwordless.asyncio import signinup from supertokens_python.recipe.passwordless.recipe import PasswordlessRecipe -from supertokens_python.types import APIResponse, RecipeUserId, User +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.response import APIResponse class CreatePasswordlessUserOkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userdetails/user_put.py b/supertokens_python/recipe/dashboard/api/userdetails/user_put.py index 108d8657b..a6123db6a 100644 --- a/supertokens_python/recipe/dashboard/api/userdetails/user_put.py +++ b/supertokens_python/recipe/dashboard/api/userdetails/user_put.py @@ -39,10 +39,10 @@ from supertokens_python.recipe.usermetadata.asyncio import update_user_metadata from supertokens_python.types import RecipeUserId +from .....types.response import APIResponse from ...interfaces import ( APIInterface, APIOptions, - APIResponse, ) diff --git a/supertokens_python/recipe/dashboard/api/userdetails/user_unlink_get.py b/supertokens_python/recipe/dashboard/api/userdetails/user_unlink_get.py index 60917b32f..f66d46d70 100644 --- a/supertokens_python/recipe/dashboard/api/userdetails/user_unlink_get.py +++ b/supertokens_python/recipe/dashboard/api/userdetails/user_unlink_get.py @@ -5,7 +5,7 @@ from supertokens_python.exceptions import raise_bad_input_exception from supertokens_python.recipe.accountlinking.asyncio import unlink_account from supertokens_python.recipe.dashboard.utils import RecipeUserId -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from ...interfaces import APIInterface, APIOptions diff --git a/supertokens_python/recipe/dashboard/api/userroles/add_role_to_user.py b/supertokens_python/recipe/dashboard/api/userroles/add_role_to_user.py index 41cfd246a..6fa808b09 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/add_role_to_user.py +++ b/supertokens_python/recipe/dashboard/api/userroles/add_role_to_user.py @@ -7,7 +7,7 @@ from supertokens_python.recipe.userroles.asyncio import add_role_to_user from supertokens_python.recipe.userroles.interfaces import AddRoleToUserOkResult from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/get_role_to_user.py b/supertokens_python/recipe/dashboard/api/userroles/get_role_to_user.py index 42937619a..c6b25d3fb 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/get_role_to_user.py +++ b/supertokens_python/recipe/dashboard/api/userroles/get_role_to_user.py @@ -6,7 +6,7 @@ from supertokens_python.recipe.dashboard.interfaces import APIInterface, APIOptions from supertokens_python.recipe.userroles.asyncio import get_roles_for_user from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/permissions/get_permissions_for_role.py b/supertokens_python/recipe/dashboard/api/userroles/permissions/get_permissions_for_role.py index 253a105bd..1a7a46217 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/permissions/get_permissions_for_role.py +++ b/supertokens_python/recipe/dashboard/api/userroles/permissions/get_permissions_for_role.py @@ -7,7 +7,7 @@ GetPermissionsForRoleOkResult, ) from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkPermissionsForRoleResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/permissions/remove_permissions_from_role.py b/supertokens_python/recipe/dashboard/api/userroles/permissions/remove_permissions_from_role.py index f545cc112..ad088721d 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/permissions/remove_permissions_from_role.py +++ b/supertokens_python/recipe/dashboard/api/userroles/permissions/remove_permissions_from_role.py @@ -7,7 +7,7 @@ RemovePermissionsFromRoleOkResult, ) from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/remove_user_role.py b/supertokens_python/recipe/dashboard/api/userroles/remove_user_role.py index 274f06711..be194e532 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/remove_user_role.py +++ b/supertokens_python/recipe/dashboard/api/userroles/remove_user_role.py @@ -7,7 +7,7 @@ from supertokens_python.recipe.userroles.asyncio import remove_user_role from supertokens_python.recipe.userroles.interfaces import RemoveUserRoleOkResult from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/roles/create_role_or_add_permissions.py b/supertokens_python/recipe/dashboard/api/userroles/roles/create_role_or_add_permissions.py index 6c2c40659..1e33f235a 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/roles/create_role_or_add_permissions.py +++ b/supertokens_python/recipe/dashboard/api/userroles/roles/create_role_or_add_permissions.py @@ -6,7 +6,7 @@ create_new_role_or_add_permissions, ) from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/roles/delete_role.py b/supertokens_python/recipe/dashboard/api/userroles/roles/delete_role.py index d5698aee5..34fdb06a1 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/roles/delete_role.py +++ b/supertokens_python/recipe/dashboard/api/userroles/roles/delete_role.py @@ -6,7 +6,7 @@ from supertokens_python.recipe.dashboard.interfaces import APIInterface, APIOptions from supertokens_python.recipe.userroles.asyncio import delete_role from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/api/userroles/roles/get_all_roles.py b/supertokens_python/recipe/dashboard/api/userroles/roles/get_all_roles.py index b87ee852c..9eda5e744 100644 --- a/supertokens_python/recipe/dashboard/api/userroles/roles/get_all_roles.py +++ b/supertokens_python/recipe/dashboard/api/userroles/roles/get_all_roles.py @@ -5,7 +5,7 @@ from supertokens_python.recipe.dashboard.interfaces import APIInterface, APIOptions from supertokens_python.recipe.userroles.asyncio import get_all_roles from supertokens_python.recipe.userroles.recipe import UserRolesRecipe -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse class OkResponse(APIResponse): diff --git a/supertokens_python/recipe/dashboard/interfaces.py b/supertokens_python/recipe/dashboard/interfaces.py index 65e662f2e..a0e8564a4 100644 --- a/supertokens_python/recipe/dashboard/interfaces.py +++ b/supertokens_python/recipe/dashboard/interfaces.py @@ -20,7 +20,7 @@ from supertokens_python.recipe.multitenancy.interfaces import TenantConfig -from ...types import APIResponse +from ...types.response import APIResponse if TYPE_CHECKING: from supertokens_python.framework import BaseRequest, BaseResponse diff --git a/supertokens_python/recipe/dashboard/recipe.py b/supertokens_python/recipe/dashboard/recipe.py index e5f1c5a5e..f789aea66 100644 --- a/supertokens_python/recipe/dashboard/recipe.py +++ b/supertokens_python/recipe/dashboard/recipe.py @@ -113,7 +113,7 @@ from supertokens_python.framework.request import BaseRequest from supertokens_python.framework.response import BaseResponse from supertokens_python.supertokens import AppInfo - from supertokens_python.types import APIResponse + from supertokens_python.types.response import APIResponse from supertokens_python.exceptions import SuperTokensError, raise_general_exception from supertokens_python.recipe.dashboard.utils import get_api_path_with_dashboard_base diff --git a/supertokens_python/recipe/emailpassword/api/implementation.py b/supertokens_python/recipe/emailpassword/api/implementation.py index e3f5e1e98..3cdd098e2 100644 --- a/supertokens_python/recipe/emailpassword/api/implementation.py +++ b/supertokens_python/recipe/emailpassword/api/implementation.py @@ -42,7 +42,6 @@ EmailExistsGetOkResult, GeneratePasswordResetTokenPostNotAllowedResponse, GeneratePasswordResetTokenPostOkResult, - LinkingToSessionUserFailedError, PasswordPolicyViolationError, PasswordResetPostOkResult, PasswordResetTokenInvalidError, @@ -63,13 +62,16 @@ from supertokens_python.recipe.emailverification.recipe import EmailVerificationRecipe from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.totp.types import UnknownUserIdError +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput +from supertokens_python.types.response import GeneralErrorResponse from ..utils import get_password_reset_link if TYPE_CHECKING: from supertokens_python.recipe.emailpassword.interfaces import APIOptions -from supertokens_python.types import AccountInfo, GeneralErrorResponse, RecipeUserId +from supertokens_python.types import RecipeUserId class APIImplementation(APIInterface): @@ -83,7 +85,7 @@ async def email_exists_get( # Check if there exists an email password user with the same email users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo(email=email), + account_info=AccountInfoInput(email=email), do_union_of_account_info=False, user_context=user_context, ) @@ -162,7 +164,7 @@ async def generate_and_send_password_reset_token( users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo(email=email), + account_info=AccountInfoInput(email=email), do_union_of_account_info=False, user_context=user_context, ) @@ -565,6 +567,7 @@ async def check_credentials_on_tenant(tenant_id: str) -> bool: email=email, phone_number=None, third_party=None, + webauthn=None, user_context=user_context, recipe_id=recipe_id, session=session, @@ -700,7 +703,7 @@ async def sign_up_post( if pre_auth_check_res.status == "SIGN_UP_NOT_ALLOWED": conflicting_users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo( + account_info=AccountInfoInput( email=email, ), do_union_of_account_info=False, diff --git a/supertokens_python/recipe/emailpassword/api/signup.py b/supertokens_python/recipe/emailpassword/api/signup.py index 42ac77726..12ccc0b4a 100644 --- a/supertokens_python/recipe/emailpassword/api/signup.py +++ b/supertokens_python/recipe/emailpassword/api/signup.py @@ -20,7 +20,7 @@ EmailAlreadyExistsError, SignUpPostOkResult, ) -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from ..exceptions import raise_form_field_exception from ..types import ErrorFormField diff --git a/supertokens_python/recipe/emailpassword/asyncio/__init__.py b/supertokens_python/recipe/emailpassword/asyncio/__init__.py index 612f75f98..3bf072501 100644 --- a/supertokens_python/recipe/emailpassword/asyncio/__init__.py +++ b/supertokens_python/recipe/emailpassword/asyncio/__init__.py @@ -17,7 +17,6 @@ from supertokens_python import get_request_from_user_context from supertokens_python.asyncio import get_user -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.emailpassword import EmailPasswordRecipe from supertokens_python.recipe.emailpassword.interfaces import ( ConsumePasswordResetTokenOkResult, @@ -38,6 +37,7 @@ ) from supertokens_python.recipe.emailpassword.utils import get_password_reset_link from supertokens_python.recipe.session import SessionContainer +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError from ....types import RecipeUserId from ...multitenancy.constants import DEFAULT_TENANT_ID diff --git a/supertokens_python/recipe/emailpassword/interfaces.py b/supertokens_python/recipe/emailpassword/interfaces.py index 395263963..96df9426e 100644 --- a/supertokens_python/recipe/emailpassword/interfaces.py +++ b/supertokens_python/recipe/emailpassword/interfaces.py @@ -16,16 +16,15 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, List, Union -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient from supertokens_python.recipe.emailpassword.types import EmailTemplateVars +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError from ...supertokens import AppInfo from ...types import ( - APIResponse, - GeneralErrorResponse, RecipeUserId, ) +from ...types.response import APIResponse, GeneralErrorResponse if TYPE_CHECKING: from supertokens_python.framework import BaseRequest, BaseResponse diff --git a/supertokens_python/recipe/emailpassword/recipe_implementation.py b/supertokens_python/recipe/emailpassword/recipe_implementation.py index 1d6d5197e..453506ef1 100644 --- a/supertokens_python/recipe/emailpassword/recipe_implementation.py +++ b/supertokens_python/recipe/emailpassword/recipe_implementation.py @@ -17,7 +17,6 @@ from supertokens_python.asyncio import get_user from supertokens_python.auth_utils import ( - LinkingToSessionUserFailedError, link_to_session_if_provided_else_create_primary_user_id_or_link_by_account_info, ) from supertokens_python.normalised_url_path import NormalisedURLPath @@ -25,6 +24,7 @@ from supertokens_python.recipe.emailverification.recipe import EmailVerificationRecipe from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import RecipeUserId +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError from ...types import User from .constants import FORM_FIELD_PASSWORD_ID diff --git a/supertokens_python/recipe/emailpassword/syncio/__init__.py b/supertokens_python/recipe/emailpassword/syncio/__init__.py index 04548f371..e4996ed20 100644 --- a/supertokens_python/recipe/emailpassword/syncio/__init__.py +++ b/supertokens_python/recipe/emailpassword/syncio/__init__.py @@ -20,7 +20,6 @@ ConsumePasswordResetTokenOkResult, CreateResetPasswordOkResult, EmailAlreadyExistsError, - LinkingToSessionUserFailedError, PasswordPolicyViolationError, PasswordResetTokenInvalidError, SignInOkResult, @@ -35,6 +34,7 @@ ) from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import RecipeUserId +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError def sign_up( diff --git a/supertokens_python/recipe/emailverification/interfaces.py b/supertokens_python/recipe/emailverification/interfaces.py index 2329dab31..bbf829047 100644 --- a/supertokens_python/recipe/emailverification/interfaces.py +++ b/supertokens_python/recipe/emailverification/interfaces.py @@ -19,7 +19,8 @@ from typing_extensions import Literal from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient -from supertokens_python.types import APIResponse, GeneralErrorResponse, RecipeUserId +from supertokens_python.types import RecipeUserId +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from ...supertokens import AppInfo from ..session.interfaces import SessionContainer diff --git a/supertokens_python/recipe/jwt/interfaces.py b/supertokens_python/recipe/jwt/interfaces.py index 026a7f166..2c398f8da 100644 --- a/supertokens_python/recipe/jwt/interfaces.py +++ b/supertokens_python/recipe/jwt/interfaces.py @@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional, Union from supertokens_python.framework import BaseRequest, BaseResponse -from supertokens_python.types import APIResponse, GeneralErrorResponse +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from .utils import JWTConfig diff --git a/supertokens_python/recipe/multifactorauth/api/implementation.py b/supertokens_python/recipe/multifactorauth/api/implementation.py index c2fd99b9e..2de67722c 100644 --- a/supertokens_python/recipe/multifactorauth/api/implementation.py +++ b/supertokens_python/recipe/multifactorauth/api/implementation.py @@ -27,7 +27,7 @@ SuperTokensSessionError, UnauthorisedError, ) -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from ..interfaces import ( APIInterface, diff --git a/supertokens_python/recipe/multifactorauth/interfaces.py b/supertokens_python/recipe/multifactorauth/interfaces.py index 8e30f2816..b5b0ec928 100644 --- a/supertokens_python/recipe/multifactorauth/interfaces.py +++ b/supertokens_python/recipe/multifactorauth/interfaces.py @@ -17,7 +17,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Union -from ...types import APIResponse, GeneralErrorResponse +from ...types.response import APIResponse, GeneralErrorResponse if TYPE_CHECKING: from supertokens_python.framework import BaseRequest, BaseResponse diff --git a/supertokens_python/recipe/multifactorauth/types.py b/supertokens_python/recipe/multifactorauth/types.py index b1e0a058f..1f5587189 100644 --- a/supertokens_python/recipe/multifactorauth/types.py +++ b/supertokens_python/recipe/multifactorauth/types.py @@ -66,6 +66,7 @@ class FactorIds: LINK_PHONE: Literal["link-phone"] = "link-phone" THIRDPARTY: Literal["thirdparty"] = "thirdparty" TOTP: Literal["totp"] = "totp" + WEBAUTHN: Literal["webauthn"] = "webauthn" class FactorIdsAndType: diff --git a/supertokens_python/recipe/multitenancy/api/implementation.py b/supertokens_python/recipe/multitenancy/api/implementation.py index 13f70ffa7..f901e07b1 100644 --- a/supertokens_python/recipe/multitenancy/api/implementation.py +++ b/supertokens_python/recipe/multitenancy/api/implementation.py @@ -22,7 +22,7 @@ LoginMethodsGetOkResult, LoginMethodThirdParty, ) -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from ..constants import DEFAULT_TENANT_ID from ..interfaces import APIInterface, ThirdPartyProvider diff --git a/supertokens_python/recipe/multitenancy/interfaces.py b/supertokens_python/recipe/multitenancy/interfaces.py index 2dfda0e4b..03bd8d012 100644 --- a/supertokens_python/recipe/multitenancy/interfaces.py +++ b/supertokens_python/recipe/multitenancy/interfaces.py @@ -16,7 +16,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union -from supertokens_python.types import APIResponse, GeneralErrorResponse, RecipeUserId +from supertokens_python.types import RecipeUserId +from supertokens_python.types.response import APIResponse, GeneralErrorResponse if TYPE_CHECKING: from supertokens_python.framework import BaseRequest, BaseResponse diff --git a/supertokens_python/recipe/oauth2provider/api/end_session.py b/supertokens_python/recipe/oauth2provider/api/end_session.py index 7f96931d3..3cf98f96e 100644 --- a/supertokens_python/recipe/oauth2provider/api/end_session.py +++ b/supertokens_python/recipe/oauth2provider/api/end_session.py @@ -19,7 +19,7 @@ from supertokens_python.exceptions import raise_bad_input_exception from supertokens_python.framework import BaseResponse -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from supertokens_python.utils import send_200_response, send_non_200_response from .utils import get_session diff --git a/supertokens_python/recipe/oauth2provider/api/implementation.py b/supertokens_python/recipe/oauth2provider/api/implementation.py index 53ae9172f..720ba9d91 100644 --- a/supertokens_python/recipe/oauth2provider/api/implementation.py +++ b/supertokens_python/recipe/oauth2provider/api/implementation.py @@ -15,7 +15,8 @@ from typing import Any, Dict, List, Optional, Union from supertokens_python.recipe.session import SessionContainer -from supertokens_python.types import GeneralErrorResponse, User +from supertokens_python.types import User +from supertokens_python.types.response import GeneralErrorResponse from ..interfaces import ( ActiveTokenResponse, diff --git a/supertokens_python/recipe/oauth2provider/api/revoke_token.py b/supertokens_python/recipe/oauth2provider/api/revoke_token.py index 233f9cb9e..62bf79796 100644 --- a/supertokens_python/recipe/oauth2provider/api/revoke_token.py +++ b/supertokens_python/recipe/oauth2provider/api/revoke_token.py @@ -19,6 +19,8 @@ from supertokens_python.framework import BaseResponse from supertokens_python.utils import send_200_response, send_non_200_response +from ....types.response import GeneralErrorResponse + if TYPE_CHECKING: from ..interfaces import ( APIInterface, @@ -34,7 +36,6 @@ async def revoke_token_post( ) -> Optional[BaseResponse]: from ..interfaces import ( ErrorOAuth2Response, - GeneralErrorResponse, ) if api_implementation.disable_revoke_token_post is True: diff --git a/supertokens_python/recipe/oauth2provider/interfaces.py b/supertokens_python/recipe/oauth2provider/interfaces.py index 2688f101c..3329013a4 100644 --- a/supertokens_python/recipe/oauth2provider/interfaces.py +++ b/supertokens_python/recipe/oauth2provider/interfaces.py @@ -20,11 +20,10 @@ from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import ( - APIResponse, - GeneralErrorResponse, RecipeUserId, User, ) +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from .oauth2_client import OAuth2Client diff --git a/supertokens_python/recipe/openid/interfaces.py b/supertokens_python/recipe/openid/interfaces.py index 9a8e0f5c0..0c2fd0ae5 100644 --- a/supertokens_python/recipe/openid/interfaces.py +++ b/supertokens_python/recipe/openid/interfaces.py @@ -20,7 +20,7 @@ CreateJwtResultUnsupportedAlgorithm, GetJWKSResult, ) -from supertokens_python.types import APIResponse, GeneralErrorResponse +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from .utils import OpenIdConfig diff --git a/supertokens_python/recipe/passwordless/api/create_code.py b/supertokens_python/recipe/passwordless/api/create_code.py index 375b13ebd..b162e6cf0 100644 --- a/supertokens_python/recipe/passwordless/api/create_code.py +++ b/supertokens_python/recipe/passwordless/api/create_code.py @@ -26,7 +26,7 @@ ContactEmailOrPhoneConfig, ContactPhoneOnlyConfig, ) -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from supertokens_python.utils import ( get_normalised_should_try_linking_with_session_user_flag, send_200_response, diff --git a/supertokens_python/recipe/passwordless/api/implementation.py b/supertokens_python/recipe/passwordless/api/implementation.py index 8072c12f4..c44df52dc 100644 --- a/supertokens_python/recipe/passwordless/api/implementation.py +++ b/supertokens_python/recipe/passwordless/api/implementation.py @@ -67,11 +67,12 @@ from supertokens_python.recipe.session.exceptions import UnauthorisedError from supertokens_python.types import ( AccountInfo, - GeneralErrorResponse, LoginMethod, RecipeUserId, User, ) +from supertokens_python.types.base import AccountInfoInput +from supertokens_python.types.response import GeneralErrorResponse from ...emailverification import EmailVerificationRecipe from ...emailverification.interfaces import CreateEmailVerificationTokenOkResult @@ -89,7 +90,7 @@ def __init__(self, user: User, login_method: Union[LoginMethod, None]): async def get_passwordless_user_by_account_info( tenant_id: str, user_context: Dict[str, Any], - account_info: AccountInfo, + account_info: AccountInfoInput, ) -> Optional[PasswordlessUserResult]: existing_users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, @@ -160,7 +161,7 @@ async def create_code_post( }, } - account_info = AccountInfo( + account_info = AccountInfoInput( email=email, phone_number=phone_number, ) @@ -357,7 +358,7 @@ async def resend_code_post( user_with_matching_login_method = await get_passwordless_user_by_account_info( tenant_id=tenant_id, user_context=user_context, - account_info=AccountInfo( + account_info=AccountInfoInput( email=device_info.email, phone_number=device_info.phone_number, ), @@ -762,7 +763,7 @@ async def email_exists_get( ) -> Union[EmailExistsGetOkResult, GeneralErrorResponse]: users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo(email=email), + account_info=AccountInfoInput(email=email), do_union_of_account_info=False, user_context=user_context, ) @@ -785,7 +786,7 @@ async def phone_number_exists_get( ) -> Union[PhoneNumberExistsGetOkResult, GeneralErrorResponse]: users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( tenant_id=tenant_id, - account_info=AccountInfo(phone_number=phone_number), + account_info=AccountInfoInput(phone_number=phone_number), do_union_of_account_info=False, user_context=user_context, ) diff --git a/supertokens_python/recipe/passwordless/asyncio/__init__.py b/supertokens_python/recipe/passwordless/asyncio/__init__.py index 3bea826b1..29fbfe5ac 100644 --- a/supertokens_python/recipe/passwordless/asyncio/__init__.py +++ b/supertokens_python/recipe/passwordless/asyncio/__init__.py @@ -14,7 +14,6 @@ from typing import Any, Dict, List, Optional, Union from supertokens_python import get_request_from_user_context -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.passwordless.interfaces import ( CheckCodeExpiredUserInputCodeError, CheckCodeIncorrectUserInputCodeError, @@ -45,6 +44,7 @@ ) from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import RecipeUserId +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError async def create_code( diff --git a/supertokens_python/recipe/passwordless/interfaces.py b/supertokens_python/recipe/passwordless/interfaces.py index 3d9361f17..aae9347cc 100644 --- a/supertokens_python/recipe/passwordless/interfaces.py +++ b/supertokens_python/recipe/passwordless/interfaces.py @@ -18,16 +18,15 @@ from typing_extensions import Literal -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.framework import BaseRequest, BaseResponse from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import ( - APIResponse, - GeneralErrorResponse, RecipeUserId, User, ) +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from ...supertokens import AppInfo diff --git a/supertokens_python/recipe/passwordless/recipe_implementation.py b/supertokens_python/recipe/passwordless/recipe_implementation.py index f74d22eb0..489ce4273 100644 --- a/supertokens_python/recipe/passwordless/recipe_implementation.py +++ b/supertokens_python/recipe/passwordless/recipe_implementation.py @@ -17,7 +17,6 @@ from supertokens_python.asyncio import get_user from supertokens_python.auth_utils import ( - LinkingToSessionUserFailedError, link_to_session_if_provided_else_create_primary_user_id_or_link_by_account_info, ) from supertokens_python.normalised_url_path import NormalisedURLPath @@ -51,6 +50,7 @@ from supertokens_python.recipe.passwordless.types import DeviceCode, DeviceType from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError from supertokens_python.utils import log_debug_message diff --git a/supertokens_python/recipe/passwordless/syncio/__init__.py b/supertokens_python/recipe/passwordless/syncio/__init__.py index ddf04846b..852449919 100644 --- a/supertokens_python/recipe/passwordless/syncio/__init__.py +++ b/supertokens_python/recipe/passwordless/syncio/__init__.py @@ -14,7 +14,6 @@ from typing import Any, Dict, List, Optional, Union from supertokens_python.async_to_sync_wrapper import sync -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.passwordless import asyncio from supertokens_python.recipe.passwordless.interfaces import ( CheckCodeExpiredUserInputCodeError, @@ -45,6 +44,7 @@ ) from supertokens_python.recipe.session import SessionContainer from supertokens_python.types import RecipeUserId +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError def create_code( diff --git a/supertokens_python/recipe/session/interfaces.py b/supertokens_python/recipe/session/interfaces.py index 181180686..30cf85f54 100644 --- a/supertokens_python/recipe/session/interfaces.py +++ b/supertokens_python/recipe/session/interfaces.py @@ -30,11 +30,10 @@ from supertokens_python.async_to_sync_wrapper import sync from supertokens_python.types import ( - APIResponse, - GeneralErrorResponse, MaybeAwaitable, RecipeUserId, ) +from supertokens_python.types.response import APIResponse, GeneralErrorResponse from ...utils import resolve from .exceptions import ClaimValidationError diff --git a/supertokens_python/recipe/thirdparty/api/implementation.py b/supertokens_python/recipe/thirdparty/api/implementation.py index af9fc4d83..24f6f2746 100644 --- a/supertokens_python/recipe/thirdparty/api/implementation.py +++ b/supertokens_python/recipe/thirdparty/api/implementation.py @@ -37,7 +37,7 @@ from supertokens_python.recipe.thirdparty.interfaces import APIOptions from supertokens_python.recipe.thirdparty.provider import Provider -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse class APIImplementation(APIInterface): diff --git a/supertokens_python/recipe/thirdparty/asyncio/__init__.py b/supertokens_python/recipe/thirdparty/asyncio/__init__.py index b93db0312..50ee36fc7 100644 --- a/supertokens_python/recipe/thirdparty/asyncio/__init__.py +++ b/supertokens_python/recipe/thirdparty/asyncio/__init__.py @@ -14,7 +14,6 @@ from typing import Any, Dict, Optional, Union -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.thirdparty.interfaces import ( EmailChangeNotAllowedError, @@ -22,6 +21,7 @@ SignInUpNotAllowed, ) from supertokens_python.recipe.thirdparty.recipe import ThirdPartyRecipe +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError async def manually_create_or_update_user( diff --git a/supertokens_python/recipe/thirdparty/interfaces.py b/supertokens_python/recipe/thirdparty/interfaces.py index 57fe2295d..db8012b39 100644 --- a/supertokens_python/recipe/thirdparty/interfaces.py +++ b/supertokens_python/recipe/thirdparty/interfaces.py @@ -16,14 +16,15 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union -from ...types import APIResponse, GeneralErrorResponse, RecipeUserId, User +from ...types import RecipeUserId, User +from ...types.response import APIResponse, GeneralErrorResponse from .provider import Provider, ProviderInput, RedirectUriInfo if TYPE_CHECKING: - from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.framework import BaseRequest, BaseResponse from supertokens_python.recipe.session import SessionContainer from supertokens_python.supertokens import AppInfo + from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError from .types import RawUserInfoFromProvider from .utils import ThirdPartyConfig diff --git a/supertokens_python/recipe/thirdparty/recipe_implementation.py b/supertokens_python/recipe/thirdparty/recipe_implementation.py index 8e4200987..ccc455bc0 100644 --- a/supertokens_python/recipe/thirdparty/recipe_implementation.py +++ b/supertokens_python/recipe/thirdparty/recipe_implementation.py @@ -26,13 +26,14 @@ find_and_create_provider_instance, merge_providers_from_core_and_static, ) -from supertokens_python.types import AccountInfo, RecipeUserId, User +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.base import AccountInfoInput if TYPE_CHECKING: - from supertokens_python.auth_utils import ( + from supertokens_python.querier import Querier + from supertokens_python.types.auth_utils import ( LinkingToSessionUserFailedError, ) - from supertokens_python.querier import Querier from .interfaces import ( EmailChangeNotAllowedError, @@ -116,7 +117,7 @@ async def manually_create_or_update_user( account_linking = AccountLinkingRecipe.get_instance() users = await list_users_by_account_info( tenant_id, - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id=third_party_id, third_party_user_id=third_party_user_id, diff --git a/supertokens_python/recipe/thirdparty/syncio/__init__.py b/supertokens_python/recipe/thirdparty/syncio/__init__.py index 4481c8131..16eeee5a4 100644 --- a/supertokens_python/recipe/thirdparty/syncio/__init__.py +++ b/supertokens_python/recipe/thirdparty/syncio/__init__.py @@ -14,13 +14,13 @@ from typing import Any, Dict, Optional, Union from supertokens_python.async_to_sync_wrapper import sync -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.thirdparty.interfaces import ( EmailChangeNotAllowedError, ManuallyCreateOrUpdateUserOkResult, SignInUpNotAllowed, ) +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError def manually_create_or_update_user( diff --git a/supertokens_python/recipe/totp/api/implementation.py b/supertokens_python/recipe/totp/api/implementation.py index b533a2972..dc7209960 100644 --- a/supertokens_python/recipe/totp/api/implementation.py +++ b/supertokens_python/recipe/totp/api/implementation.py @@ -23,7 +23,7 @@ from supertokens_python.recipe.multifactorauth.recipe import MultiFactorAuthRecipe from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.session.exceptions import UnauthorisedError # noqa: E402 -from supertokens_python.types import GeneralErrorResponse +from supertokens_python.types.response import GeneralErrorResponse from ..interfaces import APIInterface, APIOptions from ..types import ( diff --git a/supertokens_python/recipe/totp/interfaces.py b/supertokens_python/recipe/totp/interfaces.py index bc849c0e0..bd7f36249 100644 --- a/supertokens_python/recipe/totp/interfaces.py +++ b/supertokens_python/recipe/totp/interfaces.py @@ -22,7 +22,7 @@ from supertokens_python.framework import BaseRequest, BaseResponse from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.totp.recipe import TOTPRecipe - from supertokens_python.types import GeneralErrorResponse + from supertokens_python.types.response import GeneralErrorResponse from .types import ( CreateDeviceOkResult, diff --git a/supertokens_python/recipe/totp/types.py b/supertokens_python/recipe/totp/types.py index d641fc980..d863696ba 100644 --- a/supertokens_python/recipe/totp/types.py +++ b/supertokens_python/recipe/totp/types.py @@ -16,7 +16,7 @@ from typing_extensions import Literal -from supertokens_python.types import APIResponse +from supertokens_python.types.response import APIResponse from .interfaces import APIInterface, RecipeInterface diff --git a/supertokens_python/recipe/webauthn/__init__.py b/supertokens_python/recipe/webauthn/__init__.py new file mode 100644 index 000000000..aa0c29007 --- /dev/null +++ b/supertokens_python/recipe/webauthn/__init__.py @@ -0,0 +1,83 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Optional + +from supertokens_python.recipe.webauthn.functions import ( + consume_recover_account_token, + create_recover_account_link, + generate_recover_account_token, + get_credential, + get_generated_options, + get_user_from_recover_account_token, + list_credentials, + recover_account, + register_credential, + register_options, + remove_credential, + remove_generated_options, + send_email, + send_recover_account_email, + sign_in, + sign_in_options, + sign_up, + verify_credentials, +) +from supertokens_python.recipe.webauthn.interfaces.api import APIInterface, APIOptions +from supertokens_python.recipe.webauthn.interfaces.recipe import RecipeInterface +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.types.config import ( + NormalisedWebauthnConfig, + WebauthnConfig, +) + +# Some Pydantic models need a rebuild to resolve ForwardRefs +# Referencing imports here to prevent lint errors. +# Caveat: These will be available for import from this module directly. +APIInterface # type: ignore +RecipeInterface # type: ignore +NormalisedWebauthnConfig # type: ignore + + +# APIOptions - ApiInterface -> WebauthnConfig/NormalisedWebauthnConfig -> RecipeInterface +APIOptions.model_rebuild() + + +def init(config: Optional[WebauthnConfig] = None): + return WebauthnRecipe.init(config=config) + + +__all__ = [ + "init", + "WebauthnConfig", + "WebauthnRecipe", + "consume_recover_account_token", + "create_recover_account_link", + "generate_recover_account_token", + "get_credential", + "get_generated_options", + "get_user_from_recover_account_token", + "list_credentials", + "recover_account", + "register_credential", + "register_options", + "remove_credential", + "remove_generated_options", + "send_email", + "send_recover_account_email", + "sign_in", + "sign_in_options", + "sign_up", + "verify_credentials", +] diff --git a/supertokens_python/recipe/webauthn/api/__init__.py b/supertokens_python/recipe/webauthn/api/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/api/email_exists.py b/supertokens_python/recipe/webauthn/api/email_exists.py new file mode 100644 index 000000000..6ddf291d2 --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/email_exists.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def email_exists_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_email_exists_get: + return None + + email = options.req.get_query_param("email") + + if email is None: + raise_bad_input_exception("Please provide the email as a GET param") + + result = await api_implementation.email_exists_get( + email=email, tenant_id=tenant_id, options=options, user_context=user_context + ) + + return send_200_response(data_json=result.to_json(), response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/generate_recover_account_token.py b/supertokens_python/recipe/webauthn/api/generate_recover_account_token.py new file mode 100644 index 000000000..0d36af0ad --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/generate_recover_account_token.py @@ -0,0 +1,52 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def generate_recover_account_token_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_generate_recover_account_token_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + email = body["email"] + if email is None or not isinstance(email, str): + raise_bad_input_exception("Please provide the email") + + result = await api_implementation.generate_recover_account_token_post( + email=email, tenant_id=tenant_id, options=options, user_context=user_context + ) + + return send_200_response(data_json=result.to_json(), response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/implementation.py b/supertokens_python/recipe/webauthn/api/implementation.py new file mode 100644 index 000000000..d0cdb357c --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/implementation.py @@ -0,0 +1,1153 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Optional, Union, cast + +from typing_extensions import Unpack + +from supertokens_python.asyncio import get_user +from supertokens_python.auth_utils import ( + get_authenticating_user_and_add_to_current_tenant_if_required, + is_fake_email, + post_auth_checks, + pre_auth_checks, +) +from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe +from supertokens_python.recipe.accountlinking.types import ( + AccountInfoWithRecipeId, + AccountInfoWithRecipeIdAndUserId, + ShouldNotAutomaticallyLink, +) +from supertokens_python.recipe.emailverification.recipe import EmailVerificationRecipe +from supertokens_python.recipe.session.interfaces import SessionContainer +from supertokens_python.recipe.webauthn.constants import ( + DEFAULT_REGISTER_OPTIONS_ATTESTATION, + DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + DEFAULT_REGISTER_OPTIONS_TIMEOUT, + DEFAULT_REGISTER_OPTIONS_USER_PRESENCE, + DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + DEFAULT_SIGNIN_OPTIONS_TIMEOUT, + DEFAULT_SIGNIN_OPTIONS_USER_PRESENCE, + DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, +) +from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + EmailExistsGetResponse, + GenerateRecoverAccountTokenPOSTErrorResponse, + RecoverAccountNotAllowedErrorResponse, + RecoverAccountPOSTErrorResponse, + RecoverAccountPOSTResponse, + RegisterCredentialNotAllowedErrorResponse, + RegisterCredentialPOSTErrorResponse, + RegisterOptionsPOSTErrorResponse, + RegisterOptionsPOSTKwargsInput, + RegisterOptionsPOSTResponse, + SignInNotAllowedErrorResponse, + SignInOptionsPOSTErrorResponse, + SignInOptionsPOSTResponse, + SignInPOSTErrorResponse, + SignInPOSTResponse, + SignUpNotAllowedErrorResponse, + SignUpPOSTErrorResponse, + SignUpPOSTResponse, + TypeWebauthnEmailDeliveryInput, + WebauthnRecoverAccountEmailDeliveryUser, +) +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + AuthenticationPayload, + CredentialNotFoundErrorResponse, + EmailAlreadyExistsErrorResponse, + InvalidAuthenticatorErrorResponse, + InvalidCredentialsErrorResponse, + InvalidOptionsErrorResponse, + OptionsNotFoundErrorResponse, + RecoverAccountTokenInvalidErrorResponse, + RegistrationPayload, + UnknownUserIdErrorResponse, +) +from supertokens_python.recipe.webauthn.types.base import ( + WebauthnInfoInput, +) +from supertokens_python.recipe.webauthn.utils import get_recover_account_link +from supertokens_python.types.base import ( + AccountInfoInput, + LoginMethod, + RecipeUserId, + User, + UserContext, +) +from supertokens_python.types.response import ( + GeneralErrorResponse, + OkResponseBaseModel, +) +from supertokens_python.utils import ( + get_error_response_reason_from_map, + log_debug_message, +) + + +class APIImplementation(APIInterface): + async def register_options_post( + self, + *, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + **kwargs: Unpack[RegisterOptionsPOSTKwargsInput], + ) -> Union[ + RegisterOptionsPOSTResponse, + GeneralErrorResponse, + RegisterOptionsPOSTErrorResponse, + ]: + relying_party_id = await options.config.get_relying_party_id( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + relying_party_name = await options.config.get_relying_party_name( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + origin = await options.config.get_origin( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + + response = await options.recipe_implementation.register_options( + **kwargs, + relying_party_id=relying_party_id, + relying_party_name=relying_party_name, + origin=origin, + resident_key=DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + user_verification=DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + user_presence=DEFAULT_REGISTER_OPTIONS_USER_PRESENCE, + attestation=DEFAULT_REGISTER_OPTIONS_ATTESTATION, + supported_algorithm_ids=DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + timeout=DEFAULT_REGISTER_OPTIONS_TIMEOUT, + tenant_id=tenant_id, + user_context=user_context, + ) + + if response.status != "OK": + return response + + return RegisterOptionsPOSTResponse.from_json(response.to_json()) + + async def sign_in_options_post( + self, + *, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + SignInOptionsPOSTResponse, + GeneralErrorResponse, + SignInOptionsPOSTErrorResponse, + ]: + relying_party_id = await options.config.get_relying_party_id( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + relying_party_name = await options.config.get_relying_party_name( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + # use this to get the full url instead of only the domain url + origin = await options.config.get_origin( + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + + response = await options.recipe_implementation.sign_in_options( + user_verification=DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + user_presence=DEFAULT_SIGNIN_OPTIONS_USER_PRESENCE, + origin=origin, + relying_party_id=relying_party_id, + relying_party_name=relying_party_name, + timeout=DEFAULT_SIGNIN_OPTIONS_TIMEOUT, + tenant_id=tenant_id, + user_context=user_context, + ) + + if response.status != "OK": + return response + + return SignInOptionsPOSTResponse.from_json( + { + **response.to_json(), + "rp_id": relying_party_id, + } + ) + + async def sign_up_post( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + session: Optional[SessionContainer], + should_try_linking_with_session_user: Optional[bool], + options: APIOptions, + user_context: UserContext, + ) -> Union[SignUpPOSTResponse, GeneralErrorResponse, SignUpPOSTErrorResponse]: + error_code_map = { + "SIGN_UP_NOT_ALLOWED": "Cannot sign up due to security reasons. Please try logging in, use a different login method or contact support. (ERR_CODE_025)", + "LINKING_TO_SESSION_USER_FAILED": { + "EMAIL_VERIFICATION_REQUIRED": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_026)", + "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_027)", + "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_028)", + "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_029)", + }, + } + + generated_options = await options.recipe_implementation.get_generated_options( + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + + if generated_options.status != "OK": + return generated_options + + email = generated_options.email + + # NOTE: Following checks will likely never throw an error as the + # check for type is done in a parent function but they are kept + # here to be on the safe side. + if not email: + raise Exception( + "Should never come here since we already check that the email " + "value is a string in validate_email_address" + ) + + pre_auth_checks_response = await pre_auth_checks( + authenticating_account_info=AccountInfoWithRecipeId( + recipe_id="webauthn", + email=email, + ), + factor_ids=["webauthn"], + is_sign_up=True, + is_verified=is_fake_email(email), + sign_in_verifies_login_method=False, + skip_session_user_update_in_core=False, + authenticating_user=None, # since this is a sign up + tenant_id=tenant_id, + user_context=user_context, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + ) + + if pre_auth_checks_response.status == "SIGN_UP_NOT_ALLOWED": + conflicting_users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=AccountInfoInput(email=email), + do_union_of_account_info=False, + user_context=user_context, + ) + + for user in conflicting_users: + for login_method in user.login_methods: + if ( + login_method.recipe_id == "webauthn" + and login_method.has_same_email_as(email) + ): + return EmailAlreadyExistsErrorResponse() + + if pre_auth_checks_response.status != "OK": + return SignUpNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=pre_auth_checks_response.status, + error_code_map=error_code_map, + ) + ) + + if is_fake_email(email) and pre_auth_checks_response.is_first_factor: + # Fake emails cannot be used as a first factor + return EmailAlreadyExistsErrorResponse() + + sign_up_response = await options.recipe_implementation.sign_up( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + tenant_id=tenant_id, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + user_context=user_context, + ) + + if isinstance( + sign_up_response, + ( + EmailAlreadyExistsErrorResponse, + InvalidCredentialsErrorResponse, + InvalidOptionsErrorResponse, + OptionsNotFoundErrorResponse, + ), + ): + # We should only return the status, because the core also adds a reason for most of these errors + return sign_up_response + + if isinstance(sign_up_response, InvalidAuthenticatorErrorResponse): + return InvalidAuthenticatorErrorResponse(reason=sign_up_response.reason) + + if sign_up_response.status != "OK": + return SignUpNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=sign_up_response.status, + error_code_map=error_code_map, + ) + ) + + post_auth_checks_response = await post_auth_checks( + authenticated_user=sign_up_response.user, + recipe_user_id=sign_up_response.recipe_user_id, + is_sign_up=True, + factor_id="webauthn", + session=session, + request=options.req, + tenant_id=tenant_id, + user_context=user_context, + ) + + if post_auth_checks_response.status != "OK": + # It should never actually come here, but we do it cause of consistency. + # If it does come here (in case there is a bug), it would make this conditional throw + # anyway, cause there is no SIGN_IN_NOT_ALLOWED in the errorCodeMap. + return SignUpNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=post_auth_checks_response.status, + error_code_map=error_code_map, + ) + ) + + return SignUpPOSTResponse( + session=post_auth_checks_response.session, + user=post_auth_checks_response.user, + ) + + async def sign_in_post( + self, + *, + webauthn_generated_options_id: str, + credential: AuthenticationPayload, + tenant_id: str, + session: Optional[SessionContainer], + should_try_linking_with_session_user: Optional[bool], + options: APIOptions, + user_context: UserContext, + ) -> Union[SignInPOSTResponse, GeneralErrorResponse, SignInPOSTErrorResponse]: + error_code_map = { + "SIGN_IN_NOT_ALLOWED": "Cannot sign in due to security reasons. Please try recovering your account, use a different login method or contact support. (ERR_CODE_030)", + "LINKING_TO_SESSION_USER_FAILED": { + "EMAIL_VERIFICATION_REQUIRED": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_031)", + "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_032)", + "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_033)", + "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR": "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_034)", + }, + } + + verify_result_response = await options.recipe_implementation.verify_credentials( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + + if verify_result_response.status != "OK": + return InvalidCredentialsErrorResponse() + + generated_options_response = ( + await options.recipe_implementation.get_generated_options( + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + ) + + if generated_options_response.status != "OK": + return InvalidCredentialsErrorResponse() + + async def check_credentials_on_tenant(tenant_id: str): + return True + + authenticating_user = ( + await get_authenticating_user_and_add_to_current_tenant_if_required( + webauthn=WebauthnInfoInput(credential_id=credential.id), + user_context=user_context, + recipe_id="webauthn", + session=session, + tenant_id=tenant_id, + check_credentials_on_tenant=check_credentials_on_tenant, + email=None, + phone_number=None, + third_party=None, + ) + ) + + is_verified = ( + authenticating_user is not None + and authenticating_user.login_method is not None + and authenticating_user.login_method.verified + ) + + # We check this before preAuthChecks, because that function assumes that if isSignUp is false, + # then authenticatingUser is defined. While it wouldn't technically cause any problems with + # the implementation of that function, this way we can guarantee that either isSignInAllowed or + # isSignUpAllowed will be called as expected. + if authenticating_user is None: + return InvalidCredentialsErrorResponse() + + # We find the email of the user that has the same credentialId as the one we are verifying + def email_filter(login_method: LoginMethod) -> bool: + return ( + login_method.recipe_id == "webauthn" + and login_method.webauthn is not None + and credential.id in login_method.webauthn.credential_ids + ) + + email = next(filter(email_filter, authenticating_user.user.login_methods), None) + if email is None or email.email is None: + raise Exception("This should never happen: webauthn user has no email") + + email = email.email + + pre_auth_checks_response = await pre_auth_checks( + authenticating_account_info=AccountInfoWithRecipeId( + recipe_id="webauthn", + email=email, + ), + factor_ids=["webauthn"], + is_sign_up=False, + authenticating_user=authenticating_user.user, + is_verified=is_verified, + sign_in_verifies_login_method=False, + skip_session_user_update_in_core=False, + tenant_id=tenant_id, + user_context=user_context, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + ) + if pre_auth_checks_response.status == "SIGN_IN_NOT_ALLOWED": + raise Exception( + "This should never happen: pre-auth checks should not fail for sign in" + ) + if pre_auth_checks_response.status != "OK": + return SignInNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=pre_auth_checks_response.status, + error_code_map=error_code_map, + ) + ) + + if is_fake_email(email) and pre_auth_checks_response.is_first_factor: + # Fake emails cannot be used as a first factor + return InvalidCredentialsErrorResponse() + + sign_in_response = await options.recipe_implementation.sign_in( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + tenant_id=tenant_id, + user_context=user_context, + ) + + if isinstance(sign_in_response, InvalidCredentialsErrorResponse): + return sign_in_response + + if isinstance( + sign_in_response, + ( + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, + CredentialNotFoundErrorResponse, + UnknownUserIdErrorResponse, + OptionsNotFoundErrorResponse, + ), + ): + return InvalidCredentialsErrorResponse() + + if sign_in_response.status != "OK": + return SignInNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=sign_in_response.status, + error_code_map=error_code_map, + ) + ) + + post_auth_checks_response = await post_auth_checks( + authenticated_user=sign_in_response.user, + recipe_user_id=sign_in_response.recipe_user_id, + is_sign_up=False, + factor_id="webauthn", + session=session, + request=options.req, + tenant_id=tenant_id, + user_context=user_context, + ) + if post_auth_checks_response.status != "OK": + return SignInNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=post_auth_checks_response.status, + error_code_map=error_code_map, + ) + ) + + return SignInPOSTResponse( + session=post_auth_checks_response.session, + user=post_auth_checks_response.user, + ) + + async def generate_recover_account_token_post( + self, + *, + email: str, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + OkResponseBaseModel, + GeneralErrorResponse, + GenerateRecoverAccountTokenPOSTErrorResponse, + ]: + # NOTE: Check for email being a non-string value. This check will likely + # never evaluate to `true` as there is an upper-level check for the type + # in validation but kept here to be safe. + if not isinstance(email, str): # type: ignore + raise Exception( + "Should never come here since we already check that the email " + "value is a string in validateFormFieldsOrThrowError" + ) + + # This function will be reused in different parts of the flow below. + async def generate_and_send_recover_account_token( + primary_user_id: str, recipe_user_id: Optional[RecipeUserId] + ) -> OkResponseBaseModel: + # The user ID here can be primary or recipe level + response = ( + await options.recipe_implementation.generate_recover_account_token( + tenant_id=tenant_id, + user_id=primary_user_id + if recipe_user_id is None + else recipe_user_id.get_as_string(), + email=email, + user_context=user_context, + ) + ) + + if isinstance(response, UnknownUserIdErrorResponse): + log_debug_message( + "Recover account email not sent, unknown user id: " + f"{primary_user_id if recipe_user_id is None else recipe_user_id.get_as_string()}" + ) + return OkResponseBaseModel() + + recover_account_link = get_recover_account_link( + app_info=options.app_info, + token=response.token, + tenant_id=tenant_id, + request=options.req, + user_context=user_context, + ) + + log_debug_message(f"Sending recover account email to {email}") + await options.email_delivery.ingredient_interface_impl.send_email( + template_vars=TypeWebauthnEmailDeliveryInput( + type="RECOVER_ACCOUNT", + user=WebauthnRecoverAccountEmailDeliveryUser( + id=primary_user_id, + recipe_user_id=recipe_user_id, + email=email, + ), + recover_account_link=recover_account_link, + tenant_id=tenant_id, + ), + user_context=user_context, + ) + + return OkResponseBaseModel() + + # Check if primary_user_id is linked with this email + users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=AccountInfoInput(email=email), + do_union_of_account_info=False, + user_context=user_context, + ) + + # We find the recipe user ID of the webauthn account from the user's list for later use + webauthn_account: Optional[AccountInfoWithRecipeIdAndUserId] = None + for user in users: + for login_method in user.login_methods: + if ( + login_method.recipe_id == "webauthn" + and login_method.has_same_email_as(email) + ): + webauthn_account = AccountInfoWithRecipeIdAndUserId.from_account_info_or_login_method( + login_method + ) + break + + # We find the primary user ID from the user's list for later use + primary_user_associated_with_email: Optional[User] = None + for user in users: + if user.is_primary_user: + primary_user_associated_with_email = user + break + + # First we check if there even exists a primary user that has the input email + # If not, then we do the regular flow for recover account + if primary_user_associated_with_email is None: + if webauthn_account is None: + log_debug_message( + f"Recover account email not sent, unknown user email: {email}" + ) + return OkResponseBaseModel() + + if webauthn_account.recipe_user_id is None: + raise Exception( + "This should never happen: `recipe_user_id` should not be None" + ) + + return await generate_and_send_recover_account_token( + primary_user_id=webauthn_account.recipe_user_id.get_as_string(), + recipe_user_id=webauthn_account.recipe_user_id, + ) + + # Next we check if there is any login method in which the input email is verified. + # If that is the case, then it's proven that the user owns the email and we can + # trust linking of the webauthn account. + email_verified = False + for login_method in primary_user_associated_with_email.login_methods: + if login_method.has_same_email_as(email) and login_method.verified: + email_verified = True + break + + # Finally, we check if the primary user has any other email / phone number + # associated with this account - and if it does, then it means that + # there is a risk of account takeover, so we do not allow the token to be generated + has_other_email_or_phone = False + for login_method in primary_user_associated_with_email.login_methods: + if ( + login_method.email is not None + and not login_method.has_same_email_as(email) + ) or ( + login_method.phone_number is not None + and login_method.phone_number != email + ): + has_other_email_or_phone = True + break + + if not email_verified and has_other_email_or_phone: + return RecoverAccountNotAllowedErrorResponse( + reason=( + "Recover account link was not created because of account take over risk. " + "Please contact support. (ERR_CODE_001)" + ), + ) + + should_do_account_linking_response = await AccountLinkingRecipe.get_instance().config.should_do_automatic_account_linking( + webauthn_account + if webauthn_account is not None + else AccountInfoWithRecipeIdAndUserId( + recipe_id="webauthn", email=email, recipe_user_id=None + ), + primary_user_associated_with_email, + None, + tenant_id, + user_context, + ) + + # Now we need to check that if there exists any webauthn user at all + # for the input email. If not, then it implies that when the token is consumed, + # then we will create a new user - so we should only generate the token if + # the criteria for the new user is met. + if webauthn_account is None: + # this means that there is no webauthn user that exists for the input email. + # So we check for the sign up condition and only go ahead if that condition is + # met. + + # But first we must check if account linking is enabled at all - cause if it's + # not, then the new webauthn user that will be created in recover account + # code consume cannot be linked to the primary user - therefore, we should + # not generate a recover account reset token + if isinstance( + should_do_account_linking_response, ShouldNotAutomaticallyLink + ): + log_debug_message( + "Recover account email not sent, since webauthn user didn't exist, " + "and account linking not enabled" + ) + return OkResponseBaseModel() + + is_sign_up_allowed = await AccountLinkingRecipe.get_instance().is_sign_up_allowed( + new_user=AccountInfoWithRecipeId( + recipe_id="webauthn", + email=email, + ), + is_verified=True, # Because when the token is consumed, we will mark the email as verified + session=None, + tenant_id=tenant_id, + user_context=user_context, + ) + + if is_sign_up_allowed: + # Notice that we pass in the primary user ID here. This means that + # we will be creating a new webauthn account when the token + # is consumed and linking it to this primary user. + return await generate_and_send_recover_account_token( + primary_user_id=primary_user_associated_with_email.id, + recipe_user_id=None, + ) + + log_debug_message( + f"Recover account email not sent, is_sign_up_allowed returned false for email: {email}" + ) + return OkResponseBaseModel() + + # At this point, we know that some webauthn user exists with this email + # and also some primary user ID exist. We now need to find out if they are linked + # together or not. If they are linked together, then we can just generate the token + # else we check for more security conditions (since we will be linking them post token generation) + are_the_two_accounts_linked = False + for login_method in primary_user_associated_with_email.login_methods: + # `webauthn_account.recipe_user_id` is guaranteed to be not None + if ( + login_method.recipe_user_id.get_as_string() + == webauthn_account.recipe_user_id.get_as_string() # type: ignore + ): + are_the_two_accounts_linked = True + break + + if are_the_two_accounts_linked: + return await generate_and_send_recover_account_token( + primary_user_associated_with_email.id, webauthn_account.recipe_user_id + ) + + # Here we know that the two accounts are NOT linked. We now need to check for an + # extra security measure here to make sure that the input email in the primary user + # is verified, and if not, we need to make sure that there is no other email / phone number + # associated with the primary user account. If there is, then we do not proceed. + + # This security measure helps prevent the following attack: + # An attacker has email A and they create an account using TP and it doesn't matter if A is verified or not. + # Now they create another account using the webauthn with email A and verifies it. Both these accounts are linked. + # Now the attacker changes the email for webauthn recipe to B which makes the webauthn account unverified, but + # it's still linked. + + # If the real owner of B tries to signup using webauthn, it will say that the account already exists so they may + # try to recover the account which should be denied because then they will end up getting access to attacker's + # account and verify the webauthn account. + + # The problem with this situation is if the webauthn account is verified, it will allow further sign-ups with + # email B which will also be linked to this primary account (that the attacker had created with email A). + + # It is important to realize that the attacker had created another account with A because if they hadn't done that, + # then they wouldn't have access to this account after the real user recovers the account which is why it is + # important to check there is another non-webauthn account linked to the primary such that the email is not the same as B. + + # Exception to the above is that, if there is a third recipe account linked to the above two accounts and + # has B as verified, then we should allow recover account token generation because user has already proven that the + # owns the email B + + # But first, this only matters it the user cares about checking for email verification status. + + if isinstance(should_do_account_linking_response, ShouldNotAutomaticallyLink): + if webauthn_account.recipe_user_id is None: + raise Exception( + "This should never happen: `recipe_user_id` should not be None" + ) + # here we will go ahead with the token generation cause + # even when the token is consumed, we will not be linking the accounts + # so no need to check for anything + return await generate_and_send_recover_account_token( + primary_user_id=webauthn_account.recipe_user_id.get_as_string(), + recipe_user_id=webauthn_account.recipe_user_id, + ) + + if should_do_account_linking_response.should_require_verification: + # the checks below are related to email verification, and if the user + # does not care about that, then we should just continue with token generation + return await generate_and_send_recover_account_token( + primary_user_id=primary_user_associated_with_email.id, + recipe_user_id=webauthn_account.recipe_user_id, + ) + + return await generate_and_send_recover_account_token( + primary_user_id=primary_user_associated_with_email.id, + recipe_user_id=webauthn_account.recipe_user_id, + ) + + async def recover_account_post( + self, + *, + token: str, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + RecoverAccountPOSTResponse, + GeneralErrorResponse, + RecoverAccountPOSTErrorResponse, + ]: + async def mark_email_as_verified(recipe_user_id: RecipeUserId, email: str): + email_verification_instance = ( + EmailVerificationRecipe.get_instance_optional() + ) + if email_verification_instance is not None: + token_response = await email_verification_instance.recipe_implementation.create_email_verification_token( + tenant_id=tenant_id, + recipe_user_id=recipe_user_id, + email=email, + user_context=user_context, + ) + + if token_response.status == "OK": + await email_verification_instance.recipe_implementation.verify_email_using_token( + tenant_id=tenant_id, + token=token_response.token, + # We pass a false here since we do account-linking in this API + # after this function is called + attempt_account_linking=False, + user_context=user_context, + ) + + async def do_register_credential_and_verify_email_and_try_link_if_not_primary( + recipe_user_id: RecipeUserId, + ) -> Union[ + RecoverAccountPOSTResponse, + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, + GeneralErrorResponse, + ]: + update_response = await options.recipe_implementation.register_credential( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + recipe_user_id=recipe_user_id.get_as_string(), + user_context=user_context, + ) + + if isinstance( + update_response, + ( + InvalidAuthenticatorErrorResponse, + InvalidOptionsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidCredentialsErrorResponse, + ), + ): + return update_response + + # Status == "OK" + # If the update was successful, we try to mark the email as verified. + # We do this because we assume that the recover account token was delivered by email + # (and to the appropriate email address) + # so consuming it means that the user actually has access to the emails we send. + + # We only do this if the recover account was successful, otherwise the following scenario is possible: + # 1. User M: signs up using the email of user V with their own credential. They can't validate the email, + # because it is not their own. + # 2. User A: tries signing up but sees the email already exists message + # 3. User A: recovers the account, but somehow this fails + # If we verified (and linked) the existing user with the original credential, User M would get access to the + # current user and any linked users. + + await mark_email_as_verified( + recipe_user_id=recipe_user_id, email=email_for_whom_token_was_generated + ) + # We refresh the user information here, because the verification status may be updated, which is used during linking. + updated_user_after_email_verification = await get_user( + user_id=recipe_user_id.get_as_string(), + user_context=user_context, + ) + if updated_user_after_email_verification is None: + raise Exception( + "This should never happen: user deleted during recover account" + ) + + if updated_user_after_email_verification.is_primary_user: + # If the user is a primary user, we do not need to do any linking + return RecoverAccountPOSTResponse( + user=updated_user_after_email_verification, + email=email_for_whom_token_was_generated, + ) + + # If the user is not primary: + # Now we try and link the accounts. + # The function below will try and also create a primary user of the new account, this can happen if: + # 1. the user was unverified and linking requires verification + # We do not take try linking by session here, since this is supposed to be called without a session + # Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking + link_response = await AccountLinkingRecipe.get_instance().try_linking_by_account_info_or_create_primary_user( + tenant_id=tenant_id, + input_user=updated_user_after_email_verification, + session=None, + user_context=user_context, + ) + user_after_we_tried_linking = ( + # Explicit cast since we will have a user when the status is OK + cast(User, link_response.user) + if link_response.status == "OK" + else updated_user_after_email_verification + ) + return RecoverAccountPOSTResponse( + email=email_for_whom_token_was_generated, + user=user_after_we_tried_linking, + ) + + token_consumption_response = ( + await options.recipe_implementation.consume_recover_account_token( + token=token, + tenant_id=tenant_id, + user_context=user_context, + ) + ) + + if isinstance( + token_consumption_response, RecoverAccountTokenInvalidErrorResponse + ): + return token_consumption_response + + user_id_for_whom_token_was_generated = token_consumption_response.user_id + email_for_whom_token_was_generated = token_consumption_response.email + + existing_user = await get_user( + user_id=token_consumption_response.user_id, + user_context=user_context, + ) + + if existing_user is None: + # This should happen only cause of a race condition where the user + # might be deleted before token creation and consumption. + # Also note that this being undefined doesn't mean that the webauthn + # user does not exist, but it means that there is no recipe or primary user + # for whom the token was generated. + return RecoverAccountTokenInvalidErrorResponse() + + if not existing_user.is_primary_user: + # This means that the existing user is not a primary account, which implies that + # it must be a non linked webauthn account. In this case, we simply update the credential. + # Linking to an existing account will be done after the user goes through the email + # verification flow once they log in (if applicable). + return await do_register_credential_and_verify_email_and_try_link_if_not_primary( + recipe_user_id=RecipeUserId( + recipe_user_id=user_id_for_whom_token_was_generated + ) + ) + + # User is a primary user + # If this user contains an webauthn account for whom the token was generated, + # then we update that user's credential. + webauthn_user_is_linked_to_existing_user = False + for login_method in existing_user.login_methods: + if ( + login_method.recipe_id == "webauthn" + and login_method.recipe_user_id.get_as_string() + == user_id_for_whom_token_was_generated + ): + webauthn_user_is_linked_to_existing_user = True + break + + if webauthn_user_is_linked_to_existing_user: + return await do_register_credential_and_verify_email_and_try_link_if_not_primary( + recipe_user_id=RecipeUserId( + recipe_user_id=user_id_for_whom_token_was_generated + ) + ) + + # This means that the existingUser does not have an webauthn user associated + # with it. It could now mean that no webauthn user exists, or it could mean that + # the the webauthn user exists, but it's not linked to the current account. + # If a webauthn user doesn't exist, we will create one, and link it to the existing account. + # If webauthn user exists, then it means there is some race condition cause + # then the token should have been generated for that user instead of the primary user, + # and it shouldn't have come into this branch. So we can simply send a recover account + # invalid error and the user can try again. + + # NOTE: We do not ask the dev if we should do account linking or not here + # cause we already have asked them this when generating an recover account reset token. + # In the edge case that the dev changes account linking allowance from true to false + # when it comes here, only a new recipe user id will be created and not linked + # cause createPrimaryUserIdOrLinkAccounts will disallow linking. This doesn't + # really cause any security issue. + create_user_response = ( + await options.recipe_implementation.create_new_recipe_user( + tenant_id=tenant_id, + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + user_context=user_context, + ) + ) + + if isinstance( + create_user_response, + ( + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, + ), + ): + return create_user_response + + if isinstance(create_user_response, EmailAlreadyExistsErrorResponse): + # this means that the user already existed and we can just return an invalid + # token (see the above comment) + return RecoverAccountTokenInvalidErrorResponse() + + # we mark the email as verified because recover account also requires + # access to the email to work.. This has a good side effect that + # any other login method with the same email in existingAccount will also get marked + # as verified. + await mark_email_as_verified( + recipe_user_id=create_user_response.user.login_methods[0].recipe_user_id, + email=token_consumption_response.email, + ) + updated_user = await get_user( + user_id=create_user_response.user.id, user_context=user_context + ) + if updated_user is None: + raise Exception( + "This should never happen: user deleted during recover account" + ) + create_user_response.user = updated_user + + # Now we try and link the accounts. The function below will try and also + # create a primary user of the new account, and if it does that, it's OK.. + # But in most cases, it will end up linking to existing account since the + # email is shared. + # We do not take try linking by session here, since this is supposed to be called without a session + # Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking + link_response = await AccountLinkingRecipe.get_instance().try_linking_by_account_info_or_create_primary_user( + tenant_id=tenant_id, + input_user=create_user_response.user, + session=None, + user_context=user_context, + ) + + # Link response user will always be non-None if status == "OK" + user_after_linking = ( + cast(User, link_response.user) + if link_response.status == "OK" + else create_user_response.user + ) + + if ( + link_response.status == "OK" + and cast(User, link_response.user).id != existing_user.id + ): + # this means that the account we just linked to + # was not the one we had expected to link it to. This can happen + # due to some race condition or the other.. Either way, this + # is not an issue and we can just return OK + pass + + return RecoverAccountPOSTResponse( + email=token_consumption_response.email, + user=user_after_linking, + ) + + async def register_credential_post( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + session: SessionContainer, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + OkResponseBaseModel, + GeneralErrorResponse, + RegisterCredentialPOSTErrorResponse, + ]: + error_code_map = { + "REGISTER_CREDENTIAL_NOT_ALLOWED": "Cannot register credential due to security reasons. Please try logging in, use a different login method or contact support. (ERR_CODE_007)", + "INVALID_AUTHENTICATOR_ERROR": "The device used for authentication is not supported. Please use a different device. (ERR_CODE_026)", + "INVALID_CREDENTIALS_ERROR": "The credentials are incorrect. Please make sure you are using the correct credentials. (ERR_CODE_025)", + } + + generated_options = await options.recipe_implementation.get_generated_options( + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + if generated_options.status != "OK": + return generated_options + + email = generated_options.email + # NOTE: Following checks will likely never throw an error as the + # check for type is done in a parent function but they are kept + # here to be on the safe side. + if not isinstance(email, str): # type: ignore + raise Exception( + "Should never come here since we already check that the email " + "value is a string in validate_email_address" + ) + + register_credential_response = ( + await options.recipe_implementation.register_credential( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + user_context=user_context, + recipe_user_id=session.get_recipe_user_id().get_as_string(), + ) + ) + + if register_credential_response.status != "OK": + return RegisterCredentialNotAllowedErrorResponse( + reason=get_error_response_reason_from_map( + response_status=register_credential_response.status, + error_code_map=error_code_map, + ) + ) + + return OkResponseBaseModel() + + async def email_exists_get( + self, + *, + email: str, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[EmailExistsGetResponse, GeneralErrorResponse]: + users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info( + tenant_id=tenant_id, + account_info=AccountInfoInput(email=email), + do_union_of_account_info=False, + user_context=user_context, + ) + + webauthn_user_exists = False + for user in users: + for login_method in user.login_methods: + if ( + login_method.recipe_id == "webauthn" + and login_method.has_same_email_as(email) + ): + webauthn_user_exists = True + break + + return EmailExistsGetResponse(exists=webauthn_user_exists) diff --git a/supertokens_python/recipe/webauthn/api/recover_account.py b/supertokens_python/recipe/webauthn/api/recover_account.py new file mode 100644 index 000000000..f89dfbcab --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/recover_account.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import ValidationError + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + InvalidCredentialsErrorResponse, + RegistrationPayload, +) +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def recover_account_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_recover_account_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + webauthn_generated_options_id = body["webauthnGeneratedOptionsId"] + if webauthn_generated_options_id is None: + raise_bad_input_exception("webauthnGeneratedOptionsId is required") + + credential = body["credential"] + if credential is None: + raise_bad_input_exception("credential is required") + + try: + # Try to create an object + # If validation fails, return the response expected from the core. + # NOTE: Can use `.construct` as an alternative, but the implementation is not stable. + credential = RegistrationPayload.from_json(credential) + except ValidationError: + send_200_response( + data_json=InvalidCredentialsErrorResponse().to_json(), + response=options.res, + ) + + token = body["token"] + if token is None: + raise_bad_input_exception("Please provide the recover account token") + + if not isinstance(token, str): + raise_bad_input_exception("The recover account token must be a string") + + result = await api_implementation.recover_account_post( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + token=token, + tenant_id=tenant_id, + options=options, + user_context=user_context, + ) + result_json = result.to_json() + + if result_json["status"] == "OK": + return send_200_response(data_json={"status": "OK"}, response=options.res) + + return send_200_response(data_json=result_json, response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/register_credentials.py b/supertokens_python/recipe/webauthn/api/register_credentials.py new file mode 100644 index 000000000..97916429b --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/register_credentials.py @@ -0,0 +1,93 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import ValidationError + +from supertokens_python.auth_utils import load_session_in_auth_api_if_needed +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + InvalidCredentialsErrorResponse, + RegistrationPayload, +) +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def register_credential_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_register_credential_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + webauthn_generated_options_id = body["webauthnGeneratedOptionsId"] + if webauthn_generated_options_id is None: + raise_bad_input_exception("webauthnGeneratedOptionsId is required") + + credential = body["credential"] + if credential is None: + raise_bad_input_exception("credential is required") + + try: + # Try to create an object + # If validation fails, return the response expected from the core. + # NOTE: Can use `.construct` as an alternative, but the implementation is not stable. + credential = RegistrationPayload.from_json(credential) + except ValidationError: + send_200_response( + data_json=InvalidCredentialsErrorResponse().to_json(), + response=options.res, + ) + + session = await load_session_in_auth_api_if_needed( + request=options.req, + should_try_linking_with_session_user=None, + user_context=user_context, + ) + if session is None: + raise_bad_input_exception( + "A valid session is required to register a credential" + ) + + result = await api_implementation.register_credential_post( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + options=options, + user_context=user_context, + session=session, + ) + result_json = result.to_json() + + if result_json["status"] == "OK": + return send_200_response(data_json={"status": "OK"}, response=options.res) + + return send_200_response(data_json=result_json, response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/register_options.py b/supertokens_python/recipe/webauthn/api/register_options.py new file mode 100644 index 000000000..3e9de463b --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/register_options.py @@ -0,0 +1,77 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + InvalidEmailErrorResponse, +) +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def register_options_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_register_options_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + email = body.get("email") + recover_account_token = body.get("recoverAccountToken") + + if (email is None or not isinstance(email, str)) and ( + recover_account_token is None or not isinstance(recover_account_token, str) + ): + raise_bad_input_exception( + "Please provide the email or the recover account token" + ) + + if email is not None: + email = email.strip() + validate_error = await options.config.validate_email_address( + email=email, tenant_id=tenant_id, user_context=user_context + ) + if validate_error is not None: + return send_200_response( + data_json=InvalidEmailErrorResponse(err=validate_error).to_json(), + response=options.res, + ) + + result = await api_implementation.register_options_post( + email=email, + recover_account_token=recover_account_token, + tenant_id=tenant_id, + options=options, + user_context=user_context, + ) + result_json = result.to_json() + + return send_200_response(data_json=result_json, response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/sign_in.py b/supertokens_python/recipe/webauthn/api/sign_in.py new file mode 100644 index 000000000..bada3e0df --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/sign_in.py @@ -0,0 +1,117 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, cast + +from pydantic import ValidationError + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + AuthenticationPayload, + InvalidCredentialsErrorResponse, +) +from supertokens_python.types.base import UserContext +from supertokens_python.utils import ( + get_backwards_compatible_user_info, + get_normalised_should_try_linking_with_session_user_flag, + send_200_response, +) + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def sign_in_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + from supertokens_python.auth_utils import load_session_in_auth_api_if_needed + from supertokens_python.recipe.webauthn.interfaces.api import SignInPOSTResponse + + if api_implementation.disable_sign_in_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + webauthn_generated_options_id = body["webauthnGeneratedOptionsId"] + if webauthn_generated_options_id is None: + raise_bad_input_exception("webauthnGeneratedOptionsId is required") + + credential = body["credential"] + if credential is None: + raise_bad_input_exception("credential is required") + + try: + # Try to create an object + # If validation fails, return the response expected from the core. + # NOTE: Can use `.construct` as an alternative, but the implementation is not stable. + credential = AuthenticationPayload.from_json(credential) + except ValidationError: + send_200_response( + data_json=InvalidCredentialsErrorResponse().to_json(), + response=options.res, + ) + + should_try_linking_with_session_user = ( + get_normalised_should_try_linking_with_session_user_flag( + req=options.req, body=body + ) + ) + + session = await load_session_in_auth_api_if_needed( + request=options.req, + should_try_linking_with_session_user=should_try_linking_with_session_user, + user_context=user_context, + ) + if session is not None: + tenant_id = session.get_tenant_id() + + result = await api_implementation.sign_in_post( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + tenant_id=tenant_id, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + options=options, + user_context=user_context, + ) + result_json = result.to_json() + + if result_json["status"] == "OK": + result = cast(SignInPOSTResponse, result) + return send_200_response( + data_json={ + "status": "OK", + **get_backwards_compatible_user_info( + req=options.req, + user_info=result.user, + user_context=user_context, + session_container=result.session, + created_new_recipe_user=None, + ), + }, + response=options.res, + ) + + return send_200_response(data_json=result_json, response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/sign_in_options.py b/supertokens_python/recipe/webauthn/api/sign_in_options.py new file mode 100644 index 000000000..e12b32ed8 --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/sign_in_options.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from supertokens_python.framework.response import BaseResponse +from supertokens_python.types.base import UserContext +from supertokens_python.utils import send_200_response + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def sign_in_options_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + if api_implementation.disable_sign_in_options_post: + return None + + result = await api_implementation.sign_in_options_post( + tenant_id=tenant_id, options=options, user_context=user_context + ) + + return send_200_response(data_json=result.to_json(), response=options.res) diff --git a/supertokens_python/recipe/webauthn/api/sign_up.py b/supertokens_python/recipe/webauthn/api/sign_up.py new file mode 100644 index 000000000..d4a0c81f3 --- /dev/null +++ b/supertokens_python/recipe/webauthn/api/sign_up.py @@ -0,0 +1,117 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, cast + +from pydantic import ValidationError + +from supertokens_python.exceptions import raise_bad_input_exception +from supertokens_python.framework.response import BaseResponse +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + InvalidCredentialsErrorResponse, + RegistrationPayload, +) +from supertokens_python.types.base import UserContext +from supertokens_python.utils import ( + get_backwards_compatible_user_info, + get_normalised_should_try_linking_with_session_user_flag, + send_200_response, +) + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + APIOptions, + ) + + +async def sign_up_api( + api_implementation: APIInterface, + tenant_id: str, + options: APIOptions, + user_context: UserContext, +) -> Optional[BaseResponse]: + from supertokens_python.auth_utils import load_session_in_auth_api_if_needed + from supertokens_python.recipe.webauthn.interfaces.api import SignUpPOSTResponse + + if api_implementation.disable_sign_in_post: + return None + + body = await options.req.json() + if body is None: + raise_bad_input_exception("Please provide a JSON body") + + webauthn_generated_options_id = body["webauthnGeneratedOptionsId"] + if webauthn_generated_options_id is None: + raise_bad_input_exception("webauthnGeneratedOptionsId is required") + + credential = body["credential"] + if credential is None: + raise_bad_input_exception("credential is required") + + try: + # Try to create an object + # If validation fails, return the response expected from the core. + # NOTE: Can use `.construct` as an alternative, but the implementation is not stable. + credential = RegistrationPayload.from_json(credential) + except ValidationError: + send_200_response( + data_json=InvalidCredentialsErrorResponse().to_json(), + response=options.res, + ) + + should_try_linking_with_session_user = ( + get_normalised_should_try_linking_with_session_user_flag( + req=options.req, body=body + ) + ) + + session = await load_session_in_auth_api_if_needed( + request=options.req, + should_try_linking_with_session_user=should_try_linking_with_session_user, + user_context=user_context, + ) + if session is not None: + tenant_id = session.get_tenant_id() + + result = await api_implementation.sign_up_post( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + tenant_id=tenant_id, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + options=options, + user_context=user_context, + ) + result_json = result.to_json() + + if result_json["status"] == "OK": + result = cast(SignUpPOSTResponse, result) + return send_200_response( + data_json={ + "status": "OK", + **get_backwards_compatible_user_info( + req=options.req, + user_info=result.user, + user_context=user_context, + session_container=result.session, + created_new_recipe_user=None, + ), + }, + response=options.res, + ) + + return send_200_response(data_json=result_json, response=options.res) diff --git a/supertokens_python/recipe/webauthn/constants.py b/supertokens_python/recipe/webauthn/constants.py new file mode 100644 index 000000000..3fb62fc1c --- /dev/null +++ b/supertokens_python/recipe/webauthn/constants.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +REGISTER_OPTIONS_API = "/webauthn/options/register" + +SIGNIN_OPTIONS_API = "/webauthn/options/signin" + +SIGN_UP_API = "/webauthn/signup" + +SIGN_IN_API = "/webauthn/signin" + +GENERATE_RECOVER_ACCOUNT_TOKEN_API = "/user/webauthn/reset/token" + +RECOVER_ACCOUNT_API = "/user/webauthn/reset" + +SIGNUP_EMAIL_EXISTS_API = "/webauthn/email/exists" + +# 60 seconds (60 * 1000ms) +DEFAULT_REGISTER_OPTIONS_TIMEOUT = 60000 +DEFAULT_REGISTER_OPTIONS_ATTESTATION = "none" +DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY = "required" +DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION = "preferred" +DEFAULT_REGISTER_OPTIONS_USER_PRESENCE = True +# -8 = EdDSA, -7 = ES256, -257 = RS256 +DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS = [-8, -7, -257] + +# 60 seconds (60 * 1000ms) +DEFAULT_SIGNIN_OPTIONS_TIMEOUT = 60000 +DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = "preferred" +DEFAULT_SIGNIN_OPTIONS_USER_PRESENCE = True diff --git a/supertokens_python/recipe/webauthn/emaildelivery/__init__.py b/supertokens_python/recipe/webauthn/emaildelivery/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/__init__.py b/supertokens_python/recipe/webauthn/emaildelivery/services/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/__init__.py b/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/base.py b/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/base.py new file mode 100644 index 000000000..cea831610 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/backward_compatibility/base.py @@ -0,0 +1,82 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + +from httpx import AsyncClient + +from supertokens_python.ingredients.emaildelivery.types import EmailDeliveryInterface +from supertokens_python.logger import log_debug_message +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, + WebauthnRecoverAccountEmailDeliveryUser, +) +from supertokens_python.supertokens import AppInfo +from supertokens_python.types.base import UserContext +from supertokens_python.utils import handle_httpx_client_exceptions + + +async def create_and_send_email_using_supertokens_service( + app_info: AppInfo, + user: WebauthnRecoverAccountEmailDeliveryUser, + recover_account_link: str, +): + if os.environ.get("SUPERTOKENS_ENV") == "testing": + return + + data = { + "email": user.email, + "appName": app_info.app_name, + "recoverAccountURL": recover_account_link, + } + + try: + async with AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://api.supertokens.com/0/st/auth/webauthn/recover", + json=data, + headers={ + "api-version": "0", + "content-type": "application/json; charset=utf-8", + }, + ) + resp.raise_for_status() + log_debug_message(f"Email sent to {user.email}") + except Exception as e: + log_debug_message("Error sending webauthn recover account email") + handle_httpx_client_exceptions(e, data) + + +class BackwardCompatibilityService( + EmailDeliveryInterface[TypeWebauthnEmailDeliveryInput] +): + _app_info: AppInfo + + def __init__(self, app_info: AppInfo): + self._app_info = app_info + + async def send_email( + self, template_vars: TypeWebauthnEmailDeliveryInput, user_context: UserContext + ): + # we add this here cause the user may have overridden the sendEmail function + # to change the input email and if we don't do this, the input email + # will get reset by the getUserById call above. + try: + await create_and_send_email_using_supertokens_service( + app_info=self._app_info, + user=template_vars.user, + recover_account_link=template_vars.recover_account_link, + ) + except Exception: + pass diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/__init__.py b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/base.py b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/base.py new file mode 100644 index 000000000..741b619a5 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/base.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Any, Callable, Dict, Optional + +from supertokens_python.ingredients.emaildelivery.services.smtp import Transporter +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryInterface, + SMTPServiceInterface, + SMTPSettings, +) +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, +) + +from .service_implementation.base import ServiceImplementation + + +class SMTPService(EmailDeliveryInterface[TypeWebauthnEmailDeliveryInput]): + service_implementation: SMTPServiceInterface[TypeWebauthnEmailDeliveryInput] + + def __init__( + self, + smtp_settings: SMTPSettings, + override: Optional[ + Callable[ + [SMTPServiceInterface[TypeWebauthnEmailDeliveryInput]], + SMTPServiceInterface[TypeWebauthnEmailDeliveryInput], + ], + ] = None, + ) -> None: + transporter = Transporter(smtp_settings) + + oi = ServiceImplementation(transporter) + self.service_implementation = oi if override is None else override(oi) + + async def send_email( + self, + template_vars: TypeWebauthnEmailDeliveryInput, + user_context: Dict[str, Any], + ) -> None: + content = await self.service_implementation.get_content( + template_vars, user_context + ) + await self.service_implementation.send_raw_email(content, user_context) diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/recover_account.py b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/recover_account.py new file mode 100644 index 000000000..42b5faeb3 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/recover_account.py @@ -0,0 +1,950 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from supertokens_python.ingredients.emaildelivery.types import EmailContent +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnRecoverAccountEmailDeliveryInput, +) +from supertokens_python.supertokens import Supertokens + +template = """ + + + + + + + + *|MC:SUBJECT|* + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + +
+ + + + + + +
+ + +
+
+ +

+ An account recovery request for your account on + {appName} has been received. +

+ + +
+
+

+ Alternatively, you can directly paste this link + in your browser
+ ${resetLink} +

+
+
+ + + + +
+ + + + + + +
+

+ This email is meant for {email} +

+
+
+ +
+ + + + + +
+ +
+ +
+
+ + +""" + + +def get_recover_account_email_content( + input: TypeWebauthnRecoverAccountEmailDeliveryInput, +) -> EmailContent: + supertokens = Supertokens.get_instance() + app_name = supertokens.app_info.app_name + body = get_recover_account_email_html( + app_name=app_name, email=input.user.email, reset_link=input.recover_account_link + ) + + return EmailContent( + body=body, + to_email=input.user.email, + subject="Account recovery instructions", + is_html=True, + ) + + +def get_recover_account_email_html(app_name: str, email: str, reset_link: str): + return template.format(app_name=app_name, email=email, reset_link=reset_link) diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/__init__.py b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/base.py b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/base.py new file mode 100644 index 000000000..797250f13 --- /dev/null +++ b/supertokens_python/recipe/webauthn/emaildelivery/services/smtp/service_implementation/base.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Any, Dict + +from supertokens_python.ingredients.emaildelivery.types import ( + EmailContent, + SMTPServiceInterface, +) +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, +) + +from ..recover_account import ( + get_recover_account_email_content, +) + + +class ServiceImplementation(SMTPServiceInterface[TypeWebauthnEmailDeliveryInput]): + async def send_raw_email( + self, content: EmailContent, user_context: Dict[str, Any] + ) -> None: + await self.transporter.send_email(content, user_context) + + async def get_content( + self, + template_vars: TypeWebauthnEmailDeliveryInput, + user_context: Dict[str, Any], + ) -> EmailContent: + return get_recover_account_email_content(template_vars) diff --git a/supertokens_python/recipe/webauthn/exceptions.py b/supertokens_python/recipe/webauthn/exceptions.py new file mode 100644 index 000000000..0a1be0b45 --- /dev/null +++ b/supertokens_python/recipe/webauthn/exceptions.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from supertokens_python.exceptions import SuperTokensError + + +class WebauthnError(SuperTokensError): + pass diff --git a/supertokens_python/recipe/webauthn/functions.py b/supertokens_python/recipe/webauthn/functions.py new file mode 100644 index 000000000..b32b4c0e1 --- /dev/null +++ b/supertokens_python/recipe/webauthn/functions.py @@ -0,0 +1,508 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import List, Optional, Union, cast + +from typing_extensions import Unpack + +from supertokens_python import get_request_from_user_context +from supertokens_python.async_to_sync_wrapper import syncify +from supertokens_python.asyncio import get_user +from supertokens_python.recipe.multitenancy.constants import DEFAULT_TENANT_ID +from supertokens_python.recipe.session.interfaces import SessionContainer +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnRecoverAccountEmailDeliveryInput, + WebauthnRecoverAccountEmailDeliveryUser, +) +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + Attestation, + AuthenticationPayload, + ConsumeRecoverAccountTokenErrorResponse, + CreateRecoverAccountLinkResponse, + RegisterCredentialErrorResponse, + RegisterOptionsKwargsInput, + RegistrationPayload, + ResidentKey, + UnknownUserIdErrorResponse, + UserVerification, +) +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.utils import get_recover_account_link +from supertokens_python.types.base import LoginMethod, UserContext +from supertokens_python.types.response import ( + OkResponseBaseModel, + StatusResponseBaseModel, +) + + +@syncify +async def register_options( + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + resident_key: Optional[ResidentKey] = None, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + attestation: Optional[Attestation] = None, + supported_algorithm_ids: Optional[List[int]] = None, + timeout: Optional[int] = None, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, + **kwargs: Unpack[RegisterOptionsKwargsInput], +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.register_options( + relying_party_id=relying_party_id, + relying_party_name=relying_party_name, + origin=origin, + resident_key=resident_key, + user_verification=user_verification, + user_presence=user_presence, + attestation=attestation, + supported_algorithm_ids=supported_algorithm_ids, + timeout=timeout, + tenant_id=tenant_id, + user_context=user_context, + **kwargs, + ) + + +@syncify +async def sign_in_options( + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + timeout: Optional[int] = None, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.sign_in_options( + relying_party_id=relying_party_id, + relying_party_name=relying_party_name, + origin=origin, + timeout=timeout, + user_verification=user_verification, + user_presence=user_presence, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def sign_up( + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str = DEFAULT_TENANT_ID, + session: Optional[SessionContainer] = None, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.sign_up( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + tenant_id=tenant_id, + session=session, + should_try_linking_with_session_user=session is not None, + user_context=user_context, + ) + + +@syncify +async def sign_in( + *, + credential: AuthenticationPayload, + webauthn_generated_options_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + session: Optional[SessionContainer] = None, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.sign_in( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + session=session, + should_try_linking_with_session_user=session is not None, + user_context=user_context, + ) + + +@syncify +async def verify_credentials( + *, + credential: AuthenticationPayload, + webauthn_generated_options_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + response = ( + await WebauthnRecipe.get_instance().recipe_implementation.verify_credentials( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + ) + + # Here we intentionally skip the user and recipeUserId props, because we + # do not want apps to accidentally use this to sign in + return StatusResponseBaseModel(status=response.status) + + +@syncify +async def create_new_recipe_user( + *, + credential: RegistrationPayload, + webauthn_generated_options_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.create_new_recipe_user( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + + +# We do not make email optional here because we want to +# allow passing in primaryUserId. If we make email optional, +# and if the user provides a primaryUserId, then it may result in two problems: +# - there is no recipeUserId = input primaryUserId, in this case, +# this function will throw an error +# - There is a recipe userId = input primaryUserId, but that recipe has no email, +# or has wrong email compared to what the user wanted to generate a reset token for. +# +# And we want to allow primaryUserId being passed in. +@syncify +async def generate_recover_account_token( + *, + user_id: str, + email: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.generate_recover_account_token( + user_id=user_id, + email=email, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def consume_recover_account_token( + *, + token: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.consume_recover_account_token( + token=token, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def register_credential( + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + recipe_user_id: str, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return ( + await WebauthnRecipe.get_instance().recipe_implementation.register_credential( + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + recipe_user_id=recipe_user_id, + user_context=user_context, + ) + ) + + +@syncify +async def get_user_from_recover_account_token( + *, + token: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.get_user_from_recover_account_token( + token=token, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def remove_credential( + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.remove_credential( + webauthn_credential_id=webauthn_credential_id, + recipe_user_id=recipe_user_id, + user_context=user_context, + ) + + +@syncify +async def get_credential( + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.get_credential( + webauthn_credential_id=webauthn_credential_id, + recipe_user_id=recipe_user_id, + user_context=user_context, + ) + + +@syncify +async def list_credentials( + *, + recipe_user_id: str, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.list_credentials( + recipe_user_id=recipe_user_id, + user_context=user_context, + ) + + +@syncify +async def remove_generated_options( + *, + webauthn_generated_options_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.remove_generated_options( + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def get_generated_options( + *, + webauthn_generated_options_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return ( + await WebauthnRecipe.get_instance().recipe_implementation.get_generated_options( + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + ) + + +@syncify +async def update_user_email( + *, + email: str, + recipe_user_id: str, + tenant_id: str = DEFAULT_TENANT_ID, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + return await WebauthnRecipe.get_instance().recipe_implementation.update_user_email( + email=email, + recipe_user_id=recipe_user_id, + tenant_id=tenant_id, + user_context=user_context, + ) + + +@syncify +async def recover_account( + *, + tenant_id: str = DEFAULT_TENANT_ID, + webauthn_generated_options_id: str, + token: str, + credential: RegistrationPayload, + user_context: Optional[UserContext] = None, +) -> Union[ + OkResponseBaseModel, + ConsumeRecoverAccountTokenErrorResponse, + RegisterCredentialErrorResponse, +]: + consume_response = await consume_recover_account_token( + tenant_id=tenant_id, + token=token, + user_context=user_context, + ) + + if consume_response.status != "OK": + return consume_response + + result = await register_credential( + recipe_user_id=consume_response.user_id, + webauthn_generated_options_id=webauthn_generated_options_id, + credential=credential, + user_context=user_context, + ) + + return result + + +@syncify +async def create_recover_account_link( + *, + tenant_id: str = DEFAULT_TENANT_ID, + user_id: str, + email: str, + user_context: Optional[UserContext] = None, +) -> Union[CreateRecoverAccountLinkResponse, UnknownUserIdErrorResponse]: + if user_context is None: + user_context = {} + + token_response = await generate_recover_account_token( + user_id=user_id, + email=email, + tenant_id=tenant_id, + user_context=user_context, + ) + if isinstance(token_response, UnknownUserIdErrorResponse): + return token_response + + recipe_instance = WebauthnRecipe.get_instance() + link = get_recover_account_link( + app_info=recipe_instance.get_app_info(), + token=token_response.token, + tenant_id=tenant_id, + request=get_request_from_user_context(user_context), + user_context=user_context, + ) + + return CreateRecoverAccountLinkResponse(link=link) + + +@syncify +async def send_email( + *, + template_vars: TypeWebauthnRecoverAccountEmailDeliveryInput, + user_context: Optional[UserContext] = None, +): + if user_context is None: + user_context = {} + + recipe_instance = WebauthnRecipe.get_instance() + return await recipe_instance.email_delivery.ingredient_interface_impl.send_email( + template_vars=template_vars, + user_context=user_context, + ) + + +@syncify +async def send_recover_account_email( + *, + tenant_id: str = DEFAULT_TENANT_ID, + user_id: str, + email: str, + user_context: Optional[UserContext] = None, +) -> Union[OkResponseBaseModel, UnknownUserIdErrorResponse]: + user = await get_user(user_id=user_id, user_context=user_context) + if user is None: + return UnknownUserIdErrorResponse() + + login_method: Optional[LoginMethod] = None + for lm in user.login_methods: + if lm.recipe_id == "webauthn" and lm.has_same_email_as(email): + login_method = lm + break + + if login_method is None: + return UnknownUserIdErrorResponse() + + link_response = await create_recover_account_link( + tenant_id=tenant_id, + user_id=user_id, + email=email, + user_context=user_context, + ) + if link_response.status != "OK": + return link_response + + await send_email( + template_vars=TypeWebauthnRecoverAccountEmailDeliveryInput( + user=WebauthnRecoverAccountEmailDeliveryUser( + id=user.id, + recipe_user_id=login_method.recipe_user_id, + email=cast(str, login_method.email), + ), + recover_account_link=link_response.link, + tenant_id=tenant_id, + ), + ) + + return OkResponseBaseModel() diff --git a/supertokens_python/recipe/webauthn/interfaces/__init__.py b/supertokens_python/recipe/webauthn/interfaces/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/interfaces/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/interfaces/api.py b/supertokens_python/recipe/webauthn/interfaces/api.py new file mode 100644 index 000000000..230a9086f --- /dev/null +++ b/supertokens_python/recipe/webauthn/interfaces/api.py @@ -0,0 +1,335 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from abc import ABC, abstractmethod +from typing import List, Literal, Optional, TypedDict, Union + +from typing_extensions import NotRequired, Unpack + +from supertokens_python.framework.request import BaseRequest +from supertokens_python.framework.response import BaseResponse +from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient +from supertokens_python.recipe.session.interfaces import SessionContainer +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + AuthenticationPayload, + EmailAlreadyExistsErrorResponse, + InvalidAuthenticatorErrorResponse, + InvalidCredentialsErrorResponse, + InvalidOptionsErrorResponse, + OptionsNotFoundErrorResponse, + RecipeInterface, + RecoverAccountTokenInvalidErrorResponse, + RegisterOptionsErrorResponse, + RegistrationPayload, + ResidentKey, + SignInOptionsErrorResponse, + UserVerification, +) +from supertokens_python.recipe.webauthn.types.config import NormalisedWebauthnConfig +from supertokens_python.supertokens import AppInfo +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.base import UserContext +from supertokens_python.types.response import ( + CamelCaseBaseModel, + GeneralErrorResponse, + OkResponseBaseModel, + StatusReasonResponseBaseModel, +) + + +class SignUpNotAllowedErrorResponse( + StatusReasonResponseBaseModel[Literal["SIGN_UP_NOT_ALLOWED"], str] +): + status: Literal["SIGN_UP_NOT_ALLOWED"] = "SIGN_UP_NOT_ALLOWED" + + +class SignInNotAllowedErrorResponse( + StatusReasonResponseBaseModel[Literal["SIGN_IN_NOT_ALLOWED"], str] +): + status: Literal["SIGN_IN_NOT_ALLOWED"] = "SIGN_IN_NOT_ALLOWED" + + +class RecoverAccountNotAllowedErrorResponse( + StatusReasonResponseBaseModel[Literal["RECOVER_ACCOUNT_NOT_ALLOWED"], str] +): + status: Literal["RECOVER_ACCOUNT_NOT_ALLOWED"] = "RECOVER_ACCOUNT_NOT_ALLOWED" + + +class RegisterCredentialNotAllowedErrorResponse( + StatusReasonResponseBaseModel[Literal["REGISTER_CREDENTIAL_NOT_ALLOWED"], str] +): + status: Literal["REGISTER_CREDENTIAL_NOT_ALLOWED"] = ( + "REGISTER_CREDENTIAL_NOT_ALLOWED" + ) + + +class WebauthnRecoverAccountEmailDeliveryUser(CamelCaseBaseModel): + id: str + recipe_user_id: Optional[RecipeUserId] + email: str + + +class TypeWebauthnRecoverAccountEmailDeliveryInput(CamelCaseBaseModel): + type: Literal["RECOVER_ACCOUNT"] = "RECOVER_ACCOUNT" + user: WebauthnRecoverAccountEmailDeliveryUser + recover_account_link: str + tenant_id: str + + +TypeWebauthnEmailDeliveryInput = TypeWebauthnRecoverAccountEmailDeliveryInput + + +class APIOptions(CamelCaseBaseModel): + recipe_implementation: RecipeInterface + app_info: AppInfo + config: NormalisedWebauthnConfig + recipe_id: str + req: BaseRequest + res: BaseResponse + email_delivery: EmailDeliveryIngredient[TypeWebauthnEmailDeliveryInput] + + +class RegisterOptionsPOSTResponse(OkResponseBaseModel): + class RelyingParty(CamelCaseBaseModel): + id: str + name: str + + class User(CamelCaseBaseModel): + id: str + name: str + display_name: str + + class ExcludeCredentials(CamelCaseBaseModel): + id: str + type: Literal["public-key"] + transports: List[Literal["ble", "hybrid", "internal", "nfc", "usb"]] + + class PubKeyCredParams(CamelCaseBaseModel): + alg: int + type: str + + class AuthenticatorSelection(CamelCaseBaseModel): + require_resident_key: bool + resident_key: ResidentKey + user_verification: UserVerification + + webauthn_generated_options_id: str + created_at: int + expires_at: int + rp: RelyingParty + user: User + challenge: str + timeout: int + exclude_credentials: List[ExcludeCredentials] + attestation: Literal["none", "indirect", "direct", "enterprise"] + pub_key_cred_params: List[PubKeyCredParams] + authenticator_selection: AuthenticatorSelection + + +RegisterOptionsPOSTErrorResponse = RegisterOptionsErrorResponse + + +class SignInOptionsPOSTResponse(OkResponseBaseModel): + webauthn_generated_options_id: str + created_at: int + expires_at: int + rp_id: str + challenge: str + timeout: int + user_verification: UserVerification + + +SignInOptionsPOSTErrorResponse = SignInOptionsErrorResponse + +SignUpPOSTErrorResponse = Union[ + SignUpNotAllowedErrorResponse, + InvalidAuthenticatorErrorResponse, + EmailAlreadyExistsErrorResponse, + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, +] + +SignInPOSTErrorResponse = Union[ + InvalidCredentialsErrorResponse, + SignInNotAllowedErrorResponse, +] + +GenerateRecoverAccountTokenPOSTErrorResponse = RecoverAccountNotAllowedErrorResponse + +RecoverAccountPOSTErrorResponse = Union[ + RecoverAccountTokenInvalidErrorResponse, + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, +] + +RegisterCredentialPOSTErrorResponse = Union[ + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + RegisterCredentialNotAllowedErrorResponse, + InvalidAuthenticatorErrorResponse, +] + + +class EmailExistsGetResponse(OkResponseBaseModel): + exists: bool + + +class RecoverAccountPOSTResponse(OkResponseBaseModel): + user: User + email: str + + +class SignUpPOSTResponse(OkResponseBaseModel): + user: User + session: SessionContainer + + +class SignInPOSTResponse(OkResponseBaseModel): + user: User + session: SessionContainer + + +class RecoverAccountTokenInput(TypedDict): + recover_account_token: str + + +class DisplayNameEmailInput(TypedDict): + display_name: Optional[str] + email: str + + +class RegisterOptionsPOSTKwargsInput(TypedDict): + recover_account_token: NotRequired[str] + display_name: NotRequired[str] + email: NotRequired[str] + + +class APIInterface(ABC): + disable_register_options_post: bool = False + disable_sign_in_options_post: bool = False + disable_sign_up_post: bool = False + disable_sign_in_post: bool = False + disable_generate_recover_account_token_post: bool = False + disable_recover_account_post: bool = False + disable_register_credential_post: bool = False + disable_email_exists_get: bool = False + + @abstractmethod + async def register_options_post( + self, + *, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + **kwargs: Unpack[RegisterOptionsPOSTKwargsInput], + ) -> Union[ + RegisterOptionsPOSTResponse, + GeneralErrorResponse, + RegisterOptionsPOSTErrorResponse, + ]: ... + + @abstractmethod + async def sign_in_options_post( + self, + *, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + SignInOptionsPOSTResponse, GeneralErrorResponse, SignInOptionsPOSTErrorResponse + ]: ... + + @abstractmethod + async def sign_up_post( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + session: Optional[SessionContainer], + should_try_linking_with_session_user: Optional[bool], + options: APIOptions, + user_context: UserContext, + ) -> Union[SignUpPOSTResponse, GeneralErrorResponse, SignUpPOSTErrorResponse]: ... + + @abstractmethod + async def sign_in_post( + self, + *, + webauthn_generated_options_id: str, + credential: AuthenticationPayload, + tenant_id: str, + session: Optional[SessionContainer], + should_try_linking_with_session_user: Optional[bool], + options: APIOptions, + user_context: UserContext, + ) -> Union[SignInPOSTResponse, GeneralErrorResponse, SignInPOSTErrorResponse]: ... + + @abstractmethod + async def generate_recover_account_token_post( + self, + *, + email: str, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + OkResponseBaseModel, + GeneralErrorResponse, + GenerateRecoverAccountTokenPOSTErrorResponse, + ]: ... + + @abstractmethod + async def recover_account_post( + self, + *, + token: str, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + RecoverAccountPOSTResponse, + GeneralErrorResponse, + RecoverAccountPOSTErrorResponse, + ]: ... + + @abstractmethod + async def register_credential_post( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + session: SessionContainer, + options: APIOptions, + user_context: UserContext, + ) -> Union[ + OkResponseBaseModel, GeneralErrorResponse, RegisterCredentialPOSTErrorResponse + ]: ... + + @abstractmethod + async def email_exists_get( + self, + *, + email: str, + tenant_id: str, + options: APIOptions, + user_context: UserContext, + ) -> Union[EmailExistsGetResponse, GeneralErrorResponse]: ... diff --git a/supertokens_python/recipe/webauthn/interfaces/recipe.py b/supertokens_python/recipe/webauthn/interfaces/recipe.py new file mode 100644 index 000000000..67dcbe012 --- /dev/null +++ b/supertokens_python/recipe/webauthn/interfaces/recipe.py @@ -0,0 +1,630 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from abc import ABC, abstractmethod +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + TypedDict, + Union, +) + +from pydantic import Field, field_serializer +from typing_extensions import NotRequired, Unpack + +from supertokens_python.recipe.session.interfaces import SessionContainer +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import UserContext +from supertokens_python.types.response import ( + CamelCaseBaseModel, + OkResponseBaseModel, + StatusErrResponseBaseModel, + StatusReasonResponseBaseModel, + StatusResponseBaseModel, +) + +Base64URLString = str +COSEAlgorithmIdentifier = int + +ResidentKey = Literal["required", "preferred", "discouraged"] +UserVerification = Literal["required", "preferred", "discouraged"] +Attestation = Literal["none", "indirect", "direct", "enterprise"] + + +class RecoverAccountTokenInvalidErrorResponse( + StatusResponseBaseModel[Literal["RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"]] +): + status: Literal["RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"] = ( + "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" + ) + + +class InvalidOptionsErrorResponse( + StatusResponseBaseModel[Literal["INVALID_OPTIONS_ERROR"]] +): + status: Literal["INVALID_OPTIONS_ERROR"] = "INVALID_OPTIONS_ERROR" + + +class InvalidEmailErrorResponse( + StatusErrResponseBaseModel[Literal["INVALID_EMAIL_ERROR"]] +): + status: Literal["INVALID_EMAIL_ERROR"] = "INVALID_EMAIL_ERROR" + + +class EmailAlreadyExistsErrorResponse( + StatusResponseBaseModel[Literal["EMAIL_ALREADY_EXISTS_ERROR"]] +): + status: Literal["EMAIL_ALREADY_EXISTS_ERROR"] = "EMAIL_ALREADY_EXISTS_ERROR" + + +class OptionsNotFoundErrorResponse( + StatusResponseBaseModel[Literal["OPTIONS_NOT_FOUND_ERROR"]] +): + status: Literal["OPTIONS_NOT_FOUND_ERROR"] = "OPTIONS_NOT_FOUND_ERROR" + + +class InvalidCredentialsErrorResponse( + StatusResponseBaseModel[Literal["INVALID_CREDENTIALS_ERROR"]] +): + status: Literal["INVALID_CREDENTIALS_ERROR"] = "INVALID_CREDENTIALS_ERROR" + + +class InvalidAuthenticatorErrorResponse( + StatusReasonResponseBaseModel[Literal["INVALID_AUTHENTICATOR_ERROR"], str] +): + status: Literal["INVALID_AUTHENTICATOR_ERROR"] = "INVALID_AUTHENTICATOR_ERROR" + + +class CredentialNotFoundErrorResponse( + StatusResponseBaseModel[Literal["CREDENTIAL_NOT_FOUND_ERROR"]] +): + status: Literal["CREDENTIAL_NOT_FOUND_ERROR"] = "CREDENTIAL_NOT_FOUND_ERROR" + + +class UnknownUserIdErrorResponse( + StatusResponseBaseModel[Literal["UNKNOWN_USER_ID_ERROR"]] +): + status: Literal["UNKNOWN_USER_ID_ERROR"] = "UNKNOWN_USER_ID_ERROR" + + +class CredentialPayloadBase(CamelCaseBaseModel): + id: str + rawId: str + authenticatorAttachment: Optional[ + Literal[ + "platform", + "cross-platform", + ] + ] = None + # Default value required since inputs come from users, might omit this + # Not provided in the webauthn authenticator used in backend-sdk-testing + clientExtensionResults: Dict[str, Any] = Field(default_factory=dict) + type: Literal["public-key"] + + +class AuthenticatorAssertionResponseJSON(CamelCaseBaseModel): + clientDataJSON: Base64URLString + authenticatorData: Base64URLString + signature: Base64URLString + userHandle: Optional[Base64URLString] = None + + +class AuthenticationPayload(CredentialPayloadBase): + response: AuthenticatorAssertionResponseJSON + + +class AuthenticatorAttestationResponseJSON(CamelCaseBaseModel): + clientDataJSON: Base64URLString + attestationObject: Base64URLString + authenticatorData: Optional[Base64URLString] = None + transports: Optional[ + List[ + Literal[ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb", + ] + ] + ] = None + publicKeyAlgorithm: Optional[COSEAlgorithmIdentifier] = None + publicKey: Optional[Base64URLString] = None + + +class RegistrationPayload(CredentialPayloadBase): + response: AuthenticatorAttestationResponseJSON + + +class CredentialPayload(CredentialPayloadBase): + class Response(CamelCaseBaseModel): + client_data_json: str + attestation_object: str + transports: Optional[ + List[ + Literal[ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb", + ] + ] + ] = None + user_handle: str + + response: Response + + +class RegisterOptionsResponse(OkResponseBaseModel): + # for understanding the response, see https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential + # and https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential + + class RelyingParty(CamelCaseBaseModel): + id: str + name: str + + class User(CamelCaseBaseModel): + id: str + name: str # user email + display_name: str # user email + + class ExcludeCredentials(CamelCaseBaseModel): + id: str + transports: List[ + Literal[ + "ble", + "hybrid", + "internal", + "nfc", + "usb", + ] + ] + type: Literal["public-key"] + + class PubKeyCredParams(CamelCaseBaseModel): + # we will default to [-8, -7, -257] as supported algorithms. + # See https://www.iana.org/assignments/cose/cose.xhtml#algorithms + alg: int + type: Literal["public-key"] + + class AuthenticatorSelection(CamelCaseBaseModel): + require_resident_key: bool + resident_key: ResidentKey + user_verification: UserVerification + + webauthn_generated_options_id: str + created_at: int + expires_at: int + rp: RelyingParty + user: User + challenge: str + timeout: int + exclude_credentials: List[ExcludeCredentials] + attestation: Attestation + pub_key_cred_params: List[PubKeyCredParams] + authenticator_selection: AuthenticatorSelection + + +RegisterOptionsErrorResponse = Union[ + RecoverAccountTokenInvalidErrorResponse, + InvalidOptionsErrorResponse, + InvalidEmailErrorResponse, +] + + +class SignInOptionsResponse(OkResponseBaseModel): + webauthn_generated_options_id: str + created_at: int + expires_at: int + challenge: str + timeout: int + user_verification: UserVerification + + +SignInOptionsErrorResponse = InvalidOptionsErrorResponse + + +class CreateNewRecipeUserResponse(OkResponseBaseModel): + user: User + recipe_user_id: RecipeUserId + + @field_serializer("user") + def serialize_user(self, user: User): + return user.to_json() + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: RecipeUserId): + return rui.get_as_string() + + +CreateNewRecipeUserErrorResponse = Union[ + EmailAlreadyExistsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + InvalidCredentialsErrorResponse, + InvalidAuthenticatorErrorResponse, +] + + +class SignUpReponse(OkResponseBaseModel): + user: User + recipe_user_id: RecipeUserId + + @field_serializer("user") + def serialize_user(self, user: User): + return user.to_json() + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: RecipeUserId): + return rui.get_as_string() + + +SignUpErrorResponse = Union[ + CreateNewRecipeUserErrorResponse, + LinkingToSessionUserFailedError, +] + + +class VerifyCredentialsResponse(OkResponseBaseModel): + user: User + recipe_user_id: RecipeUserId + + @field_serializer("user") + def serialize_user(self, user: User): + return user.to_json() + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: RecipeUserId): + return rui.get_as_string() + + +VerifyCredentialsErrorResponse = Union[ + InvalidCredentialsErrorResponse, + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, + CredentialNotFoundErrorResponse, + UnknownUserIdErrorResponse, + OptionsNotFoundErrorResponse, +] + + +class SignInResponse(OkResponseBaseModel): + user: User + recipe_user_id: RecipeUserId + + @field_serializer("user") + def serialize_user(self, user: User): + return user.to_json() + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: RecipeUserId): + return rui.get_as_string() + + +SignInErrorResponse = Union[ + VerifyCredentialsErrorResponse, + LinkingToSessionUserFailedError, +] + + +class GenerateRecoverAccountTokenResponse(OkResponseBaseModel): + token: str + + +GenerateRecoverAccountTokenErrorResponse = UnknownUserIdErrorResponse + + +class ConsumeRecoverAccountTokenResponse(OkResponseBaseModel): + email: str + user_id: str + + +ConsumeRecoverAccountTokenErrorResponse = RecoverAccountTokenInvalidErrorResponse + + +RegisterCredentialErrorResponse = Union[ + InvalidCredentialsErrorResponse, + OptionsNotFoundErrorResponse, + InvalidOptionsErrorResponse, + InvalidAuthenticatorErrorResponse, +] + + +class GetUserFromRecoverAccountTokenResponse(OkResponseBaseModel): + user: User + recipe_user_id: Optional[RecipeUserId] + + @field_serializer("user") + def serialize_user(self, user: User): + return user.to_json() + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: Optional[RecipeUserId]): + if rui is None: + return None + + return rui.get_as_string() + + +GetUserFromRecoverAccountTokenErrorResponse = RecoverAccountTokenInvalidErrorResponse + +RemoveCredentialErrorResponse = CredentialNotFoundErrorResponse + + +class GetCredentialResponse(OkResponseBaseModel): + webauthn_credential_id: str + relying_party_id: str + recipe_user_id: RecipeUserId + created_at: int + + @field_serializer("recipe_user_id") + def serialize_recipe_user_id(self, rui: RecipeUserId): + return rui.get_as_string() + + +GetCredentialErrorResponse = CredentialNotFoundErrorResponse + + +class ListCredentialsResponse(OkResponseBaseModel): + class Credential(CamelCaseBaseModel): + webauthn_credential_id: str + relying_party_id: str + recipe_user_id: str + created_at: int + + credentials: List[Credential] + + +RemoveGeneratedOptionsErrorResponse = OptionsNotFoundErrorResponse + + +class GetGeneratedOptionsResponse(OkResponseBaseModel): + webauthn_generated_options_id: str + relying_party_id: str + relying_party_name: str + user_verification: UserVerification + user_presence: bool + origin: str + email: Optional[str] + timeout: int + challenge: str + created_at: int + expires_at: int + + +GetGeneratedOptionsErrorResponse = OptionsNotFoundErrorResponse + + +UpdateUserEmailErrorResponse = Union[ + EmailAlreadyExistsErrorResponse, + UnknownUserIdErrorResponse, +] + + +class CreateRecoverAccountLinkResponse(OkResponseBaseModel): + link: str + + +class RecoverAccountTokenInput(TypedDict): + recover_account_token: str + + +class DisplayNameEmailInput(TypedDict): + display_name: Optional[str] + email: str + + +class RegisterOptionsKwargsInput(TypedDict): + recover_account_token: NotRequired[str] + display_name: NotRequired[str] + email: NotRequired[str] + + +class RecipeInterface(ABC): + @abstractmethod + async def register_options( + self, + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + resident_key: Optional[ResidentKey] = None, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + attestation: Optional[Attestation] = None, + supported_algorithm_ids: Optional[List[int]] = None, + timeout: Optional[int] = None, + tenant_id: str, + user_context: UserContext, + **kwargs: Unpack[RegisterOptionsKwargsInput], + ) -> Union[RegisterOptionsResponse, RegisterOptionsErrorResponse]: ... + + @abstractmethod + async def sign_in_options( + self, + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + timeout: Optional[int] = None, + tenant_id: str, + user_context: UserContext, + ) -> Union[SignInOptionsResponse, SignInOptionsErrorResponse]: ... + + @abstractmethod + async def sign_up( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + session: Optional[SessionContainer] = None, + should_try_linking_with_session_user: Optional[bool] = None, + tenant_id: str, + user_context: UserContext, + ) -> Union[SignUpReponse, SignUpErrorResponse]: ... + + @abstractmethod + async def sign_in( + self, + *, + webauthn_generated_options_id: str, + credential: AuthenticationPayload, + session: Optional[SessionContainer] = None, + should_try_linking_with_session_user: Optional[bool] = None, + tenant_id: str, + user_context: UserContext, + ) -> Union[SignInResponse, SignInErrorResponse]: ... + + @abstractmethod + async def verify_credentials( + self, + *, + webauthn_generated_options_id: str, + credential: AuthenticationPayload, + tenant_id: str, + user_context: UserContext, + ) -> Union[VerifyCredentialsResponse, VerifyCredentialsErrorResponse]: ... + + @abstractmethod + async def create_new_recipe_user( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + user_context: UserContext, + ) -> Union[CreateNewRecipeUserResponse, CreateNewRecipeUserErrorResponse]: + """ + This function is meant only for creating the recipe in the core and nothing else. + We added this even though signUp exists cause devs may override signup expecting it + to be called just during sign up. But we also need a version of signing up which can be + called during operations like creating a user during account recovery flow. + """ + ... + + @abstractmethod + async def generate_recover_account_token( + self, + *, + user_id: str, + email: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + GenerateRecoverAccountTokenResponse, + GenerateRecoverAccountTokenErrorResponse, + ]: + """ + We pass in the email as well to this function cause the input userId + may not be associated with an webauthn account. In this case, we + need to know which email to use to create an webauthn account later on. + """ + + @abstractmethod + async def consume_recover_account_token( + self, + *, + token: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + ConsumeRecoverAccountTokenResponse, ConsumeRecoverAccountTokenErrorResponse + ]: ... + + @abstractmethod + async def register_credential( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + user_context: UserContext, + recipe_user_id: str, + ) -> Union[OkResponseBaseModel, RegisterCredentialErrorResponse]: ... + + @abstractmethod + async def get_user_from_recover_account_token( + self, + *, + token: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + GetUserFromRecoverAccountTokenResponse, + GetUserFromRecoverAccountTokenErrorResponse, + ]: ... + + @abstractmethod + async def remove_credential( + self, + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, RemoveCredentialErrorResponse]: ... + + @abstractmethod + async def get_credential( + self, + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: UserContext, + ) -> Union[GetCredentialResponse, GetCredentialErrorResponse]: ... + + @abstractmethod + async def list_credentials( + self, + *, + recipe_user_id: str, + user_context: UserContext, + ) -> ListCredentialsResponse: ... + + @abstractmethod + async def remove_generated_options( + self, + *, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, RemoveGeneratedOptionsErrorResponse]: ... + + @abstractmethod + async def get_generated_options( + self, + *, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[GetGeneratedOptionsResponse, GetGeneratedOptionsErrorResponse]: ... + + @abstractmethod + async def update_user_email( + self, + *, + recipe_user_id: str, + email: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, UpdateUserEmailErrorResponse]: ... diff --git a/supertokens_python/recipe/webauthn/recipe.py b/supertokens_python/recipe/webauthn/recipe.py new file mode 100644 index 000000000..727dcb15f --- /dev/null +++ b/supertokens_python/recipe/webauthn/recipe.py @@ -0,0 +1,443 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from supertokens_python.auth_utils import is_fake_email +from supertokens_python.exceptions import raise_general_exception +from supertokens_python.framework.request import BaseRequest +from supertokens_python.framework.response import BaseResponse +from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient +from supertokens_python.normalised_url_path import NormalisedURLPath +from supertokens_python.post_init_callbacks import PostSTInitCallbacks +from supertokens_python.querier import Querier +from supertokens_python.recipe.multifactorauth.recipe import MultiFactorAuthRecipe +from supertokens_python.recipe.multifactorauth.types import ( + FactorIds, + GetAllAvailableSecondaryFactorIdsFromOtherRecipesFunc, + GetEmailsForFactorFromOtherRecipesFunc, + GetEmailsForFactorOkResult, + GetEmailsForFactorUnknownSessionRecipeUserIdResult, + GetFactorsSetupForUserFromOtherRecipesFunc, +) +from supertokens_python.recipe.multitenancy.interfaces import TenantConfig +from supertokens_python.recipe.multitenancy.recipe import MultitenancyRecipe +from supertokens_python.recipe.webauthn.api.email_exists import email_exists_api +from supertokens_python.recipe.webauthn.api.generate_recover_account_token import ( + generate_recover_account_token_api, +) +from supertokens_python.recipe.webauthn.api.implementation import APIImplementation +from supertokens_python.recipe.webauthn.api.recover_account import recover_account_api +from supertokens_python.recipe.webauthn.api.register_options import register_options_api +from supertokens_python.recipe.webauthn.api.sign_in import sign_in_api +from supertokens_python.recipe.webauthn.api.sign_in_options import sign_in_options_api +from supertokens_python.recipe.webauthn.api.sign_up import sign_up_api +from supertokens_python.recipe.webauthn.constants import ( + GENERATE_RECOVER_ACCOUNT_TOKEN_API, + RECOVER_ACCOUNT_API, + REGISTER_OPTIONS_API, + SIGN_IN_API, + SIGN_UP_API, + SIGNIN_OPTIONS_API, + SIGNUP_EMAIL_EXISTS_API, +) +from supertokens_python.recipe.webauthn.exceptions import WebauthnError +from supertokens_python.recipe.webauthn.interfaces.recipe import RecipeInterface +from supertokens_python.recipe.webauthn.recipe_implementation import ( + RecipeImplementation, +) +from supertokens_python.recipe.webauthn.types.config import ( + NormalisedWebauthnConfig, + WebauthnConfig, + WebauthnIngredients, +) +from supertokens_python.recipe.webauthn.utils import validate_and_normalise_user_input +from supertokens_python.recipe_module import APIHandled, RecipeModule +from supertokens_python.supertokens import AppInfo +from supertokens_python.types.base import RecipeUserId, User, UserContext + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + TypeWebauthnEmailDeliveryInput, + ) + + +class WebauthnRecipe(RecipeModule): + __instance: Optional["WebauthnRecipe"] = None + recipe_id = "webauthn" + + config: NormalisedWebauthnConfig + recipe_implementation: RecipeInterface + api_implementation: "APIInterface" + email_delivery: EmailDeliveryIngredient["TypeWebauthnEmailDeliveryInput"] + + def __init__( + self, + recipe_id: str, + app_info: AppInfo, + config: Optional[WebauthnConfig], + ingredients: WebauthnIngredients, + ): + super().__init__(recipe_id=recipe_id, app_info=app_info) + self.config = validate_and_normalise_user_input( + app_info=app_info, config=config + ) + + querier = Querier.get_instance(rid_to_core=recipe_id) + recipe_implementation = RecipeImplementation( + querier=querier, + config=self.config, + ) + self.recipe_implementation = ( + recipe_implementation + if self.config.override.functions is None + else self.config.override.functions(recipe_implementation) + ) + + api_implementation = APIImplementation() + self.api_implementation = ( + api_implementation + if self.config.override.apis is None + else self.config.override.apis(api_implementation) + ) + + if ingredients.email_delivery is None: + self.email_delivery = EmailDeliveryIngredient( + config=self.config.get_email_delivery_config() + ) + else: + self.email_delivery = ingredients.email_delivery + + def callback(): + mfa_instance = MultiFactorAuthRecipe.get_instance() + if mfa_instance is not None: + + async def get_available_secondary_factor_ids( + _: TenantConfig, + ) -> List[str]: + return ["emailpassword"] + + mfa_instance.add_func_to_get_all_available_secondary_factor_ids_from_other_recipes( + GetAllAvailableSecondaryFactorIdsFromOtherRecipesFunc( + get_available_secondary_factor_ids + ) + ) + + async def user_setup(user: User, _: Dict[str, Any]) -> List[str]: + for login_method in user.login_methods: + # We don't check for tenantId here because if we find the user + # with emailpassword loginMethod from different tenant, then + # we assume the factor is setup for this user. And as part of factor + # completion, we associate that loginMethod with the session's tenantId + if login_method.recipe_id == self.recipe_id: + return ["emailpassword"] + + return [] + + mfa_instance.add_func_to_get_factors_setup_for_user_from_other_recipes( + GetFactorsSetupForUserFromOtherRecipesFunc(user_setup) + ) + + async def get_emails_for_factor( + user: User, session_recipe_user_id: RecipeUserId + ): + session_login_method = None + for login_method in user.login_methods: + if ( + login_method.recipe_user_id.get_as_string() + == session_recipe_user_id.get_as_string() + ): + session_login_method = login_method + break + + if session_login_method is None: + # this can happen maybe cause this login method + # was unlinked from the user or deleted entirely + return GetEmailsForFactorUnknownSessionRecipeUserIdResult() + + # We order the login methods based on `time_joined` (oldest first) + ordered_login_methods = sorted( + user.login_methods, key=lambda lm: lm.time_joined, reverse=True + ) + # We take the ones that belong to this recipe + recipe_ordered_login_methods = list( + filter( + lambda lm: lm.recipe_id == self.recipe_id, + ordered_login_methods, + ) + ) + + result: List[str] = [] + if len(recipe_ordered_login_methods) == 0: + # If there are login methods belonging to this recipe, the factor is set up + # In this case we only list email addresses that have a password associated with them + + # First we take the verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + result.extend( + [ + cast(str, lm.email) + for lm in recipe_ordered_login_methods + if not is_fake_email(cast(str, lm.email)) + and lm.verified + ] + ) + # Then we take the non-verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + result.extend( + [ + cast(str, lm.email) + for lm in recipe_ordered_login_methods + if not is_fake_email(cast(str, lm.email)) + and not lm.verified + ] + ) + # Lastly, fake emails associated with emailpassword login methods ordered by timeJoined (oldest first) + # We also add these into the list because they already have a password added to them so they can be a valid choice when signing in + # We do not want to remove the previously added "MFA password", because a new email password user was linked + # E.g.: + # 1. A discord user adds a password for MFA (which will use the fake email associated with the discord user) + # 2. Later they also sign up and (manually) link a full emailpassword user that they intend to use as a first factor + # 3. The next time they sign in using Discord, they could be asked for a secondary password. + # In this case, they'd be checked against the first user that they originally created for MFA, not the one later linked to the account + result.extend( + [ + cast(str, lm.email) + for lm in recipe_ordered_login_methods + if is_fake_email(cast(str, lm.email)) + ] + ) + # We handle moving the session email to the top of the list later + else: + # This factor hasn't been set up, we list all emails belonging to the user + if any( + [ + (lm.email is not None and not is_fake_email(lm.email)) + for lm in ordered_login_methods + ] + ): + # If there is at least one real email address linked to the user, we only suggest real addresses + result = [ + lm.email + for lm in recipe_ordered_login_methods + if lm.email is not None and not is_fake_email(lm.email) + ] + else: + # Else we use the fake ones + result = [ + lm.email + for lm in recipe_ordered_login_methods + if lm.email is not None and is_fake_email(lm.email) + ] + + # We handle moving the session email to the top of the list later + + # Since in this case emails are not guaranteed to be unique, we de-duplicate the results, keeping the oldest one in the list. + # Using a dict keeps the original insertion order, but de-duplicates the items, Python sets are not ordered. + # keeping the first one added (so keeping the older one if there are two entries with the same email) + # e.g.: [4,2,3,2,1] -> [4,2,3,1] + result = list(dict.fromkeys(result)) + + # If the loginmethod associated with the session has an email address, we move it to the top of the list (if it's already in the list) + if ( + session_login_method.email is not None + and session_login_method.email in result + ): + result = [session_login_method.email] + [ + email + for email in result + if email != session_login_method.email + ] + + # If the list is empty we generate an email address to make the flow where the user is never asked for + # an email address easier to implement. In many cases when the user adds an email-password factor, they + # actually only want to add a password and do not care about the associated email address. + # Custom implementations can choose to ignore this, and ask the user for the email anyway. + if len(result) == 0: + result.append( + f"{session_recipe_user_id.get_as_string()}@stfakeemail.supertokens.com" + ) + + return GetEmailsForFactorOkResult( + factor_id_to_emails_map={"emailpassword": result} + ) + + mfa_instance.add_func_to_get_emails_for_factor_from_other_recipes( + GetEmailsForFactorFromOtherRecipesFunc(get_emails_for_factor) + ) + + mt_recipe = MultitenancyRecipe.get_instance_optional() + if mt_recipe is not None: + mt_recipe.all_available_first_factors.append(FactorIds.WEBAUTHN) + + PostSTInitCallbacks.add_post_init_callback(callback) + + @staticmethod + def get_instance() -> "WebauthnRecipe": + if WebauthnRecipe.__instance is not None: + return WebauthnRecipe.__instance + raise_general_exception( + "Initialisation not done. Did you forget to call the SuperTokens.init function?" + ) + + @staticmethod + def get_instance_optional() -> Optional["WebauthnRecipe"]: + return WebauthnRecipe.__instance + + @staticmethod + def init(config: Optional[WebauthnConfig]): + def func(app_info: AppInfo): + if WebauthnRecipe.__instance is None: + WebauthnRecipe.__instance = WebauthnRecipe( + recipe_id=WebauthnRecipe.recipe_id, + app_info=app_info, + config=config, + ingredients=WebauthnIngredients(email_delivery=None), + ) + return WebauthnRecipe.__instance + else: + raise_general_exception( + "Webauthn recipe has already been initialised. Please check your code for bugs." + ) + + return func + + @staticmethod + def reset(): + if os.environ.get("SUPERTOKENS_ENV") != "testing": + raise_general_exception("calling testing function in non testing env") + WebauthnRecipe.__instance = None + + def get_apis_handled(self) -> List[APIHandled]: + return [ + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath(REGISTER_OPTIONS_API), + request_id=REGISTER_OPTIONS_API, + disabled=self.api_implementation.disable_register_options_post, + ), + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath(SIGNIN_OPTIONS_API), + request_id=SIGNIN_OPTIONS_API, + disabled=self.api_implementation.disable_sign_in_options_post, + ), + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath(SIGN_UP_API), + request_id=SIGN_UP_API, + disabled=self.api_implementation.disable_sign_up_post, + ), + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath(SIGN_IN_API), + request_id=SIGN_IN_API, + disabled=self.api_implementation.disable_sign_in_post, + ), + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath( + GENERATE_RECOVER_ACCOUNT_TOKEN_API + ), + request_id=GENERATE_RECOVER_ACCOUNT_TOKEN_API, + disabled=self.api_implementation.disable_generate_recover_account_token_post, + ), + APIHandled( + method="post", + path_without_api_base_path=NormalisedURLPath(RECOVER_ACCOUNT_API), + request_id=RECOVER_ACCOUNT_API, + disabled=self.api_implementation.disable_recover_account_post, + ), + APIHandled( + method="get", + path_without_api_base_path=NormalisedURLPath(SIGNUP_EMAIL_EXISTS_API), + request_id=SIGNUP_EMAIL_EXISTS_API, + disabled=self.api_implementation.disable_email_exists_get, + ), + ] + + async def handle_api_request( + self, + request_id: str, + tenant_id: str, + request: BaseRequest, + path: NormalisedURLPath, + method: str, + response: BaseResponse, + user_context: UserContext, + ) -> Optional[BaseResponse]: + from supertokens_python.recipe.webauthn.interfaces.api import APIOptions + + # APIOptions.model_rebuild() + options = APIOptions( + config=self.config, + recipe_id=self.get_recipe_id(), + recipe_implementation=self.recipe_implementation, + req=request, + res=response, + email_delivery=self.email_delivery, + app_info=self.get_app_info(), + ) + + if request_id == REGISTER_OPTIONS_API: + return await register_options_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == SIGNIN_OPTIONS_API: + return await sign_in_options_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == SIGN_UP_API: + return await sign_up_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == SIGN_IN_API: + return await sign_in_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == GENERATE_RECOVER_ACCOUNT_TOKEN_API: + return await generate_recover_account_token_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == RECOVER_ACCOUNT_API: + return await recover_account_api( + self.api_implementation, tenant_id, options, user_context + ) + + if request_id == SIGNUP_EMAIL_EXISTS_API: + return await email_exists_api( + self.api_implementation, tenant_id, options, user_context + ) + + return None + + def is_error_from_this_recipe_based_on_instance(self, err: Exception): + return isinstance(err, WebauthnError) + + async def handle_error( + self, + request: BaseRequest, + err: Exception, + response: BaseResponse, + user_context: UserContext, + ): + raise err + + def get_all_cors_headers(self) -> List[str]: + return [] diff --git a/supertokens_python/recipe/webauthn/recipe_implementation.py b/supertokens_python/recipe/webauthn/recipe_implementation.py new file mode 100644 index 000000000..e723f017a --- /dev/null +++ b/supertokens_python/recipe/webauthn/recipe_implementation.py @@ -0,0 +1,682 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Any, Dict, List, Optional, Union, cast + +from typing_extensions import Unpack + +from supertokens_python.asyncio import get_user +from supertokens_python.auth_utils import ( + link_to_session_if_provided_else_create_primary_user_id_or_link_by_account_info, +) +from supertokens_python.normalised_url_path import NormalisedURLPath +from supertokens_python.querier import Querier +from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe +from supertokens_python.recipe.session.interfaces import SessionContainer +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + Attestation, + AuthenticationPayload, + ConsumeRecoverAccountTokenErrorResponse, + ConsumeRecoverAccountTokenResponse, + CreateNewRecipeUserErrorResponse, + CreateNewRecipeUserResponse, + CredentialNotFoundErrorResponse, + DisplayNameEmailInput, + EmailAlreadyExistsErrorResponse, + GenerateRecoverAccountTokenErrorResponse, + GenerateRecoverAccountTokenResponse, + GetCredentialErrorResponse, + GetCredentialResponse, + GetGeneratedOptionsErrorResponse, + GetGeneratedOptionsResponse, + GetUserFromRecoverAccountTokenErrorResponse, + GetUserFromRecoverAccountTokenResponse, + InvalidAuthenticatorErrorResponse, + InvalidCredentialsErrorResponse, + InvalidEmailErrorResponse, + InvalidOptionsErrorResponse, + ListCredentialsResponse, + OptionsNotFoundErrorResponse, + RecipeInterface, + RecoverAccountTokenInput, + RecoverAccountTokenInvalidErrorResponse, + RegisterCredentialErrorResponse, + RegisterOptionsErrorResponse, + RegisterOptionsKwargsInput, + RegisterOptionsResponse, + RegistrationPayload, + RemoveCredentialErrorResponse, + RemoveGeneratedOptionsErrorResponse, + ResidentKey, + SignInErrorResponse, + SignInOptionsErrorResponse, + SignInOptionsResponse, + SignInResponse, + SignUpErrorResponse, + SignUpReponse, + UnknownUserIdErrorResponse, + UpdateUserEmailErrorResponse, + UserVerification, + VerifyCredentialsErrorResponse, + VerifyCredentialsResponse, +) +from supertokens_python.recipe.webauthn.types.config import NormalisedWebauthnConfig +from supertokens_python.types.base import RecipeUserId, User, UserContext +from supertokens_python.types.response import OkResponseBaseModel + + +class RecipeImplementation(RecipeInterface): + def __init__( + self, + *, + querier: Querier, + config: NormalisedWebauthnConfig, + ): + self.querier = querier + self.config = config + + async def register_options( + self, + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + timeout: Optional[int] = None, + attestation: Optional[Attestation] = None, + tenant_id: str, + user_context: UserContext, + supported_algorithm_ids: Optional[List[int]] = None, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + resident_key: Optional[ResidentKey] = None, + **kwargs: Unpack[RegisterOptionsKwargsInput], + ) -> Union[RegisterOptionsResponse, RegisterOptionsErrorResponse]: + kwargs_obj: Union[DisplayNameEmailInput, RecoverAccountTokenInput] + has_email_input: bool = False + has_recover_account_token_input: bool = False + + if "email" in kwargs and kwargs.get("email") is not None: + has_email_input = True + kwargs_obj = DisplayNameEmailInput( + email=kwargs["email"], + display_name=kwargs.get("display_name"), + ) + elif ( + "recover_account_token" in kwargs + and kwargs.get("recover_account_token") is not None + ): + has_recover_account_token_input = True + kwargs_obj = RecoverAccountTokenInput( + recover_account_token=kwargs["recover_account_token"], + ) + else: + raise ValueError( + "Either 'email' or 'recover_account_token' must be provided in kwargs." + ) + + email: Optional[str] = None + if has_email_input: + email = cast(DisplayNameEmailInput, kwargs_obj)["email"] + elif has_recover_account_token_input: + token = cast(RecoverAccountTokenInput, kwargs_obj)["recover_account_token"] + result = await self.get_user_from_recover_account_token( + token=token, + tenant_id=tenant_id, + user_context=user_context, + ) + if result.status != "OK": + return result + + user = result.user + # if the recipeUserId is not present, it means that the user does not have a webauthn login method and we should just use the user id + # this will make account recovery act as a sign up + user_id = user.id + if result.recipe_user_id is not None: + user_id = result.recipe_user_id.get_as_string() + + # Not using a filter/next here since this could potentially be None + for login_method in user.login_methods: + if login_method.recipe_user_id.get_as_string() == user_id: + email = login_method.email + break + else: + raise Exception( + "should never come here: Either `email` or `recover_aacount_token` should be specified" + ) + + if email is None: + return InvalidEmailErrorResponse(err="The email is missing") + + validate_result = await self.config.validate_email_address( + email=email, + tenant_id=tenant_id, + user_context=user_context, + ) + if validate_result: + return InvalidEmailErrorResponse(err=validate_result) + + display_name: str + # Doing a double check with `.get` since someone could explicitly pass `None` + if has_email_input and kwargs.get("display_name") is not None: + # If email is provided, and `display_name` is provided in kwargs, access directly + kwargs_display_name = cast( + DisplayNameEmailInput, + kwargs_obj, + )["display_name"] + # Additional type-cast since Pylance doesn't understand the type narrowing done above + display_name = cast(str, kwargs_display_name) + else: + display_name = email + + query_data: Dict[str, Any] = { + "email": email, + "displayName": display_name, + "relyingPartyName": relying_party_name, + "relyingPartyId": relying_party_id, + "origin": origin, + } + + if timeout is not None: + query_data["timeout"] = timeout + if attestation is not None: + query_data["attestation"] = attestation + if supported_algorithm_ids is not None: + query_data["supportedAlgorithmIds"] = supported_algorithm_ids + if user_verification is not None: + query_data["userVerification"] = user_verification + if user_presence is not None: + query_data["userPresence"] = user_presence + if resident_key is not None: + query_data["residentKey"] = resident_key + + response = await self.querier.send_post_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/options/register"), + data=query_data, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR": + return RecoverAccountTokenInvalidErrorResponse() + if response["status"] == "INVALID_OPTIONS_ERROR": + return InvalidOptionsErrorResponse() + if response["status"] == "INVALID_EMAIL_ERROR": + return InvalidEmailErrorResponse(err=response["err"]) + + raise Exception(f"Unknown Error: {response}") + + return RegisterOptionsResponse.from_json(response) + + async def sign_in_options( + self, + *, + relying_party_id: str, + relying_party_name: str, + origin: str, + timeout: Optional[int] = None, + user_verification: Optional[UserVerification] = None, + user_presence: Optional[bool] = None, + tenant_id: str, + user_context: UserContext, + ) -> Union[SignInOptionsResponse, SignInOptionsErrorResponse]: + query_data: Dict[str, Any] = { + "relyingPartyId": relying_party_id, + "relyingPartyName": relying_party_name, + "origin": origin, + } + + if timeout is not None: + query_data["timeout"] = timeout + if user_verification is not None: + query_data["userVerification"] = user_verification + if user_presence is not None: + query_data["userPresence"] = user_presence + + response = await self.querier.send_post_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/options/signin"), + data=query_data, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "INVALID_OPTIONS_ERROR": + return InvalidOptionsErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return SignInOptionsResponse.from_json(response) + + async def sign_up( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + tenant_id: str, + session: Optional[SessionContainer] = None, + should_try_linking_with_session_user: Optional[bool] = None, + user_context: UserContext, + ) -> Union[SignUpReponse, SignUpErrorResponse]: + response = await self.create_new_recipe_user( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + if response.status != "OK": + return response + + link_result = await link_to_session_if_provided_else_create_primary_user_id_or_link_by_account_info( + tenant_id=tenant_id, + input_user=response.user, + recipe_user_id=response.recipe_user_id, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + user_context=user_context, + ) + if link_result.status != "OK": + return link_result + + return SignUpReponse( + status="OK", + user=link_result.user, + recipe_user_id=response.recipe_user_id, + ) + + async def sign_in( + self, + *, + credential: AuthenticationPayload, + webauthn_generated_options_id: str, + tenant_id: str, + session: Optional[SessionContainer] = None, + should_try_linking_with_session_user: Optional[bool] = None, + user_context: UserContext, + ) -> Union[SignInResponse, SignInErrorResponse]: + verify_creds_response = await self.verify_credentials( + credential=credential, + webauthn_generated_options_id=webauthn_generated_options_id, + tenant_id=tenant_id, + user_context=user_context, + ) + if verify_creds_response.status != "OK": + return verify_creds_response + + signed_in_user = verify_creds_response.user + + login_method = next( + filter( + lambda lm: lm.recipe_user_id.get_as_string() + == verify_creds_response.recipe_user_id.get_as_string(), + verify_creds_response.user.login_methods, + ) + ) + + if not login_method.verified: + await AccountLinkingRecipe.get_instance().verify_email_for_recipe_user_if_linked_accounts_are_verified( + user=verify_creds_response.user, + recipe_user_id=verify_creds_response.recipe_user_id, + user_context=user_context, + ) + + # We do this so that we get the updated user (in case the above + # function updated the verification status) and can return that + updated_user = await get_user( + verify_creds_response.recipe_user_id.get_as_string(), user_context + ) + + if updated_user is not None: + signed_in_user = updated_user + + link_result = await link_to_session_if_provided_else_create_primary_user_id_or_link_by_account_info( + tenant_id=tenant_id, + input_user=verify_creds_response.user, + recipe_user_id=verify_creds_response.recipe_user_id, + session=session, + should_try_linking_with_session_user=should_try_linking_with_session_user, + user_context=user_context, + ) + + if link_result.status != "OK": + return link_result + + signed_in_user = link_result.user + + return SignInResponse( + status="OK", + user=signed_in_user, + recipe_user_id=verify_creds_response.recipe_user_id, + ) + + async def verify_credentials( + self, + *, + credential: AuthenticationPayload, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[VerifyCredentialsResponse, VerifyCredentialsErrorResponse]: + response = await self.querier.send_post_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/signin"), + data={ + # To allow for JSON encoding + "credential": credential.to_json(), + "webauthnGeneratedOptionsId": webauthn_generated_options_id, + }, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "INVALID_CREDENTIALS_ERROR": + return InvalidCredentialsErrorResponse() + if response["status"] == "INVALID_OPTIONS_ERROR": + return InvalidOptionsErrorResponse() + if response["status"] == "INVALID_AUTHENTICATOR_ERROR": + return InvalidAuthenticatorErrorResponse(reason=response["reason"]) + if response["status"] == "CREDENTIAL_NOT_FOUND_ERROR": + return CredentialNotFoundErrorResponse() + if response["status"] == "UNKNOWN_USER_ID_ERROR": + return UnknownUserIdErrorResponse() + if response["status"] == "OPTIONS_NOT_FOUND_ERROR": + return OptionsNotFoundErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return VerifyCredentialsResponse( + user=User.from_json(response["user"]), + recipe_user_id=RecipeUserId(response["recipeUserId"]), + ) + + async def create_new_recipe_user( + self, + *, + credential: RegistrationPayload, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[CreateNewRecipeUserResponse, CreateNewRecipeUserErrorResponse]: + response = await self.querier.send_post_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/signup"), + data={ + "webauthnGeneratedOptionsId": webauthn_generated_options_id, + # To allow for JSON encoding + "credential": credential.to_json(), + }, + user_context=user_context, + ) + + if response.get("status") != "OK": + if response["status"] == "EMAIL_ALREADY_EXISTS_ERROR": + return EmailAlreadyExistsErrorResponse() + if response["status"] == "OPTIONS_NOT_FOUND_ERROR": + return OptionsNotFoundErrorResponse() + if response["status"] == "INVALID_OPTIONS_ERROR": + return InvalidOptionsErrorResponse() + if response["status"] == "INVALID_CREDENTIALS_ERROR": + return InvalidCredentialsErrorResponse() + if response["status"] == "INVALID_AUTHENTICATOR_ERROR": + return InvalidAuthenticatorErrorResponse(reason=response["reason"]) + + raise Exception(f"Unknown Error: {response}") + + return CreateNewRecipeUserResponse( + user=User.from_json(response["user"]), + recipe_user_id=RecipeUserId(response["recipeUserId"]), + ) + + async def generate_recover_account_token( + self, + *, + user_id: str, + email: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + GenerateRecoverAccountTokenResponse, GenerateRecoverAccountTokenErrorResponse + ]: + response = await self.querier.send_post_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/user/recover/token"), + data={"userId": user_id, "email": email}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "UNKNOWN_USER_ID_ERROR": + return UnknownUserIdErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return GenerateRecoverAccountTokenResponse.from_json(response) + + async def consume_recover_account_token( + self, + *, + token: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + ConsumeRecoverAccountTokenResponse, ConsumeRecoverAccountTokenErrorResponse + ]: + response = await self.querier.send_post_request( + path=NormalisedURLPath( + f"/{tenant_id}/recipe/webauthn/user/recover/token/consume" + ), + data={"token": token}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR": + return RecoverAccountTokenInvalidErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return ConsumeRecoverAccountTokenResponse.from_json(response) + + async def register_credential( + self, + *, + webauthn_generated_options_id: str, + credential: RegistrationPayload, + recipe_user_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, RegisterCredentialErrorResponse]: + response = await self.querier.send_post_request( + path=NormalisedURLPath("/recipe/webauthn/user/credential/register"), + data={ + "recipeUserId": recipe_user_id, + "webauthnGeneratedOptionsId": webauthn_generated_options_id, + # To allow for JSON encoding + "credential": credential.to_json(), + }, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "INVALID_CREDENTIALS_ERROR": + return InvalidCredentialsErrorResponse() + if response["status"] == "OPTIONS_NOT_FOUND_ERROR": + return OptionsNotFoundErrorResponse() + if response["status"] == "INVALID_OPTIONS_ERROR": + return InvalidOptionsErrorResponse() + if response["status"] == "INVALID_AUTHENTICATOR_ERROR": + return InvalidAuthenticatorErrorResponse(reason=response["reason"]) + + raise Exception(f"Unknown Error: {response}") + + return OkResponseBaseModel() + + async def get_user_from_recover_account_token( + self, + *, + token: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[ + GetUserFromRecoverAccountTokenResponse, + GetUserFromRecoverAccountTokenErrorResponse, + ]: + response = await self.querier.send_get_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/user/recover"), + params={"token": token}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR": + return RecoverAccountTokenInvalidErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + recipe_user_id: Optional[RecipeUserId] = None + if response.get("recipeUserId") is not None: + recipe_user_id = RecipeUserId(response["recipeUserId"]) + + return GetUserFromRecoverAccountTokenResponse( + user=User.from_json(response["user"]), + recipe_user_id=recipe_user_id, + ) + + async def remove_credential( + self, + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, RemoveCredentialErrorResponse]: + response = await self.querier.send_delete_request( + path=NormalisedURLPath("/recipe/webauthn/user/credential/remove"), + params={ + "recipeUserId": recipe_user_id, + "webauthnCredentialId": webauthn_credential_id, + }, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "CREDENTIAL_NOT_FOUND_ERROR": + return CredentialNotFoundErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return OkResponseBaseModel() + + async def get_credential( + self, + *, + webauthn_credential_id: str, + recipe_user_id: str, + user_context: UserContext, + ) -> Union[GetCredentialResponse, GetCredentialErrorResponse]: + response = await self.querier.send_get_request( + path=NormalisedURLPath("/recipe/webauthn/user/credential"), + params={ + "webauthnCredentialId": webauthn_credential_id, + "recipeUserId": recipe_user_id, + }, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "CREDENTIAL_NOT_FOUND_ERROR": + return CredentialNotFoundErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return GetCredentialResponse.from_json( + { + **response, + "recipeUserId": RecipeUserId(response["recipeUserId"]), + } + ) + + async def list_credentials( + self, + *, + recipe_user_id: str, + user_context: UserContext, + ) -> ListCredentialsResponse: + response = await self.querier.send_get_request( + path=NormalisedURLPath("/recipe/webauthn/user/credential/list"), + params={"recipeUserId": recipe_user_id}, + user_context=user_context, + ) + + return ListCredentialsResponse.from_json(response) + + async def remove_generated_options( + self, + *, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, RemoveGeneratedOptionsErrorResponse]: + response = await self.querier.send_delete_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/options/remove"), + params={"webauthnGeneratedOptionsId": webauthn_generated_options_id}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "OPTIONS_NOT_FOUND_ERROR": + return RemoveGeneratedOptionsErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return OkResponseBaseModel() + + async def get_generated_options( + self, + *, + webauthn_generated_options_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[GetGeneratedOptionsResponse, GetGeneratedOptionsErrorResponse]: + response = await self.querier.send_get_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/options"), + params={"webauthnGeneratedOptionsId": webauthn_generated_options_id}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "OPTIONS_NOT_FOUND_ERROR": + return OptionsNotFoundErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return GetGeneratedOptionsResponse.from_json(response) + + async def update_user_email( + self, + *, + email: str, + recipe_user_id: str, + tenant_id: str, + user_context: UserContext, + ) -> Union[OkResponseBaseModel, UpdateUserEmailErrorResponse]: + response = await self.querier.send_put_request( + path=NormalisedURLPath(f"/{tenant_id}/recipe/webauthn/user/email"), + data={"email": email, "recipeUserId": recipe_user_id}, + query_params={}, + user_context=user_context, + ) + + if response["status"] != "OK": + if response["status"] == "EMAIL_ALREADY_EXISTS_ERROR": + return EmailAlreadyExistsErrorResponse() + if response["status"] == "UNKNOWN_USER_ID_ERROR": + return UnknownUserIdErrorResponse() + + raise Exception(f"Unknown Error: {response}") + + return OkResponseBaseModel() diff --git a/supertokens_python/recipe/webauthn/types/__init__.py b/supertokens_python/recipe/webauthn/types/__init__.py new file mode 100644 index 000000000..66eeea436 --- /dev/null +++ b/supertokens_python/recipe/webauthn/types/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/supertokens_python/recipe/webauthn/types/base.py b/supertokens_python/recipe/webauthn/types/base.py new file mode 100644 index 000000000..e8cfcb124 --- /dev/null +++ b/supertokens_python/recipe/webauthn/types/base.py @@ -0,0 +1,25 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import List + +from supertokens_python.types.response import CamelCaseBaseModel + + +class WebauthnInfo(CamelCaseBaseModel): + credential_ids: List[str] + + +class WebauthnInfoInput(CamelCaseBaseModel): + credential_id: str diff --git a/supertokens_python/recipe/webauthn/types/config.py b/supertokens_python/recipe/webauthn/types/config.py new file mode 100644 index 000000000..f0c36202e --- /dev/null +++ b/supertokens_python/recipe/webauthn/types/config.py @@ -0,0 +1,217 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union, runtime_checkable + +from supertokens_python.framework import BaseRequest +from supertokens_python.ingredients.emaildelivery import EmailDeliveryIngredient +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryConfig, + EmailDeliveryConfigWithService, +) +from supertokens_python.types.base import UserContext + +if TYPE_CHECKING: + from supertokens_python.recipe.webauthn.interfaces.api import ( + APIInterface, + TypeWebauthnEmailDeliveryInput, + ) + from supertokens_python.recipe.webauthn.interfaces.recipe import RecipeInterface + +InterfaceType = TypeVar("InterfaceType") +"""Generic Type for use in `InterfaceOverride`""" + + +@runtime_checkable +class GetRelyingPartyId(Protocol): + """ + Callable signature for `WebauthnConfig.get_relying_party_id`. + """ + + async def __call__( + self, + *, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class NormalisedGetRelyingPartyId(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.get_relying_party_id`. + """ + + async def __call__( + self, + *, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class GetRelyingPartyName(Protocol): + """ + Callable signature for `WebauthnConfig.get_relying_party_name`. + """ + + async def __call__( + self, + *, + tenant_id: str, + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class NormalisedGetRelyingPartyName(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.get_relying_party_name`. + """ + + async def __call__( + self, + *, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class GetOrigin(Protocol): + """ + Callable signature for `WebauthnConfig.get_origin`. + """ + + async def __call__( + self, + *, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class NormalisedGetOrigin(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.get_origin`. + """ + + async def __call__( + self, + *, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, + ) -> str: ... + + +@runtime_checkable +class GetEmailDeliveryConfig(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.get_email_delivery_config`. + """ + + async def __call__(self) -> EmailDeliveryConfig[TypeWebauthnEmailDeliveryInput]: ... + + +@runtime_checkable +class NormalisedGetEmailDeliveryConfig(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.get_email_delivery_config`. + """ + + def __call__( + self, + ) -> EmailDeliveryConfigWithService[TypeWebauthnEmailDeliveryInput]: ... + + +@runtime_checkable +class ValidateEmailAddress(Protocol): + """ + Callable signature for `WebauthnConfig.validate_email_address`. + """ + + async def __call__( + self, *, email: str, tenant_id: str, user_context: UserContext + ) -> Optional[str]: ... + + +@runtime_checkable +class NormalisedValidateEmailAddress(Protocol): + """ + Callable signature for `WebauthnNormalisedConfig.validate_email_address`. + """ + + async def __call__( + self, *, email: str, tenant_id: str, user_context: UserContext + ) -> Optional[str]: ... + + +@runtime_checkable +class InterfaceOverride(Protocol[InterfaceType]): + """ + Callable signature for `WebauthnConfig.override.*`. + """ + + def __call__( + self, + original_implementation: InterfaceType, + ) -> InterfaceType: ... + + +# NOTE: Using dataclasses for these classes since validation is not required +@dataclass +class OverrideConfig: + """ + `WebauthnConfig.override` + """ + + functions: Optional[InterfaceOverride[RecipeInterface]] = None + apis: Optional[InterfaceOverride[APIInterface]] = None + + +@dataclass +class WebauthnConfig: + get_relying_party_id: Optional[Union[str, GetRelyingPartyId]] = None + get_relying_party_name: Optional[Union[str, GetRelyingPartyName]] = None + get_origin: Optional[GetOrigin] = None + email_delivery: Optional[EmailDeliveryConfig[TypeWebauthnEmailDeliveryInput]] = None + validate_email_address: Optional[ValidateEmailAddress] = None + override: Optional[OverrideConfig] = None + + +@dataclass +class NormalisedWebauthnConfig: + get_relying_party_id: NormalisedGetRelyingPartyId + get_relying_party_name: NormalisedGetRelyingPartyName + get_origin: NormalisedGetOrigin + get_email_delivery_config: NormalisedGetEmailDeliveryConfig + validate_email_address: NormalisedValidateEmailAddress + override: OverrideConfig + + +@dataclass +class WebauthnIngredients: + email_delivery: Optional[ + EmailDeliveryIngredient[TypeWebauthnEmailDeliveryInput] + ] = None diff --git a/supertokens_python/recipe/webauthn/utils.py b/supertokens_python/recipe/webauthn/utils.py new file mode 100644 index 000000000..c3a35abfb --- /dev/null +++ b/supertokens_python/recipe/webauthn/utils.py @@ -0,0 +1,218 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from re import fullmatch +from typing import Any, Optional, Union +from urllib.parse import urlparse + +from supertokens_python.framework.request import BaseRequest +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryConfigWithService, +) +from supertokens_python.recipe.webauthn.emaildelivery.services.backward_compatibility.base import ( + BackwardCompatibilityService, +) +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, +) +from supertokens_python.recipe.webauthn.types.config import ( + GetOrigin, + GetRelyingPartyId, + GetRelyingPartyName, + NormalisedGetOrigin, + NormalisedGetRelyingPartyId, + NormalisedGetRelyingPartyName, + NormalisedValidateEmailAddress, + NormalisedWebauthnConfig, + OverrideConfig, + ValidateEmailAddress, + WebauthnConfig, +) +from supertokens_python.supertokens import AppInfo +from supertokens_python.types.base import UserContext + + +def validate_and_normalise_user_input( + app_info: AppInfo, config: Optional[WebauthnConfig] +) -> NormalisedWebauthnConfig: + if config is None: + config = WebauthnConfig() + + get_relying_party_id = validate_and_normalise_relying_party_id_config( + app_info, config.get_relying_party_id + ) + get_relying_party_name = validate_and_normalise_relying_party_name_config( + app_info, config.get_relying_party_name + ) + get_origin = validate_and_normalise_get_origin_config(app_info, config.get_origin) + validate_email_address = validate_and_normalise_validate_email_address_config( + config.validate_email_address + ) + + if config.override is None: + override = OverrideConfig() + else: + override = OverrideConfig( + functions=config.override.functions, + apis=config.override.apis, + ) + + def get_email_delivery_config() -> EmailDeliveryConfigWithService[ + TypeWebauthnEmailDeliveryInput + ]: + if config.email_delivery is not None and config.email_delivery.service: + return EmailDeliveryConfigWithService( + service=config.email_delivery.service, + override=config.email_delivery.override, + ) + + email_service = BackwardCompatibilityService(app_info=app_info) + if ( + config.email_delivery is not None + and config.email_delivery.override is not None + ): + override = config.email_delivery.override + else: + override = None + return EmailDeliveryConfigWithService(email_service, override=override) + + return NormalisedWebauthnConfig( + get_relying_party_id=get_relying_party_id, + get_relying_party_name=get_relying_party_name, + get_origin=get_origin, + get_email_delivery_config=get_email_delivery_config, + validate_email_address=validate_email_address, + override=override, + ) + + +def validate_and_normalise_relying_party_id_config( + app_info: AppInfo, relying_party_id_config: Optional[Union[str, GetRelyingPartyId]] +) -> NormalisedGetRelyingPartyId: + async def inner_fn( + *, tenant_id: str, request: Optional[BaseRequest], user_context: UserContext + ) -> str: + if isinstance(relying_party_id_config, str): + return relying_party_id_config + + if callable(relying_party_id_config): + return await relying_party_id_config( + tenant_id=tenant_id, + request=request, + user_context=user_context, + ) + + url_string = app_info.api_domain.get_as_string_dangerous() + url = urlparse(url_string) + + if url.hostname is None: + raise Exception("get_relying_party_id parsed a URL with no hostname") + + return url.hostname + + return inner_fn + + +def validate_and_normalise_relying_party_name_config( + app_info: AppInfo, + relying_party_name_config: Optional[Union[str, GetRelyingPartyName]], +) -> NormalisedGetRelyingPartyName: + async def inner_fn( + *, tenant_id: str, request: Optional[BaseRequest], user_context: UserContext + ) -> str: + if isinstance(relying_party_name_config, str): + return relying_party_name_config + + if callable(relying_party_name_config): + return await relying_party_name_config( + tenant_id=tenant_id, user_context=user_context + ) + + return app_info.app_name + + return inner_fn + + +async def default_email_validator(value: Any, _tenant_id: str) -> Optional[str]: + # We check if the email syntax is correct + # As per https://github.com/supertokens/supertokens-auth-react/issues/5#issuecomment-709512438 + # Regex from https://stackoverflow.com/a/46181/3867175 + if (not isinstance(value, str)) or ( + fullmatch( + r'^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,' + r"3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$", + value, + ) + is None + ): + return "Email is not valid" + + return None + + +def validate_and_normalise_validate_email_address_config( + validate_email_address_config: Optional[ValidateEmailAddress], +) -> NormalisedValidateEmailAddress: + async def inner_fn( + *, email: str, tenant_id: str, user_context: UserContext + ) -> Optional[str]: + if isinstance(validate_email_address_config, str): + return validate_email_address_config + + if callable(validate_email_address_config): + return await validate_email_address_config( + email=email, + tenant_id=tenant_id, + user_context=user_context, + ) + + return await default_email_validator(email, tenant_id) + + return inner_fn + + +def validate_and_normalise_get_origin_config( + app_info: AppInfo, + get_origin_config: Optional[GetOrigin], +) -> NormalisedGetOrigin: + async def inner_fn( + *, tenant_id: str, request: Optional[BaseRequest], user_context: UserContext + ) -> str: + if callable(get_origin_config): + return await get_origin_config( + tenant_id=tenant_id, + request=request, + user_context=user_context, + ) + + return app_info.get_origin( + request=request, user_context=user_context + ).get_as_string_dangerous() + + return inner_fn + + +def get_recover_account_link( + app_info: AppInfo, + token: str, + tenant_id: str, + request: Optional[BaseRequest], + user_context: UserContext, +) -> str: + origin = app_info.get_origin( + request=request, user_context=user_context + ).get_as_string_dangerous() + website_base_path = app_info.website_base_path.get_as_string_dangerous() + + return f"{origin}{website_base_path}/webauthn/recover?token={token}&tenantId={tenant_id}" diff --git a/supertokens_python/syncio/__init__.py b/supertokens_python/syncio/__init__.py index 868e95a4d..cd5ab11d8 100644 --- a/supertokens_python/syncio/__init__.py +++ b/supertokens_python/syncio/__init__.py @@ -25,7 +25,8 @@ UserIdMappingAlreadyExistsError, UserIDTypes, ) -from supertokens_python.types import AccountInfo, User +from supertokens_python.types import User +from supertokens_python.types.base import AccountInfoInput def get_users_oldest_first( @@ -164,7 +165,7 @@ def update_or_delete_user_id_mapping_info( def list_users_by_account_info( tenant_id: str, - account_info: AccountInfo, + account_info: AccountInfoInput, do_union_of_account_info: bool = False, user_context: Optional[Dict[str, Any]] = None, ) -> List[User]: diff --git a/supertokens_python/types/__init__.py b/supertokens_python/types/__init__.py new file mode 100644 index 000000000..72f7cda5d --- /dev/null +++ b/supertokens_python/types/__init__.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# Re-export types to maintain backward compatibility to 0.29 +# Do not add more exports here, prefer importing from the actual module +# This syntax unnecessarily pollutes the namespaces and slows down imports + +from .base import ( + AccountInfo, + LoginMethod, + MaybeAwaitable, + RecipeUserId, + User, +) +from .response import APIResponse, GeneralErrorResponse + +__all__ = ( + "APIResponse", + "GeneralErrorResponse", + "AccountInfo", + "LoginMethod", + "MaybeAwaitable", + "RecipeUserId", + "User", +) diff --git a/supertokens_python/types/auth_utils.py b/supertokens_python/types/auth_utils.py new file mode 100644 index 000000000..9cbe03e33 --- /dev/null +++ b/supertokens_python/types/auth_utils.py @@ -0,0 +1,39 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing_extensions import Literal + +from supertokens_python.types.response import StatusReasonResponseBaseModel + + +class LinkingToSessionUserFailedError( + StatusReasonResponseBaseModel[ + Literal["LINKING_TO_SESSION_USER_FAILED"], + Literal[ + "EMAIL_VERIFICATION_REQUIRED", + "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "INPUT_USER_IS_NOT_A_PRIMARY_USER", + ], + ] +): + status: Literal["LINKING_TO_SESSION_USER_FAILED"] = "LINKING_TO_SESSION_USER_FAILED" + reason: Literal[ + "EMAIL_VERIFICATION_REQUIRED", + "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", + "INPUT_USER_IS_NOT_A_PRIMARY_USER", + ] diff --git a/supertokens_python/types.py b/supertokens_python/types/base.py similarity index 73% rename from supertokens_python/types.py rename to supertokens_python/types/base.py index 7f6365c1d..98008f050 100644 --- a/supertokens_python/types.py +++ b/supertokens_python/types/base.py @@ -1,4 +1,8 @@ -# Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +""" +Types in `supertokens_python.types` as of 0.29 +""" + +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. # # This software is licensed under the Apache License, Version 2.0 (the # "License") as published by the Apache Software Foundation. @@ -11,19 +15,29 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + from __future__ import annotations -from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Awaitable, Dict, List, Optional, TypeVar, Union import phonenumbers # type: ignore from phonenumbers import format_number, parse # type: ignore -from typing_extensions import Literal - -_T = TypeVar("_T") +from typing_extensions import Literal, TypeAlias if TYPE_CHECKING: from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo + from supertokens_python.recipe.webauthn.types.base import ( + WebauthnInfo, + WebauthnInfoInput, + ) + + +# Generics +_T = TypeVar("_T") +MaybeAwaitable = Union[Awaitable[_T], _T] + +# Common Types +UserContext: TypeAlias = Dict[str, Any] class RecipeUserId: @@ -45,10 +59,43 @@ def __init__( email: Optional[str] = None, phone_number: Optional[str] = None, third_party: Optional[ThirdPartyInfo] = None, + webauthn: Optional[WebauthnInfo] = None, + ): + self.email = email + self.phone_number = phone_number + self.third_party = third_party + self.webauthn = webauthn + + def to_json(self) -> Dict[str, Any]: + json_repo: Dict[str, Any] = {} + if self.email is not None: + json_repo["email"] = self.email + if self.phone_number is not None: + json_repo["phoneNumber"] = self.phone_number + if self.third_party is not None: + json_repo["thirdParty"] = { + "id": self.third_party.id, + "userId": self.third_party.user_id, + } + if self.webauthn is not None: + json_repo["webauthn"] = { + "credentialIds": self.webauthn.credential_ids, + } + return json_repo + + +class AccountInfoInput: + def __init__( + self, + email: Optional[str] = None, + phone_number: Optional[str] = None, + third_party: Optional[ThirdPartyInfo] = None, + webauthn: Optional[WebauthnInfoInput] = None, ): self.email = email self.phone_number = phone_number self.third_party = third_party + self.webauthn = webauthn def to_json(self) -> Dict[str, Any]: json_repo: Dict[str, Any] = {} @@ -61,25 +108,30 @@ def to_json(self) -> Dict[str, Any]: "id": self.third_party.id, "userId": self.third_party.user_id, } + if self.webauthn is not None: + json_repo["webauthn"] = { + "credentialId": self.webauthn.credential_id, + } return json_repo class LoginMethod(AccountInfo): def __init__( self, - recipe_id: Literal["emailpassword", "thirdparty", "passwordless"], + recipe_id: Literal["emailpassword", "thirdparty", "passwordless", "webauthn"], recipe_user_id: str, tenant_ids: List[str], email: Union[str, None], phone_number: Union[str, None], third_party: Union[ThirdPartyInfo, None], + webauthn: Optional[WebauthnInfo], time_joined: int, verified: bool, ): - super().__init__(email, phone_number, third_party) - self.recipe_id: Literal["emailpassword", "thirdparty", "passwordless"] = ( - recipe_id - ) + super().__init__(email, phone_number, third_party, webauthn=webauthn) + self.recipe_id: Literal[ + "emailpassword", "thirdparty", "passwordless", "webauthn" + ] = recipe_id self.recipe_user_id = RecipeUserId(recipe_user_id) self.tenant_ids: List[str] = tenant_ids self.time_joined = time_joined @@ -132,6 +184,15 @@ def has_same_third_party_info_as( and self.third_party.user_id.strip() == third_party.user_id.strip() ) + def has_same_webauthn_info_as(self, webauthn: Optional[WebauthnInfoInput]) -> bool: + if webauthn is None: + return False + + return ( + self.webauthn is not None + and webauthn.credential_id in self.webauthn.credential_ids + ) + def to_json(self) -> Dict[str, Any]: result: Dict[str, Any] = { "recipeId": self.recipe_id, @@ -146,11 +207,14 @@ def to_json(self) -> Dict[str, Any]: result["phoneNumber"] = self.phone_number if self.third_party is not None: result["thirdParty"] = self.third_party.to_json() + if self.webauthn is not None: + result["webauthn"] = self.webauthn.to_json() return result @staticmethod def from_json(json: Dict[str, Any]) -> "LoginMethod": from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo as TPI + from supertokens_python.recipe.webauthn.types.base import WebauthnInfo return LoginMethod( recipe_id=json["recipeId"], @@ -169,6 +233,11 @@ def from_json(json: Dict[str, Any]) -> "LoginMethod": if "thirdParty" in json and json["thirdParty"] is not None else None ), + webauthn=( + WebauthnInfo(credential_ids=json["webauthn"]["credentialIds"]) + if "webauthn" in json and json["webauthn"] is not None + else None + ), time_joined=json["timeJoined"], verified=json["verified"], ) @@ -183,6 +252,7 @@ def __init__( emails: List[str], phone_numbers: List[str], third_party: List[ThirdPartyInfo], + webauthn: WebauthnInfo, login_methods: List[LoginMethod], time_joined: int, ): @@ -192,6 +262,7 @@ def __init__( self.emails = emails self.phone_numbers = phone_numbers self.third_party = third_party + self.webauthn = webauthn self.login_methods = login_methods self.time_joined = time_joined @@ -204,6 +275,7 @@ def __eq__(self, other: Any) -> bool: and self.emails == other.emails and self.phone_numbers == other.phone_numbers and self.third_party == other.third_party + and self.webauthn == other.webauthn and self.login_methods == other.login_methods and self.time_joined == other.time_joined ) @@ -217,6 +289,7 @@ def to_json(self) -> Dict[str, Any]: "emails": self.emails, "phoneNumbers": self.phone_numbers, "thirdParty": [tp.to_json() for tp in self.third_party], + "webauthn": self.webauthn.to_json(), "loginMethods": [lm.to_json() for lm in self.login_methods], "timeJoined": self.time_joined, } @@ -224,6 +297,12 @@ def to_json(self) -> Dict[str, Any]: @staticmethod def from_json(json: Dict[str, Any]) -> "User": from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo as TPI + from supertokens_python.recipe.webauthn.types.base import WebauthnInfo + + if "webauthn" in json: + webauthn = WebauthnInfo.from_json(json["webauthn"]) + else: + webauthn = WebauthnInfo(credential_ids=[]) return User( user_id=json["id"], @@ -232,24 +311,7 @@ def from_json(json: Dict[str, Any]) -> "User": emails=json["emails"], phone_numbers=json["phoneNumbers"], third_party=[TPI.from_json(tp) for tp in json["thirdParty"]], + webauthn=webauthn, login_methods=[LoginMethod.from_json(lm) for lm in json["loginMethods"]], time_joined=json["timeJoined"], ) - - -class APIResponse(ABC): - @abstractmethod - def to_json(self) -> Dict[str, Any]: - pass - - -class GeneralErrorResponse(APIResponse): - def __init__(self, message: str): - self.status = "GENERAL_ERROR" - self.message = message - - def to_json(self) -> Dict[str, Any]: - return {"status": self.status, "message": self.message} - - -MaybeAwaitable = Union[Awaitable[_T], _T] diff --git a/supertokens_python/types/response.py b/supertokens_python/types/response.py new file mode 100644 index 000000000..e25b2f231 --- /dev/null +++ b/supertokens_python/types/response.py @@ -0,0 +1,105 @@ +# Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. +# +# This software is licensed under the Apache License, Version 2.0 (the +# "License") as published by the Apache Software Foundation. +# +# You may not use this file except in compliance with the License. You may +# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, Literal, Protocol, TypeVar, runtime_checkable + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from typing_extensions import Self + +Status = TypeVar("Status", bound=str) +Reason = TypeVar("Reason", bound=str) + + +class APIResponse(ABC): + @abstractmethod + def to_json(self) -> Dict[str, Any]: ... + + +class GeneralErrorResponse(APIResponse): + def __init__(self, message: str): + self.status = "GENERAL_ERROR" + self.message = message + + def to_json(self) -> Dict[str, Any]: + return {"status": self.status, "message": self.message} + + +class CamelCaseBaseModel(APIResponse, BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + # Support interop between Pydantic and old classes + arbitrary_types_allowed=True, + ) + + @classmethod + def from_json(cls, obj: Dict[str, Any]) -> Self: + """ + Converts a dictionary to a Pydantic model. + """ + return cls.model_validate(obj) + + def to_json(self) -> Dict[str, Any]: + """ + Converts the Pydantic model to a dictionary. + """ + return self.model_dump(by_alias=True) + + +""" +Protocol classes will allow use of older classes with these new types +They're like interfaces and allow classes to be interpreted as per their properties, +instead of their actual types, allowing use with the `StatusResponse` types. + +Issue: Generic Protocols require the generic to be `invariant` - types need to be exact +Types defined as `StatusResponse[Literal["A", "B"]]`, and only one of these is returned. +This requires the generic to be `covariant`, which is not allowed in Protocols. + +Solution: Refactor the types to be `StatusResponse[Literal["A"]] | StatusResponse[Literal["B"]]` +""" + + +@runtime_checkable +class HasStatus(Protocol[Status]): + status: Status + + +@runtime_checkable +class HasErr(Protocol[Status]): + err: Status + + +@runtime_checkable +class HasReason(Protocol[Status]): + reason: Status + + +class StatusResponseBaseModel(CamelCaseBaseModel, Generic[Status]): + status: Status + + +class StatusReasonResponseBaseModel( + StatusResponseBaseModel[Status], Generic[Status, Reason] +): + reason: Reason + + +class OkResponseBaseModel(StatusResponseBaseModel[Literal["OK"]]): + status: Literal["OK"] = "OK" + + +class StatusErrResponseBaseModel(StatusResponseBaseModel[Status]): + err: str diff --git a/supertokens_python/utils.py b/supertokens_python/utils.py index aa002dd17..c981f173a 100644 --- a/supertokens_python/utils.py +++ b/supertokens_python/utils.py @@ -449,3 +449,20 @@ def get_normalised_should_try_linking_with_session_user_flag( if has_greater_than_equal_to_fdi(req, "3.1"): return body.get("shouldTryLinkingWithSessionUser", False) return None + + +def get_error_response_reason_from_map( + response_status: str, + error_code_map: Union[ + Dict[str, Dict[str, str]], + Dict[str, str], + Dict[str, Union[str, Dict[str, str]]], + ], +) -> str: + reason_map_like = error_code_map[response_status] + if isinstance(reason_map_like, dict): + reason = reason_map_like[response_status] + else: + reason = reason_map_like + + return reason diff --git a/tests/auth-react/django3x/mysite/store.py b/tests/auth-react/django3x/mysite/store.py index cad59fd66..fd73f4881 100644 --- a/tests/auth-react/django3x/mysite/store.py +++ b/tests/auth-react/django3x/mysite/store.py @@ -1,9 +1,27 @@ import time -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, TypedDict, Union +from urllib.parse import parse_qs, urlparse from typing_extensions import Literal + +class SaveWebauthnTokenUser(TypedDict): + email: str + recover_account_link: str + token: str + + latest_url_with_token = "" +code_store: Dict[str, List[Dict[str, Any]]] = {} +accountlinking_config: Dict[str, Any] = {} +enabled_providers: Optional[List[Any]] = None +enabled_recipes: Optional[List[Any]] = None +mfa_info: Dict[str, Any] = {} +contact_method: Union[None, Literal["PHONE", "EMAIL", "EMAIL_OR_PHONE"]] = None +flow_type: Union[ + None, Literal["USER_INPUT_CODE", "MAGIC_LINK", "USER_INPUT_CODE_AND_MAGIC_LINK"] +] = None +webauthn_store: Dict[str, SaveWebauthnTokenUser] = {} def save_url_with_token(url_with_token: str): @@ -22,17 +40,6 @@ def get_url_with_token() -> str: return latest_url_with_token -code_store: Dict[str, List[Dict[str, Any]]] = {} -accountlinking_config: Dict[str, Any] = {} -enabled_providers: Optional[List[Any]] = None -enabled_recipes: Optional[List[Any]] = None -mfa_info: Dict[str, Any] = {} -contact_method: Union[None, Literal["PHONE", "EMAIL", "EMAIL_OR_PHONE"]] = None -flow_type: Union[ - None, Literal["USER_INPUT_CODE", "MAGIC_LINK", "USER_INPUT_CODE_AND_MAGIC_LINK"] -] = None - - def save_code( pre_auth_session_id: str, url_with_link_code: Union[str, None], @@ -53,3 +60,20 @@ def save_code( def get_codes(pre_auth_session_id: str) -> List[Dict[str, Any]]: return code_store.get(pre_auth_session_id, []) + + +def save_webauthn_token(user: SaveWebauthnTokenUser, recover_account_link: str): + global webauthn_store + webauthn = webauthn_store.get( + user["email"], + {"email": user["email"], "recover_account_link": "", "token": ""}, + ) + webauthn["recover_account_link"] = recover_account_link + + # Parse the token from the recoverAccountLink using URL and URLSearchParams + url = urlparse(recover_account_link) + token = parse_qs(url.query).get("token") + if token is not None and len(token) > 0: + webauthn["token"] = token[0] + + webauthn_store[user["email"]] = webauthn diff --git a/tests/auth-react/django3x/mysite/utils.py b/tests/auth-react/django3x/mysite/utils.py index b3a0a6941..97335f18e 100644 --- a/tests/auth-react/django3x/mysite/utils.py +++ b/tests/auth-react/django3x/mysite/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from typing import Any, Awaitable, Callable, Dict, List, Optional, Union @@ -5,6 +7,10 @@ from dotenv import load_dotenv from supertokens_python import InputAppInfo, Supertokens, SupertokensConfig, init from supertokens_python.framework.request import BaseRequest +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryConfigWithService, + EmailDeliveryInterface, +) from supertokens_python.recipe import ( accountlinking, emailpassword, @@ -17,6 +23,7 @@ thirdparty, totp, userroles, + webauthn, ) from supertokens_python.recipe.accountlinking import AccountInfoWithRecipeIdAndUserId from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe @@ -81,10 +88,20 @@ from supertokens_python.recipe.thirdparty.types import UserInfo, UserInfoEmail from supertokens_python.recipe.totp.recipe import TOTPRecipe from supertokens_python.recipe.userroles import UserRolesRecipe -from supertokens_python.types import GeneralErrorResponse, User +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, +) +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.types.config import WebauthnConfig +from supertokens_python.types import User +from supertokens_python.types.response import GeneralErrorResponse from typing_extensions import Literal -from .store import save_code, save_url_with_token +from .store import ( + save_code, + save_url_with_token, + save_webauthn_token, +) load_dotenv("../auth-react.env") @@ -163,6 +180,24 @@ async def send_email( save_url_with_token(template_vars.password_reset_link) +class CustomWebwuthnEmailService( + EmailDeliveryInterface[TypeWebauthnEmailDeliveryInput] +): + async def send_email( + self, + template_vars: TypeWebauthnEmailDeliveryInput, + user_context: Dict[str, Any], + ): + save_webauthn_token( + user={ + "email": template_vars.user.email, + "recover_account_link": "", + "token": "", + }, + recover_account_link=template_vars.recover_account_link, + ) + + async def validate_age(value: Any, _tenant_id: str): try: if int(value) < 18: @@ -364,6 +399,7 @@ def custom_init( MultiFactorAuthRecipe.reset() OpenIdRecipe.reset() OAuth2ProviderRecipe.reset() + WebauthnRecipe.reset() def override_email_verification_apis( original_implementation_email_verification: EmailVerificationAPIInterface, @@ -493,6 +529,7 @@ async def sign_in_post( if body is not None and "generalErrorMessage" in body: msg = body["generalErrorMessage"] return GeneralErrorResponse(msg) + return await original_sign_in_post( form_fields, tenant_id, @@ -515,6 +552,7 @@ async def sign_up_post( ) if is_general_error: return GeneralErrorResponse("general error from API sign up") + return await original_sign_up_post( form_fields, tenant_id, @@ -955,6 +993,16 @@ async def resync_session_and_fetch_mfa_info_put( ), ), }, + { + "id": "webauthn", + "init": webauthn.init( + config=WebauthnConfig( + email_delivery=EmailDeliveryConfigWithService[ + TypeWebauthnEmailDeliveryInput + ](service=CustomWebwuthnEmailService()) # type: ignore + ) + ), + }, { "id": "thirdparty", "init": thirdparty.init( diff --git a/tests/auth-react/django3x/polls/urls.py b/tests/auth-react/django3x/polls/urls.py index b7bc90fd5..9622ebeaa 100644 --- a/tests/auth-react/django3x/polls/urls.py +++ b/tests/auth-react/django3x/polls/urls.py @@ -36,6 +36,17 @@ path("test/afterEach", views.after_each, name="afterEach"), path("test/setup/app", views.setup_core_app), path("test/setup/st", views.setup_st), + path("test/webauthn/get-token", views.get_webauthn_token, name="getWebauthnToken"), + path( + "test/webauthn/create-and-assert-credential", + views.webauthn_create_and_assert_credential, + name="webauthnCreateAndAssertCredential", + ), + path( + "test/webauthn/create-credential", + views.webauthn_create_credential, + name="webauthnCreateCredential", + ), ] mode = os.environ.get("APP_MODE", "asgi") diff --git a/tests/auth-react/django3x/polls/views.py b/tests/auth-react/django3x/polls/views.py index b248abc1b..77a0cc779 100644 --- a/tests/auth-react/django3x/polls/views.py +++ b/tests/auth-react/django3x/polls/views.py @@ -15,13 +15,13 @@ import os from typing import Any, Dict, List +import httpx from django.http import HttpRequest, HttpResponse, JsonResponse -from mysite.store import get_codes, get_url_with_token +from mysite.store import get_codes, get_url_with_token, webauthn_store from mysite.utils import custom_init from mysite.utils import setup_core_app as setup_core_app_impl from supertokens_python import convert_to_recipe_user_id from supertokens_python.asyncio import get_user -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.emailpassword.asyncio import update_email_or_password from supertokens_python.recipe.emailpassword.interfaces import ( EmailAlreadyExistsError, @@ -67,7 +67,10 @@ SignInUpNotAllowed, ) from supertokens_python.recipe.userroles import PermissionClaim, UserRoleClaim -from supertokens_python.types import AccountInfo, RecipeUserId +from supertokens_python.recipe.webauthn.functions import update_user_email +from supertokens_python.types import RecipeUserId +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput mode = os.environ.get("APP_MODE", "asgi") @@ -142,7 +145,7 @@ async def delete_user(request: HttpRequest): body = json.loads(request.body) user = await list_users_by_account_info( - "public", AccountInfo(email=body["email"]) + "public", AccountInfoInput(email=body["email"]) ) if len(user) == 0: raise Exception("Should not come here") @@ -194,7 +197,9 @@ def sync_delete_user(request: HttpRequest): from supertokens_python.syncio import delete_user, list_users_by_account_info body = json.loads(request.body) - user = list_users_by_account_info("public", AccountInfo(email=body["email"])) + user = list_users_by_account_info( + "public", AccountInfoInput(email=body["email"]) + ) if len(user) == 0: raise Exception("Should not come here") delete_user(user[0].id) @@ -244,7 +249,8 @@ async def change_email(request: HttpRequest): {"status": "EMAIL_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) return JsonResponse(resp.to_json()) - elif body["rid"] == "thirdparty": + + if body["rid"] == "thirdparty": user = await get_user(user_id=body["recipeUserId"]) assert user is not None login_method = next( @@ -274,7 +280,8 @@ async def change_email(request: HttpRequest): return JsonResponse( {"status": "EMAIL_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) - elif body["rid"] == "passwordless": + + if body["rid"] == "passwordless": resp = await update_user( recipe_user_id=convert_to_recipe_user_id(body["recipeUserId"]), email=body.get("email"), @@ -300,6 +307,14 @@ async def change_email(request: HttpRequest): } ) + if body["rid"] == "webauthn": + resp = await update_user_email( + recipe_user_id=body["recipeUserId"], + email=body["email"], + ) + + return JsonResponse(resp.to_json()) + raise Exception("Should not come here") @@ -460,6 +475,7 @@ def test_feature_flags(request: HttpRequest): "recipeConfig", "accountlinking-fixes", "oauth2", + "webauthn", ] } ) @@ -482,3 +498,39 @@ def setup_st(request: HttpRequest): custom_init(**body) return HttpResponse("") + + +def get_webauthn_token(request: HttpRequest): + webauthn = webauthn_store.get(request.GET.get("email", "")) + if webauthn is None: + return JsonResponse({"error": "Webauthn not found"}, status=404) + + return JsonResponse({"token": webauthn["token"]}) + + +def webauthn_create_and_assert_credential(request: HttpRequest): + body = json.loads(request.body) + if body is None: + raise Exception("Invalid request body") + + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-and-assert-credential", + json=body, + ) + + return JsonResponse(response.json()) + + +def webauthn_create_credential(request: HttpRequest): + body = json.loads(request.body) + if body is None: + raise Exception("Invalid request body") + + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-credential", + json=body, + ) + + return JsonResponse(response.json()) diff --git a/tests/auth-react/fastapi-server/app.py b/tests/auth-react/fastapi-server/app.py index 62691b421..c09a67f23 100644 --- a/tests/auth-react/fastapi-server/app.py +++ b/tests/auth-react/fastapi-server/app.py @@ -14,8 +14,10 @@ import os import time import typing -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, TypedDict, Union +from urllib.parse import parse_qs, urlparse +import httpx import requests import uvicorn # type: ignore from dotenv import load_dotenv @@ -36,9 +38,12 @@ init, ) from supertokens_python.asyncio import delete_user, get_user, list_users_by_account_info -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.framework.fastapi import get_middleware from supertokens_python.framework.request import BaseRequest +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryConfigWithService, + EmailDeliveryInterface, +) from supertokens_python.recipe import ( accountlinking, emailpassword, @@ -51,6 +56,7 @@ thirdparty, totp, userroles, + webauthn, ) from supertokens_python.recipe.accountlinking import AccountInfoWithRecipeIdAndUserId from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe @@ -171,12 +177,16 @@ add_role_to_user, create_new_role_or_add_permissions, ) -from supertokens_python.types import ( - AccountInfo, - GeneralErrorResponse, - RecipeUserId, - User, +from supertokens_python.recipe.webauthn.functions import update_user_email +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, ) +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.types.config import WebauthnConfig +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput +from supertokens_python.types.response import GeneralErrorResponse from typing_extensions import Literal load_dotenv("../auth-react.env") @@ -187,27 +197,34 @@ # Uncomment the following for response logging -# @app.middleware("http") -# async def log_response(request: Request, call_next): # type: ignore -# response = await call_next(request) # type: ignore - -# try: -# body_bytes = b"" -# async for chunk in response.body_iterator: # type: ignore -# body_bytes += chunk # type: ignore -# print(f"Response: {body_bytes.decode('utf-8')}") # type: ignore -# response_with_body = Response( -# content=body_bytes, -# status_code=response.status_code, # type: ignore -# headers=response.headers, # type: ignore -# media_type=response.media_type, # type: ignore -# ) -# return response_with_body -# except: -# pass -# return response # type: ignore +@app.middleware("http") +async def log_response(request: Request, call_next): # type: ignore + response = await call_next(request) # type: ignore + + try: + body_bytes = b"" + async for chunk in response.body_iterator: # type: ignore + body_bytes += chunk # type: ignore + print(f"Response: {body_bytes.decode('utf-8')}") # type: ignore + response_with_body = Response( + content=body_bytes, + status_code=response.status_code, # type: ignore + headers=response.headers, # type: ignore + media_type=response.media_type, # type: ignore + ) + return response_with_body + except Exception: + pass + return response # type: ignore +class SaveWebauthnTokenUser(TypedDict): + email: str + recover_account_link: str + token: str + + +webauthn_store: Dict[str, SaveWebauthnTokenUser] = {} code_store: Dict[str, List[Dict[str, Any]]] = {} accountlinking_config: Dict[str, Any] = {} enabled_providers: Optional[List[Any]] = None @@ -287,6 +304,41 @@ async def send_email( latest_url_with_token = template_vars.password_reset_link +def save_webauthn_token(user: SaveWebauthnTokenUser, recover_account_link: str): + global webauthn_store + webauthn = webauthn_store.get( + user["email"], + {"email": user["email"], "recover_account_link": "", "token": ""}, + ) + webauthn["recover_account_link"] = recover_account_link + + # Parse the token from the recoverAccountLink using URL and URLSearchParams + url = urlparse(recover_account_link) + token = parse_qs(url.query).get("token") + if token is not None and len(token) > 0: + webauthn["token"] = token[0] + + webauthn_store[user["email"]] = webauthn + + +class CustomWebwuthnEmailService( + EmailDeliveryInterface[TypeWebauthnEmailDeliveryInput] +): + async def send_email( + self, + template_vars: TypeWebauthnEmailDeliveryInput, + user_context: Dict[str, Any], + ): + save_webauthn_token( + user={ + "email": template_vars.user.email, + "recover_account_link": "", + "token": "", + }, + recover_account_link=template_vars.recover_account_link, + ) + + def get_api_port(): return "8083" @@ -450,6 +502,7 @@ def custom_init( MultiFactorAuthRecipe.reset() OpenIdRecipe.reset() OAuth2ProviderRecipe.reset() + WebauthnRecipe.reset() def override_email_verification_apis( original_implementation_email_verification: EmailVerificationAPIInterface, @@ -1041,6 +1094,16 @@ async def resync_session_and_fetch_mfa_info_put( ), ), }, + { + "id": "webauthn", + "init": webauthn.init( + config=WebauthnConfig( + email_delivery=EmailDeliveryConfigWithService[ + TypeWebauthnEmailDeliveryInput + ](service=CustomWebwuthnEmailService()) # type: ignore + ) + ), + }, { "id": "thirdparty", "init": thirdparty.init( @@ -1226,7 +1289,8 @@ async def change_email(request: Request): {"status": "EMAIL_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) return JSONResponse(resp.to_json()) - elif body["rid"] == "thirdparty": + + if body["rid"] == "thirdparty": user = await get_user(user_id=body["recipeUserId"]) assert user is not None login_method = next( @@ -1256,7 +1320,8 @@ async def change_email(request: Request): return JSONResponse( {"status": "EMAIL_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) - elif body["rid"] == "passwordless": + + if body["rid"] == "passwordless": resp = await update_user( recipe_user_id=convert_to_recipe_user_id(body["recipeUserId"]), email=body.get("email"), @@ -1283,6 +1348,14 @@ async def change_email(request: Request): } ) + if body["rid"] == "webauthn": + resp = await update_user_email( + recipe_user_id=body["recipeUserId"], + email=body["email"], + ) + + return JSONResponse(resp.to_json()) + raise Exception("Should not come here") @@ -1437,6 +1510,7 @@ def test_feature_flags(request: Request): "recipeConfig", "accountlinking-fixes", "oauth2", + "webauthn", ] return JSONResponse({"available": available}) @@ -1494,13 +1568,48 @@ async def set_role_api( @app.post("/deleteUser") async def delete_user_api(request: Request): body = await request.json() - user = await list_users_by_account_info("public", AccountInfo(email=body["email"])) + user = await list_users_by_account_info( + "public", AccountInfoInput(email=body["email"]) + ) if len(user) == 0: raise Exception("Should not come here") await delete_user(user[0].id) return JSONResponse({"status": "OK"}) +@app.get("/test/webauthn/get-token") +async def webauth_get_token(request: Request): + webauthn = webauthn_store.get(request.query_params.get("email", "")) + if webauthn is None: + return JSONResponse({"error": "Webauthn not found"}, status_code=404) + + return JSONResponse({"token": webauthn["token"]}) + + +@app.post("/test/webauthn/create-and-assert-credential") +async def webauthn_create_and_assert_credential(request: Request): + body = await request.json() + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-and-assert-credential", + json=body, + ) + + return JSONResponse(response.json()) + + +@app.post("/test/webauthn/create-credential") +async def webauthn_create_credential(request: Request): + body = await request.json() + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-credential", + json=body, + ) + + return JSONResponse(response.json()) + + async def override_global_claim_validators( gv: List[SessionClaimValidator], _session: SessionContainer, diff --git a/tests/auth-react/flask-server/app.py b/tests/auth-react/flask-server/app.py index e6d0c1b4e..11c8698c5 100644 --- a/tests/auth-react/flask-server/app.py +++ b/tests/auth-react/flask-server/app.py @@ -14,8 +14,10 @@ import os import time import traceback -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, TypedDict, Union +from urllib.parse import parse_qs, urlparse +import httpx import requests from dotenv import load_dotenv from flask import Flask, g, jsonify, make_response, request @@ -27,9 +29,12 @@ get_all_cors_headers, init, ) -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.framework.flask.flask_middleware import Middleware from supertokens_python.framework.request import BaseRequest +from supertokens_python.ingredients.emaildelivery.types import ( + EmailDeliveryConfigWithService, + EmailDeliveryInterface, +) from supertokens_python.recipe import ( accountlinking, emailpassword, @@ -42,6 +47,7 @@ thirdparty, totp, userroles, + webauthn, ) from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe from supertokens_python.recipe.accountlinking.types import ( @@ -168,13 +174,17 @@ add_role_to_user, create_new_role_or_add_permissions, ) -from supertokens_python.syncio import delete_user, get_user, list_users_by_account_info -from supertokens_python.types import ( - AccountInfo, - GeneralErrorResponse, - RecipeUserId, - User, +from supertokens_python.recipe.webauthn.functions import update_user_email +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, ) +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.types.config import WebauthnConfig +from supertokens_python.syncio import delete_user, get_user, list_users_by_account_info +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput +from supertokens_python.types.response import GeneralErrorResponse from typing_extensions import Literal load_dotenv("../auth-react.env") @@ -196,6 +206,14 @@ def get_website_domain(): latest_url_with_token = "" + +class SaveWebauthnTokenUser(TypedDict): + email: str + recover_account_link: str + token: str + + +webauthn_store: Dict[str, SaveWebauthnTokenUser] = {} code_store: Dict[str, List[Dict[str, Any]]] = {} accountlinking_config: Dict[str, Any] = {} enabled_providers: Optional[List[Any]] = None @@ -275,6 +293,41 @@ async def send_email( latest_url_with_token = template_vars.password_reset_link +def save_webauthn_token(user: SaveWebauthnTokenUser, recover_account_link: str): + global webauthn_store + webauthn = webauthn_store.get( + user["email"], + {"email": user["email"], "recover_account_link": "", "token": ""}, + ) + webauthn["recover_account_link"] = recover_account_link + + # Parse the token from the recoverAccountLink using URL and URLSearchParams + url = urlparse(recover_account_link) + token = parse_qs(url.query).get("token") + if token is not None and len(token) > 0: + webauthn["token"] = token[0] + + webauthn_store[user["email"]] = webauthn + + +class CustomWebwuthnEmailService( + EmailDeliveryInterface[TypeWebauthnEmailDeliveryInput] +): + async def send_email( + self, + template_vars: TypeWebauthnEmailDeliveryInput, + user_context: Dict[str, Any], + ): + save_webauthn_token( + user={ + "email": template_vars.user.email, + "recover_account_link": "", + "token": "", + }, + recover_account_link=template_vars.recover_account_link, + ) + + async def create_and_send_custom_email( _: User, url_with_token: str, __: Dict[str, Any] ) -> None: @@ -430,6 +483,7 @@ def custom_init( MultiFactorAuthRecipe.reset() OpenIdRecipe.reset() OAuth2ProviderRecipe.reset() + WebauthnRecipe.reset() def override_email_verification_apis( original_implementation_email_verification: EmailVerificationAPIInterface, @@ -1021,6 +1075,16 @@ async def resync_session_and_fetch_mfa_info_put( ), ), }, + { + "id": "webauthn", + "init": webauthn.init( + config=WebauthnConfig( + email_delivery=EmailDeliveryConfigWithService[ + TypeWebauthnEmailDeliveryInput + ](service=CustomWebwuthnEmailService()) # type: ignore + ) + ), + }, { "id": "thirdparty", "init": thirdparty.init( @@ -1164,7 +1228,7 @@ def ping(): @app.route("/changeEmail", methods=["POST"]) # type: ignore -def change_email(): +async def change_email(): body: Union[Any, None] = request.get_json() if body is None: raise Exception("Should never come here") @@ -1189,7 +1253,8 @@ def change_email(): ) # password policy violation error return jsonify(resp.to_json()) - elif body["rid"] == "thirdparty": + + if body["rid"] == "thirdparty": user = get_user(user_id=body["recipeUserId"]) assert user is not None login_method = next( @@ -1218,7 +1283,8 @@ def change_email(): return jsonify( {"status": "EMAIL_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) - elif body["rid"] == "passwordless": + + if body["rid"] == "passwordless": resp = update_user( recipe_user_id=convert_to_recipe_user_id(body["recipeUserId"]), email=body.get("email"), @@ -1241,6 +1307,14 @@ def change_email(): {"status": "PHONE_NUMBER_CHANGE_NOT_ALLOWED_ERROR", "reason": resp.reason} ) + if body["rid"] == "webauthn": + resp = await update_user_email( + recipe_user_id=body["recipeUserId"], + email=body["email"], + ) + + return jsonify(resp.to_json()) + raise Exception("Should not come here") @@ -1462,6 +1536,7 @@ def test_feature_flags(): "recipeConfig", "accountlinking-fixes", "oauth2", + "webauthn", ] return jsonify({"available": available}) @@ -1490,13 +1565,46 @@ def verify_email_api(): @app.route("/deleteUser", methods=["POST"]) # type: ignore def delete_user_api(): body: Dict[str, Any] = request.get_json() # type: ignore - user = list_users_by_account_info("public", AccountInfo(email=body["email"])) + user = list_users_by_account_info("public", AccountInfoInput(email=body["email"])) if len(user) == 0: raise Exception("Should not come here") delete_user(user[0].id) return jsonify({"status": "OK"}) +@app.route("/test/webauthn/get-token", methods=["GET"]) +async def webauth_get_token(): + webauthn = webauthn_store.get(request.args.get("email", "")) + if webauthn is None: + return jsonify({"error": "Webauthn not found"}, status_code=404) + + return jsonify({"token": webauthn["token"]}) + + +@app.route("/test/webauthn/create-and-assert-credential", methods=["POST"]) +async def webauthn_create_and_assert_credential(): + body: Dict[str, Any] = request.get_json() # type: ignore + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-and-assert-credential", + json=body, + ) + + return jsonify(response.json()) + + +@app.route("/test/webauthn/create-credential", methods=["POST"]) +async def webauthn_create_credential(): + body: Dict[str, Any] = request.get_json() # type: ignore + test_server_port = os.environ.get("NODE_PORT", 8082) + response = httpx.post( + url=f"http://localhost:{test_server_port}/test/webauthn/create-credential", + json=body, + ) + + return jsonify(response.json()) + + async def override_global_claim_validators( gv: List[SessionClaimValidator], _session: SessionContainer, diff --git a/tests/emailpassword/test_multitenancy.py b/tests/emailpassword/test_multitenancy.py index 4dca36a75..886fe3869 100644 --- a/tests/emailpassword/test_multitenancy.py +++ b/tests/emailpassword/test_multitenancy.py @@ -32,7 +32,7 @@ from supertokens_python.recipe.multitenancy.interfaces import ( TenantConfigCreateOrUpdate, ) -from supertokens_python.types import AccountInfo +from supertokens_python.types.base import AccountInfoInput from tests.utils import ( get_new_core_app_url, @@ -105,13 +105,13 @@ async def test_multitenancy_in_emailpassword(): # get user by email: by_email_user1 = await list_users_by_account_info( - "t1", AccountInfo(email="test@example.com") + "t1", AccountInfoInput(email="test@example.com") ) by_email_user2 = await list_users_by_account_info( - "t2", AccountInfo(email="test@example.com") + "t2", AccountInfoInput(email="test@example.com") ) by_email_user3 = await list_users_by_account_info( - "t3", AccountInfo(email="test@example.com") + "t3", AccountInfoInput(email="test@example.com") ) assert by_email_user1[0] == user1.user diff --git a/tests/frontendIntegration/drf_async/polls/views.py b/tests/frontendIntegration/drf_async/polls/views.py index 1bc63aa04..f5e53db57 100644 --- a/tests/frontendIntegration/drf_async/polls/views.py +++ b/tests/frontendIntegration/drf_async/polls/views.py @@ -148,7 +148,7 @@ async def wrapped_function(request: Request, *args, **kwargs): # type: ignore body = request.data # type: ignore await session_.merge_into_access_token_payload( # type: ignore - {**clearing, **body}, + {**clearing, **body}, # type: ignore {}, # type: ignore ) @@ -185,7 +185,7 @@ async def wrapped_function(request: Request, *args, **kwargs): # type: ignore body = request.data # type: ignore await merge_into_access_token_payload( session_.get_handle(), # type: ignore - {**clearing, **body}, + {**clearing, **body}, # type: ignore ) resp = Response(session_.get_access_token_payload()) # type: ignore diff --git a/tests/frontendIntegration/drf_sync/polls/views.py b/tests/frontendIntegration/drf_sync/polls/views.py index 93d1fd57b..0f869b3e2 100644 --- a/tests/frontendIntegration/drf_sync/polls/views.py +++ b/tests/frontendIntegration/drf_sync/polls/views.py @@ -147,7 +147,7 @@ def wrapped_function(request: Request, *args, **kwargs): # type: ignore body = request.data # type: ignore session_.sync_merge_into_access_token_payload( # type: ignore - {**clearing, **body}, + {**clearing, **body}, # type: ignore {}, # type: ignore ) @@ -184,7 +184,7 @@ def wrapped_function(request: Request, *args, **kwargs): # type: ignore body = request.data # type: ignore merge_into_access_token_payload( session_.get_handle(), # type: ignore - {**clearing, **body}, + {**clearing, **body}, # type: ignore ) resp = Response(session_.get_access_token_payload()) # type: ignore diff --git a/tests/passwordless/test_mutlitenancy.py b/tests/passwordless/test_mutlitenancy.py index d0417c249..20d079ade 100644 --- a/tests/passwordless/test_mutlitenancy.py +++ b/tests/passwordless/test_mutlitenancy.py @@ -26,7 +26,7 @@ consume_code, create_code, ) -from supertokens_python.types import AccountInfo +from supertokens_python.types.base import AccountInfoInput from tests.utils import ( get_new_core_app_url, @@ -122,13 +122,13 @@ async def test_multitenancy_functions(): # get user by email: by_email_user1 = await list_users_by_account_info( - "t1", AccountInfo(email="test@example.com") + "t1", AccountInfoInput(email="test@example.com") ) by_email_user2 = await list_users_by_account_info( - "t2", AccountInfo(email="test@example.com") + "t2", AccountInfoInput(email="test@example.com") ) by_email_user3 = await list_users_by_account_info( - "t3", AccountInfo(email="test@example.com") + "t3", AccountInfoInput(email="test@example.com") ) assert by_email_user1 == [user1.user] diff --git a/tests/test-server/app.py b/tests/test-server/app.py index 1cddc75e3..87647bbb7 100644 --- a/tests/test-server/app.py +++ b/tests/test-server/app.py @@ -41,6 +41,7 @@ session, thirdparty, totp, + webauthn, ) from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe from supertokens_python.recipe.dashboard.recipe import DashboardRecipe @@ -60,6 +61,11 @@ from supertokens_python.recipe.totp.recipe import TOTPRecipe from supertokens_python.recipe.usermetadata.recipe import UserMetadataRecipe from supertokens_python.recipe.userroles.recipe import UserRolesRecipe +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnEmailDeliveryInput, +) +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from supertokens_python.recipe.webauthn.types.config import WebauthnConfig from supertokens_python.recipe_module import RecipeModule from supertokens_python.types import RecipeUserId from test_functions_mapper import ( # pylint: disable=import-error @@ -70,6 +76,7 @@ from thirdparty import add_thirdparty_routes # pylint: disable=import-error from totp import add_totp_routes # pylint: disable=import-error from usermetadata import add_usermetadata_routes +from webauthn import add_webauthn_routes from supertokens import add_supertokens_routes # pylint: disable=import-error @@ -237,6 +244,7 @@ def st_reset(): MultiFactorAuthRecipe.reset() OAuth2ProviderRecipe.reset() OpenIdRecipe.reset() + WebauthnRecipe.reset() def init_st(config: Dict[str, Any]): @@ -286,7 +294,6 @@ def init_st(config: Dict[str, Any]): ), ) ) - elif recipe_id == "session": async def custom_unauthorised_callback( @@ -465,7 +472,6 @@ async def custom_unauthorised_callback( ), ) ) - elif recipe_id == "emailverification": recipe_config_json = json.loads(recipe_config.get("config", "{}")) @@ -624,6 +630,65 @@ async def send_sms( ) ) ) + elif recipe_id == "webauthn": + from supertokens_python.recipe.webauthn.types.config import ( + OverrideConfig as WebauthnOverrideConfig, + ) + + class WebauthnEmailDeliveryConfig( + EmailDeliveryConfig[TypeWebauthnEmailDeliveryInput] + ): + pass + + recipe_config_json = json.loads(recipe_config.get("config", "{}")) + recipe_list.append( + webauthn.init( + WebauthnConfig( + get_relying_party_id=callback_with_log( + "WebAuthn.getRelyingPartyId", + recipe_config_json.get("getRelyingPartyId"), + ) + if "getRelyingPartyId" in recipe_config_json + else None, + get_relying_party_name=callback_with_log( + "WebAuthn.getRelyingPartyName", + recipe_config_json.get("getRelyingPartyName"), + ) + if "getRelyingPartyName" in recipe_config_json + else None, + validate_email_address=callback_with_log( + "WebAuthn.validateEmailAddress", + recipe_config_json.get("validateEmailAddress"), + ) + if "validateEmailAddress" in recipe_config_json + else None, + get_origin=callback_with_log( + "WebAuthn.getOrigin", + recipe_config_json.get("getOrigin"), + ) + if "getOrigin" in recipe_config_json + else None, + email_delivery=WebauthnEmailDeliveryConfig( + override=override_builder_with_logging( + "WebAuthn.emailDelivery.override", + recipe_config_json.get("emailDelivery", {}).get( + "override" + ), + ), + ), + override=WebauthnOverrideConfig( + apis=override_builder_with_logging( + "WebAuthn.override.apis", + recipe_config_json.get("override", {}).get("apis"), + ), # type: ignore + functions=override_builder_with_logging( + "WebAuthn.override.functions", + recipe_config_json.get("override", {}).get("functions"), + ), # type: ignore + ), + ), + ) + ) interceptor_func = None if config.get("supertokens", {}).get("networkInterceptor") is not None: @@ -848,6 +913,7 @@ def handle_exception(e: Exception): add_usermetadata_routes(app) add_multifactorauth_routes(app) add_oauth2provider_routes(app) +add_webauthn_routes(app) if __name__ == "__main__": default_st_init() diff --git a/tests/test-server/supertokens.py b/tests/test-server/supertokens.py index 644069fe1..68911003d 100644 --- a/tests/test-server/supertokens.py +++ b/tests/test-server/supertokens.py @@ -7,7 +7,7 @@ get_users_oldest_first, list_users_by_account_info, ) -from supertokens_python.types import AccountInfo +from supertokens_python.types.base import AccountInfoInput def add_supertokens_routes(app: Flask): @@ -32,7 +32,7 @@ def list_users_by_account_info_api(): # type: ignore assert request.json is not None response = list_users_by_account_info( request.json["tenantId"], - AccountInfo( + AccountInfoInput( email=request.json["accountInfo"].get("email", None), phone_number=request.json["accountInfo"].get("phoneNumber", None), third_party=( diff --git a/tests/test-server/test_functions_mapper.py b/tests/test-server/test_functions_mapper.py index f1aec060d..34b54cf89 100644 --- a/tests/test-server/test_functions_mapper.py +++ b/tests/test-server/test_functions_mapper.py @@ -1,7 +1,6 @@ from typing import Any, Callable, Dict, List, Optional, Union from supertokens_python.asyncio import list_users_by_account_info -from supertokens_python.auth_utils import LinkingToSessionUserFailedError from supertokens_python.recipe.accountlinking import ( RecipeLevelUser, ShouldAutomaticallyLink, @@ -46,13 +45,10 @@ UserInfo, UserInfoEmail, ) -from supertokens_python.types import ( - AccountInfo, - APIResponse, - GeneralErrorResponse, - RecipeUserId, - User, -) +from supertokens_python.types import RecipeUserId, User +from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError +from supertokens_python.types.base import AccountInfoInput +from supertokens_python.types.response import APIResponse, GeneralErrorResponse class Info: @@ -681,7 +677,7 @@ async def func( ): if i.recipe_id == "emailpassword": users = await list_users_by_account_info( - "public", AccountInfo(email=i.email) + "public", AccountInfoInput(email=i.email) ) if len(users) <= 1: return ShouldNotAutomaticallyLink() @@ -870,6 +866,186 @@ async def get_email_for_recipe_user_id( return get_email_for_recipe_user_id + if eval_str.startswith("webauthn.init.getOrigin"): + if 'async()=>"https://api.supertokens.io"' in eval_str: + + async def origin_fn_1(*_: Any, **__: Any): + return "https://api.supertokens.io" + + return origin_fn_1 + + if 'async()=>"https://supertokens.io"' in eval_str: + + async def origin_fn_2(*_: Any, **__: Any): + return "https://supertokens.io" + + return origin_fn_2 + + if '()=>"https://test.testId.com"' in eval_str: + + async def origin_fn_3(*_: Any, **__: Any): + return "https://test.testId.com" + + return origin_fn_3 + + if '()=>"https://test.testOrigin.com"' in eval_str: + + async def origin_fn_4(*_: Any, **__: Any): + return "https://test.testOrigin.com" + + return origin_fn_4 + + if eval_str.startswith("webauthn.init.getRelyingPartyId"): + if '()=>"testOrigin.com"' in eval_str: + + async def rp_id_fn_1(*_: Any, **__: Any): + return "testOrigin.com" + + return rp_id_fn_1 + + if 'async()=>"supertokens.io"' in eval_str: + + async def rp_id_fn_2(*_: Any, **__: Any): + return "supertokens.io" + + return rp_id_fn_2 + + if eval_str.startswith("webauthn.init.getRelyingPartyName"): + if '()=>"testName"' in eval_str: + + async def rp_name_fn_1(*_: Any, **__: Any): + return "testName" + + return rp_name_fn_1 + + if '()=>"SuperTokens"' in eval_str: + + async def rp_name_fn_2(*_: Any, **__: Any): + return "SuperTokens" + + return rp_name_fn_2 + + if eval_str.startswith("webauthn.init.validateEmailAddress"): + if 'e=>"test@example.com"===e?void 0:"Invalid email"' in eval_str: + + async def validate_email_fn_1(*, email: str, **_: Any): + if email == "test@example.com": + return None + return "Invalid email" + + return validate_email_fn_1 + + if eval_str.startswith("webauthn.init.override.functions"): + from supertokens_python.recipe.webauthn.recipe_implementation import ( + RecipeImplementation as WebauthnRecipeImplementation, + ) + + if ( + 'e=>({...e,registerOptions:r=>e.registerOptions({...r,timeout:1e4,userVerification:"required",relyingPartyId:"testId.com",userPresence:!1})})' + in eval_str + ): + + def register_options_override_1( + original_implementation: WebauthnRecipeImplementation, + ) -> WebauthnRecipeImplementation: + og_register_options = original_implementation.register_options + + async def register_options( + **kwargs: Dict[str, Any], + ): + return await og_register_options( + **{ + **kwargs, # type: ignore + "relying_party_id": "testId.com", + "timeout": 10 * 1000, + "user_verification": "required", + "user_presence": False, + } + ) + + original_implementation.register_options = register_options # type: ignore + return original_implementation + + return register_options_override_1 + + if ( + "t=>({...t,registerOptions:async function(e){return t.registerOptions({...e,timeout:50})}})" + in eval_str + ): + + def register_options_override_2( + original_implementation: WebauthnRecipeImplementation, + ) -> WebauthnRecipeImplementation: + og_register_options = original_implementation.register_options + + async def register_options( + **kwargs: Dict[str, Any], + ): + return await og_register_options( + **{ + **kwargs, # type: ignore + "timeout": 50, + } + ) + + original_implementation.register_options = register_options # type: ignore + return original_implementation + + return register_options_override_2 + + if ( + "t=>({...t,registerOptions:async function(e){return t.registerOptions({...e,timeout:500})}})" + in eval_str + ): + + def register_options_override_3( + original_implementation: WebauthnRecipeImplementation, + ) -> WebauthnRecipeImplementation: + og_register_options = original_implementation.register_options + + async def register_options( + **kwargs: Dict[str, Any], + ): + return await og_register_options( + **{ + **kwargs, # type: ignore + "timeout": 500, + } + ) + + original_implementation.register_options = register_options # type: ignore + return original_implementation + + return register_options_override_3 + + if ( + "n=>({...n,signInOptions:async function(i){return n.signInOptions({...i,timeout:500})}})" + in eval_str + ): + + def sign_in_options_override_1( + original_implementation: WebauthnRecipeImplementation, + ) -> WebauthnRecipeImplementation: + og_sign_in_options = original_implementation.sign_in_options + + async def sign_in_options( + **kwargs: Dict[str, Any], + ): + return await og_sign_in_options( + **{ + **kwargs, # type: ignore + "timeout": 500, + } + ) + + original_implementation.sign_in_options = sign_in_options # type: ignore + return original_implementation + + return sign_in_options_override_1 + + # if eval_str.startswith("webauthn.init.override.apis"): + # pass + raise Exception("Unknown eval string: " + eval_str) diff --git a/tests/test-server/webauthn.py b/tests/test-server/webauthn.py new file mode 100644 index 000000000..f46919975 --- /dev/null +++ b/tests/test-server/webauthn.py @@ -0,0 +1,256 @@ +from typing import cast + +from flask import Flask, jsonify, request +from pydantic.alias_generators import to_snake +from session import convert_session_to_container +from supertokens_python.recipe.webauthn import ( + consume_recover_account_token, + create_recover_account_link, + generate_recover_account_token, + get_credential, + get_generated_options, + get_user_from_recover_account_token, + list_credentials, + recover_account, + register_credential, + register_options, + remove_credential, + remove_generated_options, + send_email, + send_recover_account_email, + sign_in, + sign_in_options, + sign_up, + verify_credentials, +) +from supertokens_python.recipe.webauthn.interfaces.api import ( + TypeWebauthnRecoverAccountEmailDeliveryInput, + WebauthnRecoverAccountEmailDeliveryUser, +) +from supertokens_python.recipe.webauthn.interfaces.recipe import ( + AuthenticationPayload, + AuthenticatorAssertionResponseJSON, + AuthenticatorAttestationResponseJSON, + RegistrationPayload, +) +from supertokens_python.types.response import StatusResponseBaseModel + + +def add_webauthn_routes(app: Flask): + @app.route("/test/webauthn/registeroptions", methods=["POST"]) + def webauthn_register_options(): # type: ignore + assert request.json is not None + response = register_options.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/signinoptions", methods=["POST"]) + def webauthn_sign_in_options(): # type: ignore + assert request.json is not None + response = sign_in_options.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/getgeneratedoptions", methods=["POST"]) + def webauthn_get_generated_options(): # type: ignore + assert request.json is not None + response = get_generated_options.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/signup", methods=["POST"]) + def webauthn_signup(): # type: ignore + assert request.json is not None + session = None + if "session" in request.json: + session = convert_session_to_container(request.json) + + response = sign_up.sync( + **{ + **{to_snake(k): v for k, v in request.json.items()}, + # Create model without validation so that we can test edge cases + "credential": RegistrationPayload.model_construct( + **{ + k: v + for k, v in request.json["credential"].items() + if k != "response" + }, + response=AuthenticatorAttestationResponseJSON.model_construct( + **request.json["credential"]["response"], + ), + ), + "session": session, + } # type: ignore + ) + + return jsonify(response.to_json()) + + @app.route("/test/webauthn/signin", methods=["POST"]) + def webauthn_signin(): # type: ignore + assert request.json is not None + session = None + if "session" in request.json: + session = convert_session_to_container(request.json) + response = sign_in.sync( + **{ + **{to_snake(k): v for k, v in request.json.items()}, + # Create model without validation so that we can test edge cases + "credential": AuthenticationPayload.model_construct( + **{ + k: v + for k, v in request.json["credential"].items() + if k != "response" + }, + response=AuthenticatorAssertionResponseJSON.model_construct( + **request.json["credential"]["response"], + ), + ), + "session": session, + } # type: ignore + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/verifycredentials", methods=["POST"]) + def webauthn_verify_credentials(): # type: ignore + assert request.json is not None + response = cast( + StatusResponseBaseModel[str], + verify_credentials.sync( + { + **{to_snake(k): v for k, v in request.json.items()}, + # Create model without validation so that we can test edge cases + "credential": AuthenticationPayload.model_construct( + **{ + k: v + for k, v in request.json["credential"].items() + if k != "response" + }, + response=AuthenticatorAssertionResponseJSON.model_construct( + **request.json["credential"]["response"], + ), + ), + } # type: ignore + ), + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/generaterecoveraccounttoken", methods=["POST"]) + def webauthn_generate_recover_account_token(): # type: ignore + assert request.json is not None + response = generate_recover_account_token.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/recoveraccount", methods=["POST"]) + def webauthn_recover_account(): # type: ignore + assert request.json is not None + response = recover_account.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/consumerecoveraccounttoken", methods=["POST"]) + def webauthn_consume_recover_account_token(): # type: ignore + assert request.json is not None + response = consume_recover_account_token.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/registercredential", methods=["POST"]) + def webauthn_register_credential(): # type: ignore + assert request.json is not None + response = register_credential.sync( + **{ + **{to_snake(k): v for k, v in request.json.items()}, + # Create model without validation so that we can test edge cases + "credential": RegistrationPayload.model_construct( + **{ + k: v + for k, v in request.json["credential"].items() + if k != "response" + }, + response=AuthenticatorAttestationResponseJSON.model_construct( + **request.json["credential"]["response"], + ), + ), + } # type: ignore + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/createrecoveraccountlink", methods=["POST"]) + def webauthn_create_recover_account_link(): # type: ignore + assert request.json is not None + response = create_recover_account_link.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/sendrecoveraccountemail", methods=["POST"]) + def webauthn_send_recover_account_email(): # type: ignore + assert request.json is not None + response = send_recover_account_email.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/sendemail", methods=["POST"]) + def webauthn_send_email(): # type: ignore + assert request.json is not None + response = send_email.sync( + template_vars=TypeWebauthnRecoverAccountEmailDeliveryInput( + user=WebauthnRecoverAccountEmailDeliveryUser( + id=request.json["user"]["id"], + recipe_user_id=request.json["user"]["recipeUserId"], + email=request.json["user"]["email"], + ), + recover_account_link=request.json["recoverAccountLink"], + tenant_id=request.json["tenantId"], + ), + user_context=request.json.get("userContext"), + ) + return jsonify(response) + + @app.route("/test/webauthn/getuserfromrecoveraccounttoken", methods=["POST"]) + def webauthn_get_user_from_recover_account_token(): # type: ignore + assert request.json is not None + response = get_user_from_recover_account_token.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/removegeneratedoptions", methods=["POST"]) + def webauthn_remove_generated_options(): # type: ignore + assert request.json is not None + response = remove_generated_options.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/removecredential", methods=["POST"]) + def webauthn_remove_credential(): # type: ignore + assert request.json is not None + response = remove_credential.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/getcredential", methods=["POST"]) + def webauthn_get_credential(): # type: ignore + assert request.json is not None + response = get_credential.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) + + @app.route("/test/webauthn/listcredentials", methods=["POST"]) + def webauthn_list_credentials(): # type: ignore + assert request.json is not None + response = list_credentials.sync( + **{to_snake(k): v for k, v in request.json.items()} + ) + return jsonify(response.to_json()) diff --git a/tests/test_passwordless.py b/tests/test_passwordless.py index bc86ddcb4..8b10b28f5 100644 --- a/tests/test_passwordless.py +++ b/tests/test_passwordless.py @@ -30,7 +30,8 @@ from supertokens_python.recipe.passwordless.interfaces import ( UpdateUserOkResult, ) -from supertokens_python.types import AccountInfo, RecipeUserId +from supertokens_python.types import RecipeUserId +from supertokens_python.types.base import AccountInfoInput from supertokens_python.utils import is_version_gte from tests.testclient import TestClientWithNoCookieJar as TestClient @@ -122,6 +123,9 @@ async def send_sms( "verified": True, } ], + "webauthn": { + "credentialIds": [], + }, }, } @@ -230,7 +234,7 @@ async def send_sms( assert isinstance(response, UpdateUserOkResult) user = await list_users_by_account_info( - "public", AccountInfo(phone_number="+919494949494") + "public", AccountInfoInput(phone_number="+919494949494") ) assert len(user) == 0 @@ -314,7 +318,7 @@ async def send_sms( assert isinstance(response, UpdateUserOkResult) user = await list_users_by_account_info( - "public", AccountInfo(email="hello@example.com") + "public", AccountInfoInput(email="hello@example.com") ) assert len(user) == 0 diff --git a/tests/thirdparty/test_multitenancy.py b/tests/thirdparty/test_multitenancy.py index 0980a7d9e..2654c2be2 100644 --- a/tests/thirdparty/test_multitenancy.py +++ b/tests/thirdparty/test_multitenancy.py @@ -30,7 +30,7 @@ ManuallyCreateOrUpdateUserOkResult, ) from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo -from supertokens_python.types import AccountInfo +from supertokens_python.types.base import AccountInfoInput from tests.utils import ( get_new_core_app_url, @@ -108,13 +108,13 @@ async def test_thirtyparty_multitenancy_functions(): # get user by email: by_email_user1 = await list_users_by_account_info( - "t1", AccountInfo(email="test@example.com") + "t1", AccountInfoInput(email="test@example.com") ) by_email_user2 = await list_users_by_account_info( - "t2", AccountInfo(email="test@example.com") + "t2", AccountInfoInput(email="test@example.com") ) by_email_user3 = await list_users_by_account_info( - "t3", AccountInfo(email="test@example.com") + "t3", AccountInfoInput(email="test@example.com") ) assert by_email_user1 == [user1a.user, user1b.user] @@ -124,7 +124,7 @@ async def test_thirtyparty_multitenancy_functions(): # get user by thirdparty id: g_user_by_tpid1a = await list_users_by_account_info( "t1", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="google", third_party_user_id="googleid1" ) @@ -132,7 +132,7 @@ async def test_thirtyparty_multitenancy_functions(): ) g_user_by_tpid1b = await list_users_by_account_info( "t1", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="facebook", third_party_user_id="fbid1" ) @@ -140,7 +140,7 @@ async def test_thirtyparty_multitenancy_functions(): ) g_user_by_tpid2a = await list_users_by_account_info( "t2", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="google", third_party_user_id="googleid1" ) @@ -148,7 +148,7 @@ async def test_thirtyparty_multitenancy_functions(): ) g_user_by_tpid2b = await list_users_by_account_info( "t2", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="facebook", third_party_user_id="fbid1" ) @@ -156,7 +156,7 @@ async def test_thirtyparty_multitenancy_functions(): ) g_user_by_tpid3a = await list_users_by_account_info( "t3", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="google", third_party_user_id="googleid1" ) @@ -164,7 +164,7 @@ async def test_thirtyparty_multitenancy_functions(): ) g_user_by_tpid3b = await list_users_by_account_info( "t3", - AccountInfo( + AccountInfoInput( third_party=ThirdPartyInfo( third_party_id="facebook", third_party_user_id="fbid1" ) diff --git a/tests/useridmapping/recipe_tests.py b/tests/useridmapping/recipe_tests.py index 993ef1694..d56042366 100644 --- a/tests/useridmapping/recipe_tests.py +++ b/tests/useridmapping/recipe_tests.py @@ -26,7 +26,8 @@ SignUpOkResult, UpdateEmailOrPasswordOkResult, ) -from supertokens_python.types import AccountInfo, RecipeUserId +from supertokens_python.types import RecipeUserId +from supertokens_python.types.base import AccountInfoInput from supertokens_python.utils import is_version_gte from typing_extensions import Literal @@ -57,7 +58,7 @@ async def ep_get_existing_user_id(user_id: str) -> str: async def ep_get_existing_user_by_email(email: str) -> str: from supertokens_python.asyncio import list_users_by_account_info - res = await list_users_by_account_info("public", AccountInfo(email=email)) + res = await list_users_by_account_info("public", AccountInfoInput(email=email)) assert len(res) == 1 return res[0].id diff --git a/tests/utils.py b/tests/utils.py index 4e553e8cb..8b90f325a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -52,6 +52,7 @@ from supertokens_python.recipe.totp.recipe import TOTPRecipe from supertokens_python.recipe.usermetadata import UserMetadataRecipe from supertokens_python.recipe.userroles import UserRolesRecipe +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe from supertokens_python.utils import is_version_gte API_VERSION_TEST_NON_SUPPORTED_SV = ["0.0", "1.0", "1.1", "2.1"] @@ -137,6 +138,7 @@ def reset(): TOTPRecipe.reset() OpenIdRecipe.reset() OAuth2ProviderRecipe.reset() + WebauthnRecipe.reset() def get_cookie_from_response( diff --git a/tests/webauthn/test_config.py b/tests/webauthn/test_config.py new file mode 100644 index 000000000..bcc0bab00 --- /dev/null +++ b/tests/webauthn/test_config.py @@ -0,0 +1,194 @@ +from typing import Any, Optional + +from pytest import fixture, mark +from supertokens_python import InputAppInfo, SupertokensConfig, init +from supertokens_python.recipe import webauthn +from supertokens_python.recipe.webauthn.recipe import WebauthnRecipe +from tests.utils import get_new_core_app_url, outputs, reset + + +@fixture(scope="function") +def default_webauthn_recipe(): + init( + supertokens_config=SupertokensConfig(get_new_core_app_url()), + app_info=InputAppInfo( + app_name="SuperTokens", + api_domain="api.supertokens.io", + website_domain="supertokens.io", + api_base_path="/auth", + ), + framework="fastapi", + recipe_list=[ + webauthn.init(), + ], + ) + + webauthn_recipe = WebauthnRecipe.get_instance() + assert webauthn_recipe.config is not None + + yield webauthn_recipe + + reset() + + +@mark.asyncio +async def test_default_config_get_origin(default_webauthn_recipe: WebauthnRecipe): + webauthn_recipe = default_webauthn_recipe + + origin = await webauthn_recipe.config.get_origin( + tenant_id="public", request=None, user_context={} + ) + assert origin == "https://supertokens.io" + + +@mark.asyncio +async def test_default_config_get_relying_party_id( + default_webauthn_recipe: WebauthnRecipe, +): + webauthn_recipe = default_webauthn_recipe + + relying_party_id = await webauthn_recipe.config.get_relying_party_id( + tenant_id="public", request=None, user_context={} + ) + assert relying_party_id == "api.supertokens.io" + + +@mark.asyncio +async def test_default_config_get_relying_party_name( + default_webauthn_recipe: WebauthnRecipe, +): + webauthn_recipe = default_webauthn_recipe + + relying_party_name = await webauthn_recipe.config.get_relying_party_name( + tenant_id="public", request=None, user_context={} + ) + assert relying_party_name == "SuperTokens" + + +@mark.asyncio +@mark.parametrize( + ("email", "expectation"), + [ + ("aaaaa", outputs("Email is not valid")), + ("aaaaa@aaaaa", outputs("Email is not valid")), + ("random User @randomMail.com", outputs("Email is not valid")), + ("*@*", outputs("Email is not valid")), + ("validemail@gmail.com", outputs(None)), + ], +) +async def test_default_config_validate_email( + default_webauthn_recipe: WebauthnRecipe, email: str, expectation: Any +): + webauthn_recipe = default_webauthn_recipe + + with expectation as output: + assert ( + await webauthn_recipe.config.validate_email_address( + email=email, tenant_id="public", user_context={} + ) + == output + ) + + +@fixture(scope="function") +def custom_webauthn_recipe(): + async def get_origin(**kwargs: Any) -> str: + return "testOrigin" + + async def get_relying_party_id(**kwargs: Any) -> str: + return "testId" + + async def get_relying_party_name(**kwargs: Any) -> str: + return "testName" + + async def validate_email_address(**kwargs: Any) -> Optional[str]: + print("validate_email_address", f"{kwargs=}") + + if kwargs["email"] == "test": + return "valid" + + return "invalid" + + init( + supertokens_config=SupertokensConfig(get_new_core_app_url()), + app_info=InputAppInfo( + app_name="SuperTokens", + api_domain="api.supertokens.io", + website_domain="supertokens.io", + api_base_path="/auth", + ), + framework="fastapi", + recipe_list=[ + webauthn.init( + config=webauthn.WebauthnConfig( + get_origin=get_origin, + get_relying_party_id=get_relying_party_id, + get_relying_party_name=get_relying_party_name, + validate_email_address=validate_email_address, + ), + ), + ], + ) + + webauthn_recipe = WebauthnRecipe.get_instance() + assert webauthn_recipe.config is not None + + yield webauthn_recipe + + reset() + + +@mark.asyncio +async def test_custom_config_get_origin(custom_webauthn_recipe: WebauthnRecipe): + webauthn_recipe = custom_webauthn_recipe + + origin = await webauthn_recipe.config.get_origin( + tenant_id="public", request=None, user_context={} + ) + assert origin == "testOrigin" + + +@mark.asyncio +async def test_custom_config_get_relying_party_id( + custom_webauthn_recipe: WebauthnRecipe, +): + webauthn_recipe = custom_webauthn_recipe + + relying_party_id = await webauthn_recipe.config.get_relying_party_id( + tenant_id="public", request=None, user_context={} + ) + assert relying_party_id == "testId" + + +@mark.asyncio +async def test_custom_config_get_relying_party_name( + custom_webauthn_recipe: WebauthnRecipe, +): + webauthn_recipe = custom_webauthn_recipe + + relying_party_name = await webauthn_recipe.config.get_relying_party_name( + tenant_id="public", request=None, user_context={} + ) + assert relying_party_name == "testName" + + +@mark.asyncio +@mark.parametrize( + ("email", "expectation"), + [ + ("test", outputs("valid")), + ("test!", outputs("invalid")), + ], +) +async def test_custom_config_validate_email( + custom_webauthn_recipe: WebauthnRecipe, email: str, expectation: Any +): + webauthn_recipe = custom_webauthn_recipe + + with expectation as output: + assert ( + await webauthn_recipe.config.validate_email_address( + email=email, tenant_id="public", user_context={} + ) + == output + )