diff --git a/README.md b/README.md index ec71c5eba..a74325480 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,29 @@ 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`. 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( + 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 a6325678f..82be388bb 100644 --- a/descope/authmethod/_enchantedlink_base.py +++ b/descope/authmethod/_enchantedlink_base.py @@ -33,16 +33,20 @@ 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_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.update_user_phone_enchantedlink_path, method) @staticmethod def _compose_signin_body( @@ -58,6 +62,7 @@ def _compose_signin_body( @staticmethod def _compose_signup_body( + method: DeliveryMethod, login_id: str, uri: str, user: dict | None = None, @@ -70,7 +75,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 @@ -102,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 76aee4f63..3b1e30f11 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() @@ -57,13 +95,29 @@ 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, ) 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( + revoke_other_sessions=signup_options.revokeOtherSessions, + 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() @@ -106,3 +160,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 858cad192..26311fab4 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() @@ -62,13 +102,30 @@ 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, ) 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( + revoke_other_sessions=signup_options.revokeOtherSessions, + 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() @@ -115,3 +172,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/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/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 c9b5eb47a..c60ace51d 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -1,11 +1,12 @@ 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, 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 @@ -15,9 +16,15 @@ 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" + 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") == { @@ -62,6 +69,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 +133,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 +190,110 @@ 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) + + 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_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) @@ -229,3 +391,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, + ) 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)