From 92da2033b1bc86384498c0259c0261503d690017 Mon Sep 17 00:00:00 2001 From: Eliran Kononowicz Date: Tue, 25 Aug 2026 10:57:03 +0300 Subject: [PATCH 1/5] feat: add SMS delivery support to Enchanted Link Add sign_in_with_phone/sign_up_with_phone/sign_up_or_in_with_phone as new, additive methods on EnchantedLink/EnchantedLinkAsync that route to the new /sms backend endpoints. Existing sign_in/sign_up/sign_up_or_in keep their original signatures and email-only behavior unchanged. The private URL/body composers in _enchantedlink_base.py take a DeliveryMethod so both the email and phone paths share the same plumbing; adjust_and_verify_delivery_method (in _auth_base.py) already handled non-email methods generically, so no changes were needed there. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 2f2d2312e2b6e80fd841d0d0a7a353d62d4001c2) --- descope/authmethod/_enchantedlink_base.py | 15 ++-- descope/authmethod/enchantedlink.py | 60 ++++++++++++++-- descope/authmethod/enchantedlink_async.py | 63 +++++++++++++++-- tests/test_enchantedlink.py | 84 +++++++++++++++++++++-- 4 files changed, 203 insertions(+), 19 deletions(-) diff --git a/descope/authmethod/_enchantedlink_base.py b/descope/authmethod/_enchantedlink_base.py index a6325678f..178e8407a 100644 --- a/descope/authmethod/_enchantedlink_base.py +++ b/descope/authmethod/_enchantedlink_base.py @@ -33,16 +33,16 @@ def _validate_login_id(login_id: str) -> None: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") @staticmethod - def _compose_signin_url() -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) + def _compose_signin_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, method) @staticmethod - def _compose_signup_url() -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, DeliveryMethod.EMAIL) + def _compose_signup_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, method) @staticmethod - def _compose_sign_up_or_in_url() -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) + def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, method) @staticmethod def _compose_signin_body( @@ -58,6 +58,7 @@ def _compose_signin_body( @staticmethod def _compose_signup_body( + method: DeliveryMethod, login_id: str, uri: str, user: dict | None = None, @@ -70,7 +71,7 @@ def _compose_signup_body( if user is not None: body["user"] = user - method_str, val = Auth.get_login_id_by_method(DeliveryMethod.EMAIL, user) + method_str, val = Auth.get_login_id_by_method(method, user) body[method_str] = val return body diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 76aee4f63..7d4be9735 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -27,7 +27,23 @@ def sign_in( validate_refresh_token_provided(login_options, refresh_token) body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_signin_url() + url = self._compose_signin_url(DeliveryMethod.EMAIL) + response = self._http.post(url, body=body, pswd=refresh_token) + return response.json() + + def sign_in_with_phone( + self, + phone: str, + uri: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> dict: + self._validate_sign_in_login_id(phone) + + validate_refresh_token_provided(login_options, refresh_token) + + body = self._compose_signin_body(phone, uri, login_options) + url = self._compose_signin_url(DeliveryMethod.SMS) response = self._http.post(url, body=body, pswd=refresh_token) return response.json() @@ -48,8 +64,30 @@ def sign_up( f"Login ID {login_id} is not valid for email", ) - body = self._compose_signup_body(login_id, uri, user, signup_options) - url = self._compose_signup_url() + body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) + url = self._compose_signup_url(DeliveryMethod.EMAIL) + response = self._http.post(url, body=body) + return response.json() + + def sign_up_with_phone( + self, + phone: str, + uri: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> dict: + if not user: + user = {} + + if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Login ID {phone} is not valid for phone", + ) + + body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) + url = self._compose_signup_url(DeliveryMethod.SMS) response = self._http.post(url, body=body) return response.json() @@ -63,7 +101,21 @@ def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | ) body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_sign_up_or_in_url() + url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) + response = self._http.post(url, body=body) + return response.json() + + def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: + login_options: LoginOptions | None = None + if signup_options is not None: + login_options = LoginOptions( + custom_claims=signup_options.customClaims, + template_options=signup_options.templateOptions, + template_id=signup_options.templateId, + ) + + body = self._compose_signin_body(phone, uri, login_options) + url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) response = self._http.post(url, body=body) return response.json() diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index 858cad192..bd6d1c94a 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -30,7 +30,24 @@ async def sign_in( validate_refresh_token_provided(login_options, refresh_token) body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_signin_url() + url = self._compose_signin_url(DeliveryMethod.EMAIL) + response = await self._http.post(url, body=body, pswd=refresh_token) + return response.json() + + async def sign_in_with_phone( + self, + phone: str, + uri: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> dict: + """Send an enchanted-link SMS for sign-in; returns the pending-ref and link-id.""" + self._validate_sign_in_login_id(phone) + + validate_refresh_token_provided(login_options, refresh_token) + + body = self._compose_signin_body(phone, uri, login_options) + url = self._compose_signin_url(DeliveryMethod.SMS) response = await self._http.post(url, body=body, pswd=refresh_token) return response.json() @@ -52,8 +69,31 @@ async def sign_up( f"Login ID {login_id} is not valid for email", ) - body = self._compose_signup_body(login_id, uri, user, signup_options) - url = self._compose_signup_url() + body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) + url = self._compose_signup_url(DeliveryMethod.EMAIL) + response = await self._http.post(url, body=body) + return response.json() + + async def sign_up_with_phone( + self, + phone: str, + uri: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> dict: + """Send an enchanted-link SMS for sign-up; returns the pending-ref and link-id.""" + if not user: + user = {} + + if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Login ID {phone} is not valid for phone", + ) + + body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) + url = self._compose_signup_url(DeliveryMethod.SMS) response = await self._http.post(url, body=body) return response.json() @@ -68,7 +108,22 @@ async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOpt ) body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_sign_up_or_in_url() + url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) + response = await self._http.post(url, body=body) + return response.json() + + async def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: + """Send an enchanted-link SMS for sign-up or sign-in depending on whether the user exists.""" + login_options: LoginOptions | None = None + if signup_options is not None: + login_options = LoginOptions( + custom_claims=signup_options.customClaims, + template_options=signup_options.templateOptions, + template_id=signup_options.templateId, + ) + + body = self._compose_signin_body(phone, uri, login_options) + url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) response = await self._http.post(url, body=body) return response.json() diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index c9b5eb47a..6123dda36 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -1,6 +1,6 @@ import pytest -from descope import AuthException +from descope import AuthException, DeliveryMethod from descope.authmethod.enchantedlink import EnchantedLink from descope.common import ( REFRESH_SESSION_COOKIE_NAME, @@ -15,9 +15,14 @@ class TestEnchantedLink: def test_compose_urls(self): - assert EnchantedLink._compose_signin_url() == "/v1/auth/enchantedlink/signin/email" - assert EnchantedLink._compose_signup_url() == "/v1/auth/enchantedlink/signup/email" - assert EnchantedLink._compose_sign_up_or_in_url() == "/v1/auth/enchantedlink/signup-in/email" + assert EnchantedLink._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signin/email" + assert EnchantedLink._compose_signup_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signup/email" + assert ( + EnchantedLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signup-in/email" + ) + assert EnchantedLink._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signin/sms" + assert EnchantedLink._compose_signup_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup/sms" + assert EnchantedLink._compose_sign_up_or_in_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup-in/sms" def test_compose_body(self): assert EnchantedLink._compose_signin_body("id1", "uri1") == { @@ -62,6 +67,26 @@ async def test_sign_in(self, client_factory): follow_redirects=False, ) + async def test_sign_in_with_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: + result = await client.invoke(client.enchantedlink.sign_in_with_phone("+11234567890", "http://r.me")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "+11234567890", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, + ) + async def test_sign_in_with_login_options(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) @@ -106,6 +131,37 @@ async def test_sign_up(self, client_factory): result = await client.invoke(client.enchantedlink.sign_up("dummy@dummy.com", "http://r.me", user)) assert result is not None + async def test_sign_up_with_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + user = {"name": "John"} + + # Validation error - invalid phone + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_up_with_phone("id", "http://r.me", {"phone": "not-valid"})) + + # Success + payload + with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: + result = await client.invoke(client.enchantedlink.sign_up_with_phone("+11234567890", "http://r.me", user)) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_enchantedlink_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "+11234567890", + "URI": "http://r.me", + "user": {"name": "John", "phone": "+11234567890"}, + "phone": "+11234567890", + }, + follow_redirects=False, + ) + async def test_sign_up_or_in(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) @@ -132,6 +188,26 @@ async def test_sign_up_or_in(self, client_factory): follow_redirects=False, ) + async def test_sign_up_or_in_with_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: + result = await client.invoke(client.enchantedlink.sign_up_or_in_with_phone("+11234567890", "http://r.me")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "+11234567890", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, + ) + async def test_get_session(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) From b5783a98ab59ee77c5a73f1dd3d01937449866ea Mon Sep 17 00:00:00 2001 From: Eliran Date: Tue, 8 Sep 2026 12:49:44 +0300 Subject: [PATCH 2/5] feat(enchantedlink): add the update/phone/sms route and document SMS delivery Completes Enchanted Link SMS support: adds the update_user_phone_enchantedlink_path route constant, the _compose_update_phone_url and _compose_update_user_phone_body helpers, and update_user_phone on both the sync and async classes. Also documents the phone variants in the README, whose Enchanted Link section still described the link as email-only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tygd97fhsqkojmDTnr1aVj --- README.md | 25 +++++++++++- descope/authmethod/_enchantedlink_base.py | 28 ++++++++++++++ descope/authmethod/enchantedlink.py | 28 ++++++++++++++ descope/authmethod/enchantedlink_async.py | 29 ++++++++++++++ descope/common.py | 1 + tests/test_enchantedlink.py | 47 +++++++++++++++++++++++ 6 files changed, 157 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec71c5eba..dae7d6eb9 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ The session and refresh JWTs should be returned to the caller, and passed with e ### Enchanted Link Using the Enchanted Link APIs enables users to sign in by clicking a link -delivered to their email address. The email will include 3 different links, +delivered to their email address or, with the `*_with_phone` variants, to their +phone number by SMS. The message will include 3 different links, and the user will have to click the right one, based on the 2-digit number that is displayed when initiating the authentication process. @@ -178,6 +179,28 @@ pending_ref = resp["pendingRef"] # Used to poll for a valid session masked_email = resp["maskedEmail"] # The email that the message was sent to in a masked format ``` +To deliver the link by SMS instead, use the phone variants — `sign_up_with_phone`, +`sign_in_with_phone` and `sign_up_or_in_with_phone`. They return `maskedPhone` in place +of `maskedEmail`: + +```python +resp = descope_client.enchantedlink.sign_up_or_in_with_phone( + phone=phone, + uri="http://myapp.com/verify-enchanted-link", # Set redirect URI here or via console +) +link_identifier = resp["linkId"] # Show the user which link they should press in their SMS +pending_ref = resp["pendingRef"] # Used to poll for a valid session +masked_phone = resp["maskedPhone"] # The phone number that the message was sent to in a masked format +``` + +An existing user's email or phone can be updated with an enchanted link sent to the new +address, which the user must click to confirm the change: + +```python +descope_client.enchantedlink.update_user_email(login_id, new_email, refresh_token) +descope_client.enchantedlink.update_user_phone(login_id, new_phone, refresh_token) +``` + After sending the link, you must poll to receive a valid session using the `pending_ref` from the previous step. A valid session will be returned only after the user clicks the right link. diff --git a/descope/authmethod/_enchantedlink_base.py b/descope/authmethod/_enchantedlink_base.py index 178e8407a..82be388bb 100644 --- a/descope/authmethod/_enchantedlink_base.py +++ b/descope/authmethod/_enchantedlink_base.py @@ -44,6 +44,10 @@ def _compose_signup_url(method: DeliveryMethod) -> str: def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, method) + @staticmethod + def _compose_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.update_user_phone_enchantedlink_path, method) + @staticmethod def _compose_signin_body( login_id: str, @@ -103,6 +107,30 @@ def _compose_update_user_email_body( body["providerId"] = provider_id return body + @staticmethod + def _compose_update_user_phone_body( + login_id: str, + phone: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "phone": phone, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body + @staticmethod def _compose_get_session_body(pending_ref: str) -> dict: return {"pendingRef": pending_ref} diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 7d4be9735..3dad9182e 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -158,3 +158,31 @@ def update_user_email( uri = EndpointsV1.update_user_email_enchantedlink_path response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() + + def update_user_phone( + self, + login_id: str, + phone: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + self._validate_login_id(login_id) + + Auth.validate_phone(DeliveryMethod.SMS, phone) + + body = self._compose_update_user_phone_body( + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + url = self._compose_update_phone_url(DeliveryMethod.SMS) + response = self._http.post(url, body=body, pswd=refresh_token) + return response.json() diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index bd6d1c94a..b3cb938ed 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -170,3 +170,32 @@ async def update_user_email( uri = EndpointsV1.update_user_email_enchantedlink_path response = await self._http.post(uri, body=body, pswd=refresh_token) return response.json() + + async def update_user_phone( + self, + login_id: str, + phone: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + """Send an enchanted-link SMS to a new phone number to verify the update.""" + self._validate_login_id(login_id) + + Auth.validate_phone(DeliveryMethod.SMS, phone) + + body = self._compose_update_user_phone_body( + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + url = self._compose_update_phone_url(DeliveryMethod.SMS) + response = await self._http.post(url, body=body, pswd=refresh_token) + return response.json() diff --git a/descope/common.py b/descope/common.py index 946eb7cb1..b11bbad75 100644 --- a/descope/common.py +++ b/descope/common.py @@ -59,6 +59,7 @@ class EndpointsV1: verify_enchantedlink_auth_path = "/v1/auth/enchantedlink/verify" get_session_enchantedlink_auth_path = "/v1/auth/enchantedlink/pending-session" update_user_email_enchantedlink_path = "/v1/auth/enchantedlink/update/email" + update_user_phone_enchantedlink_path = "/v1/auth/enchantedlink/update/phone" # oauth oauth_start_path = "/v1/auth/oauth/authorize" diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index 6123dda36..cb07c05f6 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -23,6 +23,7 @@ def test_compose_urls(self): assert EnchantedLink._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signin/sms" assert EnchantedLink._compose_signup_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup/sms" assert EnchantedLink._compose_sign_up_or_in_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup-in/sms" + assert EnchantedLink._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/update/phone/sms" def test_compose_body(self): assert EnchantedLink._compose_signin_body("id1", "uri1") == { @@ -305,3 +306,49 @@ async def test_update_user_email(self, client_factory): }, follow_redirects=False, ) + + async def test_update_user_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_phone("", "+11234567890", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_phone(None, "+11234567890", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_phone("id", "", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_phone("id", "not-a-phone", refresh_token)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_phone("id", "+11234567890", refresh_token)) + + # Success + payload + with client.mock_post( + make_response({"pendingRef": "ref123", "linkId": "lnk1", "maskedPhone": "+1123*****90"}) + ) as mock_post: + result = await client.invoke( + client.enchantedlink.update_user_phone("dummy@dummy.com", "+11234567890", refresh_token) + ) + assert result["maskedPhone"] == "+1123*****90" + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_enchantedlink_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "phone": "+11234567890", + "addToLoginIDs": False, + "onMergeUseExisting": False, + }, + follow_redirects=False, + ) From b15c2379b7bf6feb08bbf096e7328639d253366d Mon Sep 17 00:00:00 2001 From: Eliran Date: Wed, 9 Sep 2026 12:01:33 +0300 Subject: [PATCH 3/5] fix(enchantedlink): forward SignUpOptions on phone sign-up-or-in sign_up_or_in_with_phone built its LoginOptions without revoke_other_sessions, so the option was silently dropped. Passed it through in both the sync and async clients. The email variant sign_up_or_in has the same gap on main, as do the magiclink and otp sign_up_or_in methods; those are left untouched here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tygd97fhsqkojmDTnr1aVj --- descope/authmethod/enchantedlink.py | 1 + descope/authmethod/enchantedlink_async.py | 1 + tests/test_enchantedlink.py | 43 +++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 3dad9182e..53a7c9080 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -109,6 +109,7 @@ def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpO login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index b3cb938ed..815b732ec 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -117,6 +117,7 @@ async def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: S login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index cb07c05f6..fd1ef3352 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -6,6 +6,7 @@ REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, + SignUpOptions, ) from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN @@ -209,6 +210,48 @@ async def test_sign_up_or_in_with_phone(self, client_factory): follow_redirects=False, ) + async def test_sign_up_or_in_with_phone_forwards_signup_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: + result = await client.invoke( + client.enchantedlink.sign_up_or_in_with_phone( + "+11234567890", + "http://r.me", + SignUpOptions( + revoke_other_sessions=True, + custom_claims={"k1": "v1"}, + template_options={"blah": "blah"}, + template_id="tmpl1", + ), + ) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "+11234567890", + "URI": "http://r.me", + "loginOptions": { + "stepup": False, + "mfa": False, + "customClaims": {"k1": "v1"}, + "revokeOtherSessions": True, + "templateOptions": {"blah": "blah"}, + "templateId": "tmpl1", + }, + }, + follow_redirects=False, + ) + async def test_get_session(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) From 4f55681b27261ff3ee60d857d8bbf0c37f3dcdb0 Mon Sep 17 00:00:00 2001 From: Eliran Date: Wed, 9 Sep 2026 12:10:25 +0300 Subject: [PATCH 4/5] fix(auth): forward revokeOtherSessions on every sign-up-or-in SignUpOptions.revokeOtherSessions was dropped when composing LoginOptions for the email sign-up-or-in flows in enchantedlink, magiclink and otp, in both the sync and async clients, so callers setting it got no effect. The phone variant was fixed separately; this brings the remaining sites into line so behaviour no longer differs by delivery method. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tygd97fhsqkojmDTnr1aVj --- descope/authmethod/enchantedlink.py | 1 + descope/authmethod/enchantedlink_async.py | 1 + descope/authmethod/magiclink.py | 1 + descope/authmethod/magiclink_async.py | 1 + descope/authmethod/otp.py | 1 + descope/authmethod/otp_async.py | 1 + tests/test_enchantedlink.py | 42 ++++++++++++++++++++++ tests/test_magiclink.py | 44 +++++++++++++++++++++++ tests/test_otp.py | 42 ++++++++++++++++++++++ 9 files changed, 134 insertions(+) diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 53a7c9080..3b1e30f11 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -95,6 +95,7 @@ def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index 815b732ec..26311fab4 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -102,6 +102,7 @@ async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOpt login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py index c025c93be..eb3707b23 100644 --- a/descope/authmethod/magiclink.py +++ b/descope/authmethod/magiclink.py @@ -67,6 +67,7 @@ def sign_up_or_in( login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/magiclink_async.py b/descope/authmethod/magiclink_async.py index 1e996ff8e..f345d344d 100644 --- a/descope/authmethod/magiclink_async.py +++ b/descope/authmethod/magiclink_async.py @@ -72,6 +72,7 @@ async def sign_up_or_in( login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index 70671353f..1b6cff941 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -110,6 +110,7 @@ def sign_up_or_in( login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/descope/authmethod/otp_async.py b/descope/authmethod/otp_async.py index da308279c..4266fbc70 100644 --- a/descope/authmethod/otp_async.py +++ b/descope/authmethod/otp_async.py @@ -72,6 +72,7 @@ async def sign_up_or_in( login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( + revoke_other_sessions=signup_options.revokeOtherSessions, custom_claims=signup_options.customClaims, template_options=signup_options.templateOptions, template_id=signup_options.templateId, diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index fd1ef3352..c60ace51d 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -190,6 +190,48 @@ async def test_sign_up_or_in(self, client_factory): follow_redirects=False, ) + async def test_sign_up_or_in_forwards_signup_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: + result = await client.invoke( + client.enchantedlink.sign_up_or_in( + "dummy@dummy.com", + "http://r.me", + SignUpOptions( + revoke_other_sessions=True, + custom_claims={"k1": "v1"}, + template_options={"blah": "blah"}, + template_id="tmpl1", + ), + ) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "URI": "http://r.me", + "loginOptions": { + "stepup": False, + "mfa": False, + "customClaims": {"k1": "v1"}, + "revokeOtherSessions": True, + "templateOptions": {"blah": "blah"}, + "templateId": "tmpl1", + }, + }, + follow_redirects=False, + ) + async def test_sign_up_or_in_with_phone(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py index 7c9d078ea..cbb275d30 100644 --- a/tests/test_magiclink.py +++ b/tests/test_magiclink.py @@ -6,6 +6,7 @@ REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, + SignUpOptions, ) from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN @@ -130,6 +131,49 @@ async def test_sign_up_or_in(self, client_factory): follow_redirects=False, ) + async def test_sign_up_or_in_forwards_signup_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + result = await client.invoke( + client.magiclink.sign_up_or_in( + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://r.me", + SignUpOptions( + revoke_other_sessions=True, + custom_claims={"k1": "v1"}, + template_options={"blah": "blah"}, + template_id="tmpl1", + ), + ) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_magiclink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "URI": "http://r.me", + "loginOptions": { + "stepup": False, + "mfa": False, + "customClaims": {"k1": "v1"}, + "revokeOtherSessions": True, + "templateOptions": {"blah": "blah"}, + "templateId": "tmpl1", + }, + }, + follow_redirects=False, + ) + async def test_verify(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) diff --git a/tests/test_otp.py b/tests/test_otp.py index a745fd5a9..8bc02bf4f 100644 --- a/tests/test_otp.py +++ b/tests/test_otp.py @@ -6,6 +6,7 @@ REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, + SignUpOptions, ) from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN @@ -125,6 +126,47 @@ async def test_sign_up_or_in(self, client_factory): follow_redirects=False, ) + async def test_sign_up_or_in_forwards_signup_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + result = await client.invoke( + client.otp.sign_up_or_in( + DeliveryMethod.EMAIL, + "dummy@dummy.com", + SignUpOptions( + revoke_other_sessions=True, + custom_claims={"k1": "v1"}, + template_options={"blah": "blah"}, + template_id="tmpl1", + ), + ) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_otp_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "loginOptions": { + "stepup": False, + "mfa": False, + "customClaims": {"k1": "v1"}, + "revokeOtherSessions": True, + "templateOptions": {"blah": "blah"}, + "templateId": "tmpl1", + }, + }, + follow_redirects=False, + ) + async def test_verify_code(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) From 58741512babf36f362a0c91128b988c1dcccad3d Mon Sep 17 00:00:00 2001 From: Eliran Date: Thu, 10 Sep 2026 14:24:45 +0300 Subject: [PATCH 5/5] docs(enchantedlink): scope the three-links behaviour to email The enchanted link intro was widened to mention the phone variants while still claiming the message carries three links. Over SMS only the correct link is sent, so the intro now covers email alone and the SMS paragraph states that there is nothing for the user to choose. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dae7d6eb9..a74325480 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,7 @@ The session and refresh JWTs should be returned to the caller, and passed with e ### Enchanted Link Using the Enchanted Link APIs enables users to sign in by clicking a link -delivered to their email address or, with the `*_with_phone` variants, to their -phone number by SMS. The message will include 3 different links, +delivered to their email address. The email will include 3 different links, and the user will have to click the right one, based on the 2-digit number that is displayed when initiating the authentication process. @@ -181,7 +180,8 @@ masked_email = resp["maskedEmail"] # The email that the message was sent to in a To deliver the link by SMS instead, use the phone variants — `sign_up_with_phone`, `sign_in_with_phone` and `sign_up_or_in_with_phone`. They return `maskedPhone` in place -of `maskedEmail`: +of `maskedEmail`. The SMS carries only the correct link, so there is nothing for the +user to choose: ```python resp = descope_client.enchantedlink.sign_up_or_in_with_phone(