Skip to content

Commit 989155e

Browse files
authored
feat: webauthn (#583)
### 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`
1 parent 1d431dc commit 989155e

File tree

130 files changed

+7913
-265
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+7913
-265
lines changed

.github/workflows/backend-sdk-testing.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ jobs:
8282
run: |
8383
source venv/bin/activate
8484
docker compose up --build --wait
85-
python3 tests/test-server/app.py &
85+
python3 tests/test-server/app.py &> python.log &
8686
8787
- uses: supertokens/backend-sdk-testing-action@main
8888
with:
8989
version: ${{ matrix.fdi-version }}
9090
check-name-suffix: '[CDI=${{ matrix.cdi-version }}][Core=${{ steps.versions.outputs.coreVersionXy }}][FDI=${{ matrix.fdi-version }}][Py=${{ matrix.py-version }}][Node=${{ matrix.node-version }}]'
9191
path: backend-sdk-testing
92+
app-server-logs: ${{ github.workspace }}/supertokens-python/python.log

.github/workflows/lint-code.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ jobs:
1919
outputs:
2020
pyVersions: '["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]'
2121

22+
steps:
23+
# Required to avoid errors in runs due to no steps
24+
- name: Placeholder
25+
run: echo "Placeholder"
26+
2227
lint-format:
2328
name: Check linting and formatting
2429
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## [unreleased]
1010

11+
## [0.30.0] - 2025-05-27
12+
### Adds Webauthn (Passkeys) support
13+
- Adds Webauthn recipe with support for:
14+
- Registration, sign-in, and credential verification flows
15+
- Account recovery
16+
- Adds new API endpoints for WebAuthn operations:
17+
- GET `/api/webauthn/email/exists` - Check if email exists in system
18+
- POST `/api/webauthn/options/register` - Handle registration options
19+
- POST `/api/webauthn/options/signin` - Handle sign-in options
20+
- POST `/api/webauthn/signin` - Handle WebAuthn sign-in
21+
- POST `/api/webauthn/signup` - Handle WebAuthn sign-up
22+
- POST `/api/user/webauthn/reset` - Handle account recovery
23+
- POST `/api/user/webauthn/reset/token` - Generate recovery tokens
24+
- Adds WebAuthn support to account linking functionality:
25+
- Support for linking users based on WebAuthn `credential_id`
26+
- Updates `AccountInfo` type to `AccountInfoInput` with WebAuthn fields
27+
- Adds `has_same_webauthn_info_as` method for credential comparison
28+
- Adds FDI support for version `4.1`
29+
- Recipe functions are directly importable from the Webauthn recipe module
30+
- ```python
31+
from supertokens_python.recipe.webauthn import sign_in
32+
33+
await sign_in(...) # Async
34+
sign_in.sync(...) # Sync
35+
```
36+
37+
### Breaking Changes
38+
- Updates supported CDI version from `5.2` to `5.3`
39+
- Changes `AccountInfo` to `AccountInfoInput` in various methods
40+
- This is required to allow querying by a single Webauthn `credential_id`, while the Webauthn login method contains an array of `credential_ids`
41+
- Affected functions:
42+
- `supertokens_python.asyncio.list_users_by_account_info`
43+
- `supertokens_python.syncio.list_users_by_account_info`
44+
- `supertokens_python.recipe.accountlinking.interface.RecipeInterface.list_users_by_account_info`
45+
- `supertokens_python.recipe.accountlinking.recipe_implementation.RecipeImplementation.list_users_by_account_info`
46+
- `supertokens_python.recipe.passwordless.api.implementation.get_passwordless_user_by_account_info`
47+
- `supertokens_python.recipe.passwordless.api.implementation.get_passwordless_user_by_account_info`
48+
49+
1150
## [0.29.2] - 2025-05-19
1251
- Fixes cookies being set without expiry in Django
1352
- Reverts timezone change from 0.28.0 and uses GMT

coreDriverInterfaceSupported.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"_comment": "contains a list of core-driver interfaces branch names that this core supports",
33
"versions": [
4-
"5.2"
4+
"5.3"
55
]
66
}

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ pyyaml==6.0.2
2121
requests-mock==1.12.1
2222
respx>=0.13.0, <1.0.0
2323
uvicorn==0.32.0
24+
wasmtime==25.0.0
2425
-e .

frontendDriverInterfaceSupported.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"2.0",
88
"3.0",
99
"3.1",
10-
"4.0"
10+
"4.0",
11+
"4.1"
1112
]
1213
}

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282

8383
setup(
8484
name="supertokens_python",
85-
version="0.29.2",
85+
version="0.30.0",
8686
author="SuperTokens",
8787
license="Apache 2.0",
8888
author_email="[email protected]",
@@ -127,6 +127,7 @@
127127
"pkce<1.1.0",
128128
"pyotp<3",
129129
"python-dateutil<3",
130+
"pydantic>=2.10.6,<3.0.0",
130131
],
131132
python_requires=">=3.8",
132133
include_package_data=True,

supertokens_python/async_to_sync_wrapper.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,20 @@
1313
# under the License.
1414

1515
import asyncio
16+
from functools import update_wrapper
1617
from os import getenv
17-
from typing import Any, Coroutine, TypeVar
18+
from typing import (
19+
Any,
20+
Callable,
21+
Coroutine,
22+
Generic,
23+
TypeVar,
24+
)
25+
26+
from typing_extensions import ParamSpec
27+
28+
Param = ParamSpec("Param")
29+
RetType = TypeVar("RetType", covariant=True)
1830

1931
_T = TypeVar("_T")
2032

@@ -43,3 +55,25 @@ def create_or_get_event_loop() -> asyncio.AbstractEventLoop:
4355
def sync(co: Coroutine[Any, Any, _T]) -> _T:
4456
loop = create_or_get_event_loop()
4557
return loop.run_until_complete(co)
58+
59+
60+
class syncify(Generic[Param, RetType]):
61+
"""
62+
Decorator to allow async functions to be executed synchronously
63+
using a `sync` attribute.
64+
"""
65+
66+
def __init__(self, func: Callable[Param, Coroutine[Any, Any, RetType]]):
67+
update_wrapper(self, func)
68+
self.func = func
69+
70+
def __call__(
71+
self, *args: Param.args, **kwargs: Param.kwargs
72+
) -> Coroutine[Any, Any, RetType]:
73+
return self.func(*args, **kwargs)
74+
75+
def sync(self, *args: Param.args, **kwargs: Param.kwargs) -> RetType:
76+
"""
77+
Synchronous version of the decorated function.
78+
"""
79+
return sync(self.func(*args, **kwargs))

supertokens_python/asyncio/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
)
2727
from supertokens_python.recipe.accountlinking.interfaces import GetUsersResult
2828
from supertokens_python.recipe.accountlinking.recipe import AccountLinkingRecipe
29-
from supertokens_python.types import AccountInfo, User
29+
from supertokens_python.types import User
30+
from supertokens_python.types.base import AccountInfoInput
3031

3132

3233
async def get_users_oldest_first(
@@ -159,7 +160,7 @@ async def update_or_delete_user_id_mapping_info(
159160

160161
async def list_users_by_account_info(
161162
tenant_id: str,
162-
account_info: AccountInfo,
163+
account_info: AccountInfoInput,
163164
do_union_of_account_info: bool = False,
164165
user_context: Optional[Dict[str, Any]] = None,
165166
) -> List[User]:

supertokens_python/auth_utils.py

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
1+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
22

33
from typing_extensions import Literal
44

@@ -31,37 +31,18 @@
3131
from supertokens_python.recipe.session.interfaces import SessionContainer
3232
from supertokens_python.recipe.thirdparty.types import ThirdPartyInfo
3333
from supertokens_python.types import (
34-
AccountInfo,
3534
LoginMethod,
3635
RecipeUserId,
3736
User,
3837
)
38+
from supertokens_python.types.auth_utils import LinkingToSessionUserFailedError
39+
from supertokens_python.types.base import AccountInfoInput
3940
from supertokens_python.utils import log_debug_message
4041

4142
from .asyncio import get_user
4243

43-
44-
class LinkingToSessionUserFailedError:
45-
status: Literal["LINKING_TO_SESSION_USER_FAILED"] = "LINKING_TO_SESSION_USER_FAILED"
46-
reason: Literal[
47-
"EMAIL_VERIFICATION_REQUIRED",
48-
"RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
49-
"ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
50-
"SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
51-
"INPUT_USER_IS_NOT_A_PRIMARY_USER",
52-
]
53-
54-
def __init__(
55-
self,
56-
reason: Literal[
57-
"EMAIL_VERIFICATION_REQUIRED",
58-
"RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
59-
"ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
60-
"SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR",
61-
"INPUT_USER_IS_NOT_A_PRIMARY_USER",
62-
],
63-
):
64-
self.reason = reason
44+
if TYPE_CHECKING:
45+
from supertokens_python.recipe.webauthn.types.base import WebauthnInfoInput
6546

6647

6748
class OkResponse:
@@ -290,6 +271,7 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required(
290271
session: Optional[SessionContainer],
291272
check_credentials_on_tenant: Callable[[str], Awaitable[bool]],
292273
user_context: Dict[str, Any],
274+
webauthn: Optional["WebauthnInfoInput"] = None,
293275
) -> Optional[AuthenticatingUserInfo]:
294276
i = 0
295277
while i < 300:
@@ -303,8 +285,11 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required(
303285
)
304286
existing_users = await AccountLinkingRecipe.get_instance().recipe_implementation.list_users_by_account_info(
305287
tenant_id=tenant_id,
306-
account_info=AccountInfo(
307-
email=email, phone_number=phone_number, third_party=third_party
288+
account_info=AccountInfoInput(
289+
email=email,
290+
phone_number=phone_number,
291+
third_party=third_party,
292+
webauthn=webauthn,
308293
),
309294
do_union_of_account_info=True,
310295
user_context=user_context,
@@ -324,6 +309,7 @@ async def get_authenticating_user_and_add_to_current_tenant_if_required(
324309
(email is not None and lm.has_same_email_as(email))
325310
or lm.has_same_phone_number_as(phone_number)
326311
or lm.has_same_third_party_info_as(third_party)
312+
or lm.has_same_webauthn_info_as(webauthn)
327313
)
328314
),
329315
None,

supertokens_python/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515
from __future__ import annotations
1616

17-
SUPPORTED_CDI_VERSIONS = ["5.2"]
18-
VERSION = "0.29.2"
17+
SUPPORTED_CDI_VERSIONS = ["5.3"]
18+
VERSION = "0.30.0"
1919
TELEMETRY = "/telemetry"
2020
USER_COUNT = "/users/count"
2121
USER_DELETE = "/user/remove"

supertokens_python/recipe/accountlinking/interfaces.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818

1919
from typing_extensions import Literal
2020

21+
from supertokens_python.types.base import AccountInfoInput
22+
2123
if TYPE_CHECKING:
2224
from supertokens_python.types import (
23-
AccountInfo,
2425
RecipeUserId,
2526
User,
2627
)
@@ -104,7 +105,7 @@ async def get_user(
104105
async def list_users_by_account_info(
105106
self,
106107
tenant_id: str,
107-
account_info: AccountInfo,
108+
account_info: AccountInfoInput,
108109
do_union_of_account_info: bool,
109110
user_context: Dict[str, Any],
110111
) -> List[User]:

supertokens_python/recipe/accountlinking/recipe.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727
from supertokens_python.querier import Querier
2828
from supertokens_python.recipe_module import APIHandled, RecipeModule
2929
from supertokens_python.supertokens import Supertokens
30+
from supertokens_python.types.base import AccountInfoInput
3031

3132
from .interfaces import RecipeInterface
3233
from .recipe_implementation import RecipeImplementation
3334
from .types import (
34-
AccountInfo,
3535
AccountInfoWithRecipeId,
3636
AccountInfoWithRecipeIdAndUserId,
3737
InputOverrideConfig,
@@ -210,7 +210,15 @@ async def get_primary_user_that_can_be_linked_to_recipe_user_id(
210210
# Then, we try and find a primary user based on the email / phone number / third party ID.
211211
users = await self.recipe_implementation.list_users_by_account_info(
212212
tenant_id=tenant_id,
213-
account_info=user.login_methods[0],
213+
account_info=AccountInfoInput(
214+
email=user.login_methods[0].email,
215+
phone_number=user.login_methods[0].phone_number,
216+
third_party=user.login_methods[0].third_party,
217+
# We don't need to list by (webauthn) credentialId because we are looking for
218+
# a user to link to the current recipe user, but any search using the credentialId
219+
# of the current user "will identify the same user" which is the current one.
220+
webauthn=None,
221+
),
214222
do_union_of_account_info=True,
215223
user_context=user_context,
216224
)
@@ -266,7 +274,15 @@ async def get_oldest_user_that_can_be_linked_to_recipe_user(
266274
# Then, we try and find matching users based on the email / phone number / third party ID.
267275
users = await self.recipe_implementation.list_users_by_account_info(
268276
tenant_id=tenant_id,
269-
account_info=user.login_methods[0],
277+
account_info=AccountInfoInput(
278+
email=user.login_methods[0].email,
279+
phone_number=user.login_methods[0].phone_number,
280+
third_party=user.login_methods[0].third_party,
281+
# We don't need to list by (webauthn) credentialId because we are looking for
282+
# a user to link to the current recipe user, but any search using the credentialId
283+
# of the current user "will identify the same user" which is the current one.
284+
webauthn=None,
285+
),
270286
do_union_of_account_info=True,
271287
user_context=user_context,
272288
)
@@ -346,7 +362,15 @@ async def is_sign_in_up_allowed_helper(
346362

347363
users = await self.recipe_implementation.list_users_by_account_info(
348364
tenant_id=tenant_id,
349-
account_info=account_info,
365+
account_info=AccountInfoInput(
366+
email=account_info.email,
367+
phone_number=account_info.phone_number,
368+
third_party=account_info.third_party,
369+
# We don't need to list by (webauthn) credentialId because we are looking for
370+
# a user to link to the current recipe user, but any search using the credentialId
371+
# of the current user "will identify the same user" which is the current one.
372+
webauthn=None,
373+
),
350374
do_union_of_account_info=True,
351375
user_context=user_context,
352376
)
@@ -548,7 +572,7 @@ async def is_email_change_allowed(
548572
existing_users_with_new_email = (
549573
await self.recipe_implementation.list_users_by_account_info(
550574
tenant_id=tenant_id,
551-
account_info=AccountInfo(email=new_email),
575+
account_info=AccountInfoInput(email=new_email),
552576
do_union_of_account_info=False,
553577
user_context=user_context,
554578
)

supertokens_python/recipe/accountlinking/recipe_implementation.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from supertokens_python.normalised_url_path import NormalisedURLPath
2121
from supertokens_python.types import RecipeUserId, User
22+
from supertokens_python.types.base import AccountInfoInput
2223

2324
from .interfaces import (
2425
CanCreatePrimaryUserAccountInfoAlreadyAssociatedError,
@@ -39,7 +40,7 @@
3940
RecipeInterface,
4041
UnlinkAccountOkResult,
4142
)
42-
from .types import AccountInfo, AccountLinkingConfig, RecipeLevelUser
43+
from .types import AccountLinkingConfig, RecipeLevelUser
4344

4445
if TYPE_CHECKING:
4546
from supertokens_python.querier import Querier
@@ -327,7 +328,7 @@ async def get_user(
327328
async def list_users_by_account_info(
328329
self,
329330
tenant_id: str,
330-
account_info: AccountInfo,
331+
account_info: AccountInfoInput,
331332
do_union_of_account_info: bool,
332333
user_context: Dict[str, Any],
333334
) -> List[User]:
@@ -343,6 +344,9 @@ async def list_users_by_account_info(
343344
params["thirdPartyId"] = account_info.third_party.id
344345
params["thirdPartyUserId"] = account_info.third_party.user_id
345346

347+
if account_info.webauthn:
348+
params["webauthnCredentialId"] = account_info.webauthn.credential_id
349+
346350
response = await self.querier.send_get_request(
347351
NormalisedURLPath(f"/{tenant_id or 'public'}/users/by-accountinfo"),
348352
params,

0 commit comments

Comments
 (0)