From 2fabf8ff0ecf5aa2e8d27eee619f4b0659376fc4 Mon Sep 17 00:00:00 2001 From: Eliran Date: Tue, 8 Sep 2026 12:54:17 +0300 Subject: [PATCH 1/3] feat(auth): support enchanted link over SMS Adds signInWithPhone, signUpWithPhone (two overloads), signUpOrInWithPhone and updateUserPhone (two overloads) to EnchantedLinkService, targeting the four /v1/auth/enchantedlink/**/sms routes. The SMS routes return the backend's distinct PhoneEnchantedLinkResponse message, so a new model class carries maskedPhone rather than widening EnchantedLinkResponse, whose all-args constructor arity stays intact. --- README.md | 17 ++- .../java/com/descope/literals/Routes.java | 1 + .../PhoneEnchantedLinkResponse.java | 14 ++ .../magiclink/request/SignUpRequest.java | 1 + .../sdk/auth/EnchantedLinkService.java | 88 +++++++++++++ .../auth/impl/EnchantedLinkServiceImpl.java | 123 ++++++++++++++--- .../impl/EnchantedLinkServiceImplTest.java | 124 ++++++++++++++++++ 7 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/descope/model/enchantedlink/PhoneEnchantedLinkResponse.java diff --git a/README.md b/README.md index 1b24b654..e07701fd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,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 `WithPhone` 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. @@ -221,6 +222,20 @@ try { ``` +To deliver the enchanted link by SMS, use the `WithPhone` variants, which take a phone +number as the login ID and return a `PhoneEnchantedLinkResponse` carrying `maskedPhone`. + +```java +PhoneEnchantedLinkResponse res = null; +try { + String uri = "http://myapp.com/verify-enchanted-link"; + res = els.signUpOrInWithPhone(phoneNumber, uri); +} catch (DescopeException de) { + // Handle the error +} + +``` + After sending the link, you must poll to receive a valid session using the `PendingRef` from the previous step. A valid session will be returned only after the user clicks the right link. diff --git a/src/main/java/com/descope/literals/Routes.java b/src/main/java/com/descope/literals/Routes.java index 00800e26..e55b2def 100644 --- a/src/main/java/com/descope/literals/Routes.java +++ b/src/main/java/com/descope/literals/Routes.java @@ -38,6 +38,7 @@ public static class AuthEndPoints { public static final String VERIFY_ENCHANTED_LINK = "/v1/auth/enchantedlink/verify"; public static final String ENCHANTED_LINK_SESSION = "/v1/auth/enchantedlink/pending-session"; public static final String UPDATE_EMAIL_ENCHANTED_LINK = "/v1/auth/enchantedlink/update/email"; + public static final String UPDATE_PHONE_ENCHANTED_LINK = "/v1/auth/enchantedlink/update/phone"; // TOTP public static final String SIGN_UP_TOTP_LINK = "/v1/auth/totp/signup"; diff --git a/src/main/java/com/descope/model/enchantedlink/PhoneEnchantedLinkResponse.java b/src/main/java/com/descope/model/enchantedlink/PhoneEnchantedLinkResponse.java new file mode 100644 index 00000000..7e5a0a0f --- /dev/null +++ b/src/main/java/com/descope/model/enchantedlink/PhoneEnchantedLinkResponse.java @@ -0,0 +1,14 @@ +package com.descope.model.enchantedlink; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PhoneEnchantedLinkResponse { + private String pendingRef; + private String linkId; + private String maskedPhone; +} diff --git a/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java b/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java index 26e29163..c11f78f2 100644 --- a/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java +++ b/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java @@ -14,6 +14,7 @@ @AllArgsConstructor public class SignUpRequest { private String email; + private String phone; private String loginId; private User user; diff --git a/src/main/java/com/descope/sdk/auth/EnchantedLinkService.java b/src/main/java/com/descope/sdk/auth/EnchantedLinkService.java index f51e671b..b5d9e092 100644 --- a/src/main/java/com/descope/sdk/auth/EnchantedLinkService.java +++ b/src/main/java/com/descope/sdk/auth/EnchantedLinkService.java @@ -4,6 +4,7 @@ import com.descope.model.auth.AuthenticationInfo; import com.descope.model.auth.UpdateOptions; import com.descope.model.enchantedlink.EnchantedLinkResponse; +import com.descope.model.enchantedlink.PhoneEnchantedLinkResponse; import com.descope.model.magiclink.LoginOptions; import com.descope.model.magiclink.SignUpOptions; import com.descope.model.user.User; @@ -27,6 +28,23 @@ EnchantedLinkResponse signIn( LoginOptions loginOptions) throws DescopeException; + /** + * Use to login a user based on an enchanted link that will be sent by SMS. + * + * @param loginId - User login ID + * @param uri - Base URI + * @param token - when doing step-up or mfa then we need current session token + * @param loginOptions - {@link LoginOptions LoginOptions} + * @return pendingRef, linkId and masked phone + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse signInWithPhone( + String loginId, + String uri, + String token, + LoginOptions loginOptions) + throws DescopeException; + /** * Use to create a new user based on the given loginID either email or a phone. * @@ -52,6 +70,33 @@ EnchantedLinkResponse signUp(String loginId, String uri, User user) EnchantedLinkResponse signUp(String loginId, String uri, User user, SignUpOptions signupOptions) throws DescopeException; + /** + * Use to create a new user with a phone number as the loginID, verified by an enchanted link + * sent by SMS. + * + * @param loginId - User login ID, a phone number + * @param uri - Base URI + * @param user - {@link User User} + * @return pendingRef, linkId and masked phone + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse signUpWithPhone(String loginId, String uri, User user) + throws DescopeException; + + /** + * Use to create a new user with a phone number as the loginID, verified by an enchanted link + * sent by SMS. + * + * @param loginId - User login ID, a phone number + * @param uri - Base URI + * @param user - {@link User User} + * @param signupOptions - optional claims and template strings + * @return pendingRef, linkId and masked phone + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse signUpWithPhone(String loginId, String uri, User user, + SignUpOptions signupOptions) throws DescopeException; + /** * Use to login in using loginID, if user does not exist, a new user will be created. * @@ -63,6 +108,18 @@ EnchantedLinkResponse signUp(String loginId, String uri, User user, SignUpOption EnchantedLinkResponse signUpOrIn(String loginId, String uri) throws DescopeException; + /** + * Use to login in using a phone number as the loginID, if user does not exist, a new user will + * be created. The enchanted link is sent by SMS. + * + * @param loginId - User login ID, a phone number + * @param uri - Base URI + * @return pendingRef, linkId and masked phone + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse signUpOrInWithPhone(String loginId, String uri) + throws DescopeException; + /** * Use to get a session that was generated by SignIn/SignUp request. * @@ -110,4 +167,35 @@ EnchantedLinkResponse updateUserEmail(String loginId, String email, String uri, */ EnchantedLinkResponse updateUserEmail(String loginId, String email, String uri, String refreshToken, UpdateOptions updateOptions, Map templateOptions) throws DescopeException; + + /** + * Use to update phone and validate via enchanted link sent by SMS. + * + * @param loginId - User login ID + * @param phone - User phone number + * @param uri - Base URI + * @param refreshToken - refresh token to perform the update + * @param updateOptions - update options for the update + * @return {@link PhoneEnchantedLinkResponse} including masked address where the link was sent + * (phone), link to chose and link to retrieve new session from + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse updateUserPhone(String loginId, String phone, String uri, String refreshToken, + UpdateOptions updateOptions) throws DescopeException; + + /** + * Use to update phone and validate via enchanted link sent by SMS. + * + * @param loginId - User login ID + * @param phone - User phone number + * @param uri - Base URI + * @param refreshToken - refresh token to perform the update + * @param updateOptions - update options for the update + * @param templateOptions - optional parameters for template + * @return {@link PhoneEnchantedLinkResponse} including masked address where the link was sent + * (phone), link to chose and link to retrieve new session from + * @throws DescopeException - error upon failure + */ + PhoneEnchantedLinkResponse updateUserPhone(String loginId, String phone, String uri, String refreshToken, + UpdateOptions updateOptions, Map templateOptions) throws DescopeException; } diff --git a/src/main/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImpl.java b/src/main/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImpl.java index cfb211b7..b649af4f 100644 --- a/src/main/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImpl.java +++ b/src/main/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImpl.java @@ -1,14 +1,18 @@ package com.descope.sdk.auth.impl; import static com.descope.enums.DeliveryMethod.EMAIL; +import static com.descope.enums.DeliveryMethod.SMS; import static com.descope.literals.Routes.AuthEndPoints.ENCHANTED_LINK_SESSION; import static com.descope.literals.Routes.AuthEndPoints.SIGN_IN_ENCHANTED_LINK; import static com.descope.literals.Routes.AuthEndPoints.SIGN_UP_ENCHANTED_LINK; import static com.descope.literals.Routes.AuthEndPoints.SIGN_UP_OR_IN_ENCHANTED_LINK; import static com.descope.literals.Routes.AuthEndPoints.UPDATE_EMAIL_ENCHANTED_LINK; +import static com.descope.literals.Routes.AuthEndPoints.UPDATE_PHONE_ENCHANTED_LINK; import static com.descope.literals.Routes.AuthEndPoints.VERIFY_ENCHANTED_LINK; import static com.descope.utils.PatternUtils.EMAIL_PATTERN; +import static com.descope.utils.PatternUtils.PHONE_PATTERN; +import com.descope.enums.DeliveryMethod; import com.descope.exception.DescopeException; import com.descope.exception.ServerCommonException; import com.descope.model.auth.AuthenticationInfo; @@ -17,12 +21,14 @@ import com.descope.model.enchantedlink.EmptyResponse; import com.descope.model.enchantedlink.EnchantedLinkResponse; import com.descope.model.enchantedlink.EnchantedLinkSessionBody; +import com.descope.model.enchantedlink.PhoneEnchantedLinkResponse; import com.descope.model.jwt.response.JWTResponse; import com.descope.model.magiclink.LoginOptions; import com.descope.model.magiclink.SignUpOptions; import com.descope.model.magiclink.request.SignInRequest; import com.descope.model.magiclink.request.SignUpRequest; import com.descope.model.magiclink.request.UpdateEmailRequest; +import com.descope.model.magiclink.request.UpdatePhoneRequest; import com.descope.model.magiclink.request.VerifyRequest; import com.descope.model.user.User; import com.descope.proxy.ApiProxy; @@ -41,10 +47,21 @@ class EnchantedLinkServiceImpl extends AuthenticationServiceImpl implements Ench @Override public EnchantedLinkResponse signIn(String loginId, String uri, String token, LoginOptions loginOptions) throws DescopeException { + return signInByDeliveryMethod(EMAIL, loginId, uri, token, loginOptions, EnchantedLinkResponse.class); + } + + @Override + public PhoneEnchantedLinkResponse signInWithPhone(String loginId, String uri, String token, + LoginOptions loginOptions) throws DescopeException { + return signInByDeliveryMethod(SMS, loginId, uri, token, loginOptions, PhoneEnchantedLinkResponse.class); + } + + private R signInByDeliveryMethod(DeliveryMethod deliveryMethod, String loginId, String uri, String token, + LoginOptions loginOptions, Class responseClass) throws DescopeException { if (StringUtils.isBlank(loginId)) { throw ServerCommonException.invalidArgument("Login ID"); } - URI enchantedLink = composeEnchantedLinkSignInURL(); + URI enchantedLink = composeEnchantedLinkSignInURL(deliveryMethod); SignInRequest signInRequest = new SignInRequest(uri, loginId, loginOptions); ApiProxy apiProxy; if (JwtUtils.isJWTRequired(loginOptions)) { @@ -55,7 +72,7 @@ public EnchantedLinkResponse signIn(String loginId, String uri, String token, Lo } else { apiProxy = getApiProxy(); } - return apiProxy.post(enchantedLink, signInRequest, EnchantedLinkResponse.class); + return apiProxy.post(enchantedLink, signInRequest, responseClass); } @Override @@ -67,32 +84,67 @@ public EnchantedLinkResponse signUp(String loginId, String uri, User user) @Override public EnchantedLinkResponse signUp(String loginId, String uri, User user, SignUpOptions signupOptions) throws DescopeException { + return signUpByDeliveryMethod(EMAIL, loginId, uri, user, signupOptions, EnchantedLinkResponse.class); + } + + @Override + public PhoneEnchantedLinkResponse signUpWithPhone(String loginId, String uri, User user) + throws DescopeException { + return signUpWithPhone(loginId, uri, user, null); + } + + @Override + public PhoneEnchantedLinkResponse signUpWithPhone(String loginId, String uri, User user, + SignUpOptions signupOptions) throws DescopeException { + return signUpByDeliveryMethod(SMS, loginId, uri, user, signupOptions, PhoneEnchantedLinkResponse.class); + } + + private R signUpByDeliveryMethod(DeliveryMethod deliveryMethod, String loginId, String uri, User user, + SignUpOptions signupOptions, Class responseClass) throws DescopeException { if (user == null) { user = new User(); } - URI enchantedLinkSignUpURL = composeEnchantedLinkSignUpURL(); + URI enchantedLinkSignUpURL = composeEnchantedLinkSignUpURL(deliveryMethod); SignUpRequest.SignUpRequestBuilder signUpRequestBuilder = - SignUpRequest.builder().loginId(loginId).uri(uri).user(user).email(loginId); - if (StringUtils.isBlank(user.getEmail())) { - user.setEmail(loginId); + SignUpRequest.builder().loginId(loginId).uri(uri); + if (deliveryMethod == SMS) { + signUpRequestBuilder.phone(loginId); + if (StringUtils.isBlank(user.getPhone())) { + user.setPhone(loginId); + } + } else { + signUpRequestBuilder.email(loginId); + if (StringUtils.isBlank(user.getEmail())) { + user.setEmail(loginId); + } } if (signupOptions != null) { signUpRequestBuilder.loginOptions(signupOptions); } SignUpRequest signUpRequest = signUpRequestBuilder.user(user).build(); ApiProxy apiProxy = getApiProxy(); - return apiProxy.post(enchantedLinkSignUpURL, signUpRequest, EnchantedLinkResponse.class); + return apiProxy.post(enchantedLinkSignUpURL, signUpRequest, responseClass); } @Override public EnchantedLinkResponse signUpOrIn(String loginId, String uri) throws DescopeException { + return signUpOrInByDeliveryMethod(EMAIL, loginId, uri, EnchantedLinkResponse.class); + } + + @Override + public PhoneEnchantedLinkResponse signUpOrInWithPhone(String loginId, String uri) throws DescopeException { + return signUpOrInByDeliveryMethod(SMS, loginId, uri, PhoneEnchantedLinkResponse.class); + } + + private R signUpOrInByDeliveryMethod(DeliveryMethod deliveryMethod, String loginId, String uri, + Class responseClass) throws DescopeException { if (StringUtils.isBlank(loginId)) { throw ServerCommonException.invalidArgument("Login ID"); } - URI magicLinkSignUpOrInURL = composeEnchantedLinkSignUpOrInURL(); + URI enchantedLinkSignUpOrInURL = composeEnchantedLinkSignUpOrInURL(deliveryMethod); SignInRequest signInRequest = new SignInRequest(uri, loginId, null); ApiProxy apiProxy = getApiProxy(); - return apiProxy.post(magicLinkSignUpOrInURL, signInRequest, EnchantedLinkResponse.class); + return apiProxy.post(enchantedLinkSignUpOrInURL, signInRequest, responseClass); } @Override @@ -152,20 +204,61 @@ public EnchantedLinkResponse updateUserEmail(String loginId, String email, Strin return apiProxy.post(magicLinkUpdateUserEmail, updateEmailRequest, EnchantedLinkResponse.class); } + @Override + public PhoneEnchantedLinkResponse updateUserPhone(String loginId, String phone, String uri, String refreshToken, + UpdateOptions updateOptions) throws DescopeException { + return updateUserPhone(loginId, phone, uri, refreshToken, updateOptions, null); + } + + @Override + public PhoneEnchantedLinkResponse updateUserPhone(String loginId, String phone, String uri, String refreshToken, + UpdateOptions updateOptions, Map templateOptions) throws DescopeException { + if (StringUtils.isBlank(loginId)) { + throw ServerCommonException.invalidArgument("Login ID"); + } + if (StringUtils.isBlank(phone) || !PHONE_PATTERN.matcher(phone).matches()) { + throw ServerCommonException.invalidArgument("Phone"); + } + if (StringUtils.isBlank(refreshToken)) { + throw ServerCommonException.invalidArgument("Refresh Token"); + } + URI enchantedLinkUpdateUserPhone = composeUpdateUserPhoneEnchantedLink(); + if (updateOptions == null) { + updateOptions = new UpdateOptions(); + } + UpdatePhoneRequest updatePhoneRequest = + UpdatePhoneRequest.builder() + .phone(phone) + .uri(uri) + .loginId(loginId) + .crossDevice(false) + .addToLoginIds(updateOptions.isAddToLoginIds()) + .onMergeUseExisting(updateOptions.isOnMergeUseExisting()) + .templateOptions(templateOptions) + .build(); + + ApiProxy apiProxy = getApiProxy(refreshToken); + return apiProxy.post(enchantedLinkUpdateUserPhone, updatePhoneRequest, PhoneEnchantedLinkResponse.class); + } + private URI composeUpdateUserEmailEnchantedLink() { return getUri(UPDATE_EMAIL_ENCHANTED_LINK); } - private URI composeEnchantedLinkSignInURL() { - return composeURI(SIGN_IN_ENCHANTED_LINK, EMAIL.getValue()); + private URI composeUpdateUserPhoneEnchantedLink() { + return composeURI(UPDATE_PHONE_ENCHANTED_LINK, SMS.getValue()); + } + + private URI composeEnchantedLinkSignInURL(DeliveryMethod deliveryMethod) { + return composeURI(SIGN_IN_ENCHANTED_LINK, deliveryMethod.getValue()); } - private URI composeEnchantedLinkSignUpURL() { - return composeURI(SIGN_UP_ENCHANTED_LINK, EMAIL.getValue()); + private URI composeEnchantedLinkSignUpURL(DeliveryMethod deliveryMethod) { + return composeURI(SIGN_UP_ENCHANTED_LINK, deliveryMethod.getValue()); } - private URI composeEnchantedLinkSignUpOrInURL() { - return composeURI(SIGN_UP_OR_IN_ENCHANTED_LINK, EMAIL.getValue()); + private URI composeEnchantedLinkSignUpOrInURL(DeliveryMethod deliveryMethod) { + return composeURI(SIGN_UP_OR_IN_ENCHANTED_LINK, deliveryMethod.getValue()); } private URI composeVerifyEnchantedLinkURL() { diff --git a/src/test/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImplTest.java b/src/test/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImplTest.java index 2e510564..9aa879ff 100644 --- a/src/test/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImplTest.java +++ b/src/test/java/com/descope/sdk/auth/impl/EnchantedLinkServiceImplTest.java @@ -4,6 +4,8 @@ import static com.descope.sdk.TestUtils.MOCK_EMAIL; import static com.descope.sdk.TestUtils.MOCK_JWT_RESPONSE; import static com.descope.sdk.TestUtils.MOCK_MASKED_EMAIL; +import static com.descope.sdk.TestUtils.MOCK_MASKED_PHONE; +import static com.descope.sdk.TestUtils.MOCK_PHONE; import static com.descope.sdk.TestUtils.MOCK_REFRESH_TOKEN; import static com.descope.sdk.TestUtils.MOCK_SIGNING_KEY; import static com.descope.sdk.TestUtils.MOCK_TOKEN; @@ -19,6 +21,8 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; @@ -26,8 +30,12 @@ import com.descope.model.client.Client; import com.descope.model.enchantedlink.EmptyResponse; import com.descope.model.enchantedlink.EnchantedLinkResponse; +import com.descope.model.enchantedlink.PhoneEnchantedLinkResponse; import com.descope.model.jwt.Token; import com.descope.model.jwt.response.SigningKeysResponse; +import com.descope.model.magiclink.request.SignInRequest; +import com.descope.model.magiclink.request.SignUpRequest; +import com.descope.model.magiclink.request.UpdatePhoneRequest; import com.descope.model.user.User; import com.descope.model.user.request.UserRequest; import com.descope.model.user.response.EnchantedLinkTestUserResponse; @@ -40,12 +48,14 @@ import com.descope.sdk.mgmt.impl.ManagementServiceBuilder; import com.descope.utils.JwtUtils; import com.descope.utils.UriUtils; +import java.net.URI; import java.util.Arrays; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; public class EnchantedLinkServiceImplTest { @@ -170,6 +180,120 @@ void testUpdateUserEmailForSuccess() { } } + @Test + void signUpWithPhoneSendsPhoneBodyToSmsRoute() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new PhoneEnchantedLinkResponse(MOCK_URL, MOCK_URL, MOCK_MASKED_PHONE)) + .when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + PhoneEnchantedLinkResponse response = + enchantedLinkService.signUpWithPhone(MOCK_PHONE, MOCK_DOMAIN, null); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(SignUpRequest.class); + verify(apiProxy, times(1)).post(uriCaptor.capture(), bodyCaptor.capture(), + eq(PhoneEnchantedLinkResponse.class)); + assertThat(uriCaptor.getValue().getPath()).isEqualTo("/v1/auth/enchantedlink/signup/sms"); + SignUpRequest sent = bodyCaptor.getValue(); + assertThat(sent.getPhone()).isEqualTo(MOCK_PHONE); + assertThat(sent.getLoginId()).isEqualTo(MOCK_PHONE); + assertThat(sent.getEmail()).isNull(); + assertThat(sent.getUri()).isEqualTo(MOCK_DOMAIN); + assertThat(sent.getUser().getPhone()).isEqualTo(MOCK_PHONE); + assertThat(response.getMaskedPhone()).isEqualTo(MOCK_MASKED_PHONE); + } + } + + @Test + void signInWithPhoneSendsLoginIdToSmsRoute() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new PhoneEnchantedLinkResponse(MOCK_URL, MOCK_URL, MOCK_MASKED_PHONE)) + .when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + PhoneEnchantedLinkResponse response = + enchantedLinkService.signInWithPhone(MOCK_PHONE, MOCK_DOMAIN, null, null); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(SignInRequest.class); + verify(apiProxy, times(1)).post(uriCaptor.capture(), bodyCaptor.capture(), + eq(PhoneEnchantedLinkResponse.class)); + assertThat(uriCaptor.getValue().getPath()).isEqualTo("/v1/auth/enchantedlink/signin/sms"); + SignInRequest sent = bodyCaptor.getValue(); + assertThat(sent.getLoginId()).isEqualTo(MOCK_PHONE); + assertThat(sent.getUri()).isEqualTo(MOCK_DOMAIN); + assertThat(response.getMaskedPhone()).isEqualTo(MOCK_MASKED_PHONE); + } + } + + @Test + void signUpOrInWithPhoneSendsLoginIdToSmsRoute() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new PhoneEnchantedLinkResponse(MOCK_URL, MOCK_URL, MOCK_MASKED_PHONE)) + .when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + PhoneEnchantedLinkResponse response = + enchantedLinkService.signUpOrInWithPhone(MOCK_PHONE, MOCK_DOMAIN); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(SignInRequest.class); + verify(apiProxy, times(1)).post(uriCaptor.capture(), bodyCaptor.capture(), + eq(PhoneEnchantedLinkResponse.class)); + assertThat(uriCaptor.getValue().getPath()).isEqualTo("/v1/auth/enchantedlink/signup-in/sms"); + SignInRequest sent = bodyCaptor.getValue(); + assertThat(sent.getLoginId()).isEqualTo(MOCK_PHONE); + assertThat(sent.getUri()).isEqualTo(MOCK_DOMAIN); + assertThat(response.getMaskedPhone()).isEqualTo(MOCK_MASKED_PHONE); + } + } + + @Test + void updateUserPhoneSendsPhoneBodyToSmsRoute() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new PhoneEnchantedLinkResponse(MOCK_URL, MOCK_URL, MOCK_MASKED_PHONE)) + .when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + PhoneEnchantedLinkResponse response = enchantedLinkService.updateUserPhone( + MOCK_EMAIL, MOCK_PHONE, MOCK_DOMAIN, MOCK_REFRESH_TOKEN, null); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(UpdatePhoneRequest.class); + verify(apiProxy, times(1)).post(uriCaptor.capture(), bodyCaptor.capture(), + eq(PhoneEnchantedLinkResponse.class)); + assertThat(uriCaptor.getValue().getPath()) + .isEqualTo("/v1/auth/enchantedlink/update/phone/sms"); + UpdatePhoneRequest sent = bodyCaptor.getValue(); + assertThat(sent.getPhone()).isEqualTo(MOCK_PHONE); + assertThat(sent.getLoginId()).isEqualTo(MOCK_EMAIL); + assertThat(sent.getUri()).isEqualTo(MOCK_DOMAIN); + assertThat(response.getMaskedPhone()).isEqualTo(MOCK_MASKED_PHONE); + } + } + + @Test + void updateUserPhoneRejectsInvalidArguments() { + assertEquals("The Login ID argument is invalid", assertThrows(ServerCommonException.class, + () -> enchantedLinkService.updateUserPhone("", MOCK_PHONE, MOCK_DOMAIN, MOCK_REFRESH_TOKEN, null)) + .getMessage()); + assertEquals("The Phone argument is invalid", assertThrows(ServerCommonException.class, + () -> enchantedLinkService.updateUserPhone(MOCK_EMAIL, "abc", MOCK_DOMAIN, MOCK_REFRESH_TOKEN, null)) + .getMessage()); + assertEquals("The Refresh Token argument is invalid", assertThrows(ServerCommonException.class, + () -> enchantedLinkService.updateUserPhone(MOCK_EMAIL, MOCK_PHONE, MOCK_DOMAIN, "", null)) + .getMessage()); + } + @Test void testGetSession() { ApiProxy apiProxy = mock(ApiProxy.class); From ff32fabf108b68457e1941bee71191523fc384ab Mon Sep 17 00:00:00 2001 From: Eliran Date: Wed, 9 Sep 2026 11:53:01 +0300 Subject: [PATCH 2/3] fix(magiclink): keep SignUpRequest binary compatible Moved the new phone field to the end of SignUpRequest and re-added the original five-argument constructor as an overload delegating with a null phone. Inserting phone mid-list changed Lombok's generated constructor arity and parameter order, breaking external callers at compile time and precompiled ones with NoSuchMethodError. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tygd97fhsqkojmDTnr1aVj --- .../com/descope/model/magiclink/request/SignUpRequest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java b/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java index c11f78f2..7f72a6d1 100644 --- a/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java +++ b/src/main/java/com/descope/model/magiclink/request/SignUpRequest.java @@ -14,11 +14,16 @@ @AllArgsConstructor public class SignUpRequest { private String email; - private String phone; private String loginId; private User user; @JsonProperty("URI") private String uri; private SignUpOptions loginOptions; + private String phone; + + public SignUpRequest(String email, String loginId, User user, String uri, + SignUpOptions loginOptions) { + this(email, loginId, user, uri, loginOptions, null); + } } From b7d137041925dfcf2ef3def70d6efb17a491d571 Mon Sep 17 00:00:00 2001 From: Eliran Date: Thu, 10 Sep 2026 14:24:45 +0300 Subject: [PATCH 3/3] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e07701fd..7ab0b641 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,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 `WithPhone` 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. @@ -224,6 +223,7 @@ try { To deliver the enchanted link by SMS, use the `WithPhone` variants, which take a phone number as the login ID and return a `PhoneEnchantedLinkResponse` carrying `maskedPhone`. +The SMS carries only the correct link, so there is nothing for the user to choose. ```java PhoneEnchantedLinkResponse res = null;