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
+ )