From 92e4fba4e78bfa0bcbe8cd733eb5db92e488e703 Mon Sep 17 00:00:00 2001 From: dorsha Date: Sun, 28 Jun 2026 09:14:50 +0300 Subject: [PATCH 1/3] feat(webauthn): add mfa option to passkey enrollment (update) Add an optional login_options to webauthn update_start (sync + async) so enrolling a passkey for an already-authenticated user can return a single session whose amr merges the previously-passed factors with the new passkey, instead of a separate sign-in. update_finish now returns that JWT response (nested under "jwt") when present, else None. Pairs with descope/backend webauthn add-device mfa support (descope/etc#16573). Co-Authored-By: Claude Opus 4.8 (1M context) --- descope/authmethod/_webauthn_base.py | 6 +++- descope/authmethod/webauthn.py | 38 +++++++++++++++++++---- descope/authmethod/webauthn_async.py | 38 +++++++++++++++++++---- tests/test_webauthn.py | 46 ++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 13 deletions(-) diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py index da1ed4435..1be114c16 100644 --- a/descope/authmethod/_webauthn_base.py +++ b/descope/authmethod/_webauthn_base.py @@ -64,10 +64,14 @@ def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict: return {"transactionId": transaction_id, "response": response} @staticmethod - def _compose_update_start_body(login_id: str, origin: str) -> dict: + def _compose_update_start_body( + login_id: str, origin: str, login_options: Optional[LoginOptions] = None + ) -> dict: body: dict = {"loginId": login_id} if origin: body["origin"] = origin + if login_options: + body["loginOptions"] = login_options.__dict__ return body @staticmethod diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index bcbba79c3..aa19caa9a 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -108,25 +108,51 @@ def sign_up_or_in_start( response = self._http.post(uri, body=body) return response.json() - def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: + def update_start( + self, + login_id: str, + refresh_token: str, + origin: str, + login_options: Optional[LoginOptions] = None, + ) -> dict: """ - Docs + Start adding a new WebAuthn authenticator (passkey) to an existing user. + + Pass a login_options with mfa (or stepup) set so that update_finish returns a single + session whose amr merges the user's previously-passed factors with the new passkey, + instead of having to run a separate sign-in afterwards. """ self._validate_login_id(login_id) self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin) + body = self._compose_update_start_body(login_id, origin, login_options) response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() - def update_finish(self, transaction_id: str, response: str) -> None: + def update_finish( + self, + transaction_id: str, + response: str, + audience: Union[str, None, Iterable[str]] = None, + ) -> Optional[dict]: """ - Docs + Complete adding a new WebAuthn authenticator (passkey) to an existing user. + + When the matching update_start opted into mfa/stepup, returns a JWT response whose amr + merges the prior factors with the new passkey; otherwise returns None (the credential is + enrolled but no new session is minted). """ self._validate_transaction_id(transaction_id) self._validate_response(response) uri = EndpointsV1.update_auth_webauthn_finish_path body = self._compose_update_finish_body(transaction_id, response) - self._http.post(uri, body=body) + http_response = self._http.post(uri, body=body) + resp = http_response.json() + jwt = resp.get("jwt") if isinstance(resp, dict) else None + if not jwt: + return None + return self._auth.generate_jwt_response( + jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience + ) diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py index dc3f04a64..55d9108c1 100644 --- a/descope/authmethod/webauthn_async.py +++ b/descope/authmethod/webauthn_async.py @@ -103,21 +103,47 @@ async def sign_up_or_in_start( response = await self._http.post(uri, body=body) return response.json() - async def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: - """Start adding a new WebAuthn authenticator to an existing user.""" + async def update_start( + self, + login_id: str, + refresh_token: str, + origin: str, + login_options: Optional[LoginOptions] = None, + ) -> dict: + """Start adding a new WebAuthn authenticator (passkey) to an existing user. + + Pass a login_options with mfa (or stepup) set so that update_finish returns a single + session whose amr merges the user's previously-passed factors with the new passkey. + """ self._validate_login_id(login_id) self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin) + body = self._compose_update_start_body(login_id, origin, login_options) response = await self._http.post(uri, body=body, pswd=refresh_token) return response.json() - async def update_finish(self, transaction_id: str, response: str) -> None: - """Complete adding a new WebAuthn authenticator to an existing user.""" + async def update_finish( + self, + transaction_id: str, + response: str, + audience: Union[str, None, Iterable[str]] = None, + ) -> Optional[dict]: + """Complete adding a new WebAuthn authenticator (passkey) to an existing user. + + When the matching update_start opted into mfa/stepup, returns a JWT response whose amr + merges the prior factors with the new passkey; otherwise returns None. + """ self._validate_transaction_id(transaction_id) self._validate_response(response) uri = EndpointsV1.update_auth_webauthn_finish_path body = self._compose_update_finish_body(transaction_id, response) - await self._http.post(uri, body=body) + http_response = await self._http.post(uri, body=body) + resp = http_response.json() + jwt = resp.get("jwt") if isinstance(resp, dict) else None + if not jwt: + return None + return await self._auth.prepare_jwt_response( + jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience + ) diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 4fc19b84b..549bbb6a7 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -48,6 +48,14 @@ def test_compose_update_start_body(self): "loginId": "dummy@dummy.com", "origin": "https://example.com", } + # login options (mfa) are included only when provided + assert WebAuthn._compose_update_start_body( + "dummy@dummy.com", "https://example.com", LoginOptions(mfa=True) + ) == { + "loginId": "dummy@dummy.com", + "origin": "https://example.com", + "loginOptions": LoginOptions(mfa=True).__dict__, + } async def test_sign_up_start(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) @@ -323,3 +331,41 @@ async def test_update_finish(self, client_factory): json={"transactionId": "t01", "response": "resp01"}, follow_redirects=False, ) + + async def test_update_start_with_mfa(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: + await client.invoke( + client.webauthn.update_start( + "id1", refresh_token, "https://example.com", LoginOptions(mfa=True) + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "id1", + "origin": "https://example.com", + "loginOptions": LoginOptions(mfa=True).__dict__, + }, + follow_redirects=False, + ) + + async def test_update_finish_with_mfa(self, client_factory): + # mfa enrollment: finish returns the merged-amr session nested under "jwt" + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + success_resp = make_response( + {"jwt": {"sessionJwt": VALID_SESSION_TOKEN}}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp): + result = await client.invoke(client.webauthn.update_finish("t01", "resp01")) + assert result is not None From 0da8178be28cdf7c52621c9a367627736467774e Mon Sep 17 00:00:00 2001 From: dorsha Date: Sun, 28 Jun 2026 09:52:12 +0300 Subject: [PATCH 2/3] style(webauthn): apply ruff format to mfa changes Fixes the Lint & Type Check (ruff format --check) CI failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- descope/authmethod/_webauthn_base.py | 4 +--- tests/test_webauthn.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py index 1be114c16..9c0f7a70a 100644 --- a/descope/authmethod/_webauthn_base.py +++ b/descope/authmethod/_webauthn_base.py @@ -64,9 +64,7 @@ def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict: return {"transactionId": transaction_id, "response": response} @staticmethod - def _compose_update_start_body( - login_id: str, origin: str, login_options: Optional[LoginOptions] = None - ) -> dict: + def _compose_update_start_body(login_id: str, origin: str, login_options: Optional[LoginOptions] = None) -> dict: body: dict = {"loginId": login_id} if origin: body["origin"] = origin diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 549bbb6a7..428d03f64 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -337,9 +337,7 @@ async def test_update_start_with_mfa(self, client_factory): refresh_token = VALID_REFRESH_TOKEN with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: await client.invoke( - client.webauthn.update_start( - "id1", refresh_token, "https://example.com", LoginOptions(mfa=True) - ) + client.webauthn.update_start("id1", refresh_token, "https://example.com", LoginOptions(mfa=True)) ) assert_http_called( mock_post, From afd945fb0554fef24674b9f448562289c57d1fda Mon Sep 17 00:00:00 2001 From: dorsha Date: Sun, 28 Jun 2026 10:36:02 +0300 Subject: [PATCH 3/3] refactor(webauthn): use mfa flag on update_start, not login_options Match the OTP update phone/email API: webauthn update_start (sync + async) takes an optional mfa bool (sent as the 'mfa' request field) instead of a LoginOptions, mirroring the backend change. Co-Authored-By: Claude Opus 4.8 (1M context) --- descope/authmethod/_webauthn_base.py | 6 +++--- descope/authmethod/webauthn.py | 10 +++++----- descope/authmethod/webauthn_async.py | 8 ++++---- tests/test_webauthn.py | 14 +++++--------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py index 9c0f7a70a..fec8f120d 100644 --- a/descope/authmethod/_webauthn_base.py +++ b/descope/authmethod/_webauthn_base.py @@ -64,12 +64,12 @@ def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict: return {"transactionId": transaction_id, "response": response} @staticmethod - def _compose_update_start_body(login_id: str, origin: str, login_options: Optional[LoginOptions] = None) -> dict: + def _compose_update_start_body(login_id: str, origin: str, mfa: bool = False) -> dict: body: dict = {"loginId": login_id} if origin: body["origin"] = origin - if login_options: - body["loginOptions"] = login_options.__dict__ + if mfa: + body["mfa"] = mfa return body @staticmethod diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index aa19caa9a..4a4eb3948 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -113,20 +113,20 @@ def update_start( login_id: str, refresh_token: str, origin: str, - login_options: Optional[LoginOptions] = None, + mfa: bool = False, ) -> dict: """ Start adding a new WebAuthn authenticator (passkey) to an existing user. - Pass a login_options with mfa (or stepup) set so that update_finish returns a single - session whose amr merges the user's previously-passed factors with the new passkey, - instead of having to run a separate sign-in afterwards. + Pass mfa=True so that update_finish returns a single session whose amr merges the user's + previously-passed factors with the new passkey, instead of having to run a separate + sign-in afterwards. """ self._validate_login_id(login_id) self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin, login_options) + body = self._compose_update_start_body(login_id, origin, mfa) response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py index 55d9108c1..58624cf8c 100644 --- a/descope/authmethod/webauthn_async.py +++ b/descope/authmethod/webauthn_async.py @@ -108,18 +108,18 @@ async def update_start( login_id: str, refresh_token: str, origin: str, - login_options: Optional[LoginOptions] = None, + mfa: bool = False, ) -> dict: """Start adding a new WebAuthn authenticator (passkey) to an existing user. - Pass a login_options with mfa (or stepup) set so that update_finish returns a single - session whose amr merges the user's previously-passed factors with the new passkey. + Pass mfa=True so that update_finish returns a single session whose amr merges the user's + previously-passed factors with the new passkey. """ self._validate_login_id(login_id) self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin, login_options) + body = self._compose_update_start_body(login_id, origin, mfa) response = await self._http.post(uri, body=body, pswd=refresh_token) return response.json() diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 428d03f64..8c6bb523d 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -48,13 +48,11 @@ def test_compose_update_start_body(self): "loginId": "dummy@dummy.com", "origin": "https://example.com", } - # login options (mfa) are included only when provided - assert WebAuthn._compose_update_start_body( - "dummy@dummy.com", "https://example.com", LoginOptions(mfa=True) - ) == { + # mfa is included only when set + assert WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com", mfa=True) == { "loginId": "dummy@dummy.com", "origin": "https://example.com", - "loginOptions": LoginOptions(mfa=True).__dict__, + "mfa": True, } async def test_sign_up_start(self, client_factory): @@ -336,9 +334,7 @@ async def test_update_start_with_mfa(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) refresh_token = VALID_REFRESH_TOKEN with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: - await client.invoke( - client.webauthn.update_start("id1", refresh_token, "https://example.com", LoginOptions(mfa=True)) - ) + await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com", mfa=True)) assert_http_called( mock_post, client.mode, @@ -352,7 +348,7 @@ async def test_update_start_with_mfa(self, client_factory): json={ "loginId": "id1", "origin": "https://example.com", - "loginOptions": LoginOptions(mfa=True).__dict__, + "mfa": True, }, follow_redirects=False, )