Skip to content

feat: webauthn #583

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 51 commits into
base: 0.30
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1c46102
feat: port config types, setup skeleton
namsnath Apr 23, 2025
1dc46f4
update: uses generic type for overrides
namsnath Apr 23, 2025
75dd645
feat: adds register_options types, uses dataclasses
namsnath Apr 23, 2025
fd66bd3
fix: cyclic import
namsnath Apr 24, 2025
d1b5ac2
refactor: use dataclasses
namsnath Apr 24, 2025
9d205c3
feat: adds remaining `RecipeInterface` methods/types
namsnath Apr 25, 2025
7b633bb
feat: adds `ApiInterface` methods/types
namsnath Apr 25, 2025
c7902ca
fix: inconsistencies
namsnath Apr 26, 2025
e0ecd16
feat: adds recipe implementation
namsnath Apr 30, 2025
3751c41
update: `LinkingToSessionUserFailedError` types
namsnath Apr 30, 2025
a786a19
update: fixes types for kwargs
namsnath May 1, 2025
5a86342
update: rename base dataclass, fix types
namsnath May 1, 2025
ffe3a71
refactor: Switch to Pydantic models instead of dataclass
namsnath May 2, 2025
2b74292
feat: implements `sign_in_post`
namsnath May 5, 2025
e91df08
update: adds pydantic dependency
namsnath May 5, 2025
bea7436
feat: APIImplementation, EmailDelivery
namsnath May 6, 2025
409b0e0
feat: adds recipe and api functions
namsnath May 8, 2025
5e2a7ca
update: update auth-react django server
namsnath May 9, 2025
173ce7d
update: fix circular imports from auth_utils/webauthn
namsnath May 9, 2025
add6404
fix: return `WebauthnRecipe` instance from `init`
namsnath May 9, 2025
9a282e2
update: remove __future__ annotations from webauthn
namsnath May 9, 2025
b126c08
fix: cyclic imports
namsnath May 12, 2025
5ec1d3d
fix: input config type
namsnath May 12, 2025
cd80d8f
feat: adds syncify decorator
namsnath May 12, 2025
64532a6
feat: adds function wrappers
namsnath May 13, 2025
a0ae4e8
fix: user webauthn types, update querier cdi version
namsnath May 14, 2025
8ad9a1b
fix: various issues
namsnath May 15, 2025
1ec9e9b
update: creates models in api functions
namsnath May 15, 2025
aade01b
update: adds missing import
namsnath May 15, 2025
e2b91e5
update: defaults for options APIs
namsnath May 15, 2025
4c89dc8
feat: adds webauthn support to backend sdk testing server
namsnath May 15, 2025
5383fda
update: removes model validation from server endpoints
namsnath May 15, 2025
f6e5193
update: removes model validation from api endpoints
namsnath May 18, 2025
60a360e
update: remove dataclasses_json refs
namsnath May 19, 2025
61fe34c
update: return error if credential validation fails in API methods
namsnath May 20, 2025
3289e89
fix: null checks for email
namsnath May 20, 2025
eaaf5f2
fix: adds type ignores
namsnath May 20, 2025
24da5f7
fix: recover token flow not being able to find email
namsnath May 20, 2025
f66f5fb
feat: auth-react servers
namsnath May 20, 2025
080ad61
fix: backend-sdk mock parsing
namsnath May 21, 2025
6bb6927
fix: failing unit-test
namsnath May 21, 2025
5c98f01
update: add backend-sdk server logs
namsnath May 22, 2025
d6590e3
fix: failing unit test
namsnath May 22, 2025
74da8c5
update: add defaults for webauthn in `User`
namsnath May 22, 2025
468d4f5
refactor: review comments
namsnath May 26, 2025
ad03588
update: match get_origin types with AppInfo implementation
namsnath May 27, 2025
36e4478
feat: adds UTs for Webauthn config
namsnath May 27, 2025
3186b11
update: changelog
namsnath May 28, 2025
1841adb
fix: add missing test function
namsnath May 28, 2025
9e6abae
fix: add steps to lint-code workflow
namsnath May 28, 2025
23e4729
fix: types
namsnath May 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/backend-sdk-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/lint-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion coreDriverInterfaceSupported.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"_comment": "contains a list of core-driver interfaces branch names that this core supports",
"versions": [
"5.2"
"5.3"
]
}
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
3 changes: 2 additions & 1 deletion frontendDriverInterfaceSupported.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"2.0",
"3.0",
"3.1",
"4.0"
"4.0",
"4.1"
]
}
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

setup(
name="supertokens_python",
version="0.29.2",
version="0.30.0",
author="SuperTokens",
license="Apache 2.0",
author_email="[email protected]",
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 35 additions & 1 deletion supertokens_python/async_to_sync_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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))
5 changes: 3 additions & 2 deletions supertokens_python/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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]:
Expand Down
38 changes: 12 additions & 26 deletions supertokens_python/auth_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions supertokens_python/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions supertokens_python/recipe/accountlinking/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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]:
Expand Down
34 changes: 29 additions & 5 deletions supertokens_python/recipe/accountlinking/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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,
Expand Down
Loading