From b6769bc832cddf1f4dc7f142b0529d9f7048de23 Mon Sep 17 00:00:00 2001 From: sadilchamishka Date: Fri, 2 Oct 2026 11:41:48 +0530 Subject: [PATCH 1/3] Use the email verification recovery scenario for admin initiated email verification --- .../org.wso2.carbon.identity.recovery/pom.xml | 6 ++ .../recovery/IdentityRecoveryConstants.java | 6 ++ .../handler/UserEmailVerificationHandler.java | 13 ++++- .../IdentityRecoveryServiceComponent.java | 17 ++++++ .../IdentityRecoveryServiceDataHolder.java | 22 ++++++++ .../recovery/store/JDBCRecoveryDataStore.java | 10 ++++ .../carbon/identity/recovery/util/Utils.java | 46 ++++++++++++++++ .../store/JDBCRecoveryDataStoreTest.java | 55 +++++++++++++++++++ pom.xml | 5 ++ 9 files changed, 178 insertions(+), 2 deletions(-) diff --git a/components/org.wso2.carbon.identity.recovery/pom.xml b/components/org.wso2.carbon.identity.recovery/pom.xml index 83b1b35835..485f436de0 100644 --- a/components/org.wso2.carbon.identity.recovery/pom.xml +++ b/components/org.wso2.carbon.identity.recovery/pom.xml @@ -60,6 +60,10 @@ org.wso2.carbon.identity.framework org.wso2.carbon.identity.configuration.mgt.core + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.compatibility.settings.core + org.json.wso2 json @@ -197,6 +201,8 @@ version="${carbon.identity.framework.imp.pkg.version.range}", org.wso2.carbon.identity.configuration.mgt.core.*; version="${carbon.identity.framework.imp.pkg.version.range}", + org.wso2.carbon.identity.compatibility.settings.core.*; + version="${carbon.identity.framework.imp.pkg.version.range}", org.wso2.carbon.identity.base; version="${carbon.identity.framework.imp.pkg.version.range}", org.wso2.carbon.identity.central.log.mgt.utils; version="${carbon.identity.framework.imp.pkg.version.range}", diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java index e5a0e7d2ca..4c566ec160 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java @@ -224,6 +224,12 @@ public class IdentityRecoveryConstants { public static final int RECOVERY_CONFIRMATION_CODE_DEFAULT_TOLERANCE = 0; public static final int ASK_PASSWORD_CODE_DEFAULT_TOLERANCE = 0; public static final int SELF_SIGN_UP_CODE_DEFAULT_TOLERANCE = 0; + + // Compatibility setting that keeps admin-initiated email verification codes on the legacy + // SELF_SIGN_UP recovery scenario. See UserEmailVerificationHandler. + public static final String USER_ONBOARDING_COMPATIBILITY_SETTING_GROUP = "userOnboarding"; + public static final String ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO = + "enableLegacyEmailVerificationScenario"; public static final String EMAIL_TEMPLATE_PATH = "/identity/email"; // Workflow constants. diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java index 41a3338a92..9e28b9ec75 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java @@ -215,8 +215,17 @@ public void handleEvent(Event event) throws IdentityEventException { IdentityRecoveryConstants.PENDING_EMAIL_VERIFICATION, userStoreManager, user); } String notificationType = IdentityRecoveryConstants.NOTIFICATION_TYPE_EMAIL_CONFIRM; - RecoveryScenarios recoveryScenario = RecoveryScenarios.SELF_SIGN_UP; - RecoverySteps recoveryStep = RecoverySteps.CONFIRM_SIGN_UP; + /* + An administratively created user pending email verification is not a self sign-up, but the code + used to be stored as one, which made it expire on the self registration dial. Organizations that + have not moved off that behaviour keep it via the compatibility setting. + */ + boolean isLegacyScenario = + Utils.isLegacyEmailVerificationScenarioEnabled(user.getTenantDomain()); + RecoveryScenarios recoveryScenario = isLegacyScenario ? RecoveryScenarios.SELF_SIGN_UP + : RecoveryScenarios.EMAIL_VERIFICATION; + RecoverySteps recoveryStep = isLegacyScenario ? RecoverySteps.CONFIRM_SIGN_UP + : RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION; try { boolean isSendEmailOTPEnabled = Boolean.parseBoolean(getRecoveryConfigs(EMAIL_VERIFICATION_SEND_OTP, diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java index 2d7324cf88..a48ecc147d 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java @@ -80,6 +80,7 @@ import org.wso2.carbon.identity.recovery.username.NotificationUsernameRecoveryManager; import org.wso2.carbon.identity.user.functionality.mgt.UserFunctionalityManager; import org.wso2.carbon.identity.user.profile.mgt.association.federation.FederatedAssociationManager; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.workflow.mgt.WorkflowManagementService; import org.wso2.carbon.stratos.common.listeners.TenantMgtListener; import org.wso2.carbon.user.core.service.RealmService; @@ -539,4 +540,20 @@ protected void unsetWorkflowService(WorkflowManagementService workflowManagement IdentityRecoveryServiceDataHolder.getInstance().setWorkflowManagementService(null); } + + @Reference( + name = "compatibility.settings.service", + service = CompatibilitySettingsService.class, + cardinality = ReferenceCardinality.OPTIONAL, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetCompatibilitySettingsService") + protected void setCompatibilitySettingsService(CompatibilitySettingsService compatibilitySettingsService) { + + IdentityRecoveryServiceDataHolder.getInstance().setCompatibilitySettingsService(compatibilitySettingsService); + } + + protected void unsetCompatibilitySettingsService(CompatibilitySettingsService compatibilitySettingsService) { + + IdentityRecoveryServiceDataHolder.getInstance().setCompatibilitySettingsService(null); + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java index dd9795f728..5d6523904d 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java @@ -20,6 +20,7 @@ import org.wso2.carbon.consent.mgt.core.ConsentManager; import org.wso2.carbon.identity.application.mgt.ApplicationManagementService; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerManager; import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; import org.wso2.carbon.identity.configuration.mgt.core.ConfigurationManager; @@ -65,6 +66,7 @@ public class IdentityRecoveryServiceDataHolder { private ApplicationManagementService applicationManagementService; private static Map userOperationEventListeners = new TreeMap<>(); private WorkflowManagementService workflowService; + private CompatibilitySettingsService compatibilitySettingsService; public static IdentityRecoveryServiceDataHolder getInstance() { @@ -367,4 +369,24 @@ public WorkflowManagementService getWorkflowManagementService() { return this.workflowService; } + + /** + * Set CompatibilitySettingsService OSGi service. + * + * @param compatibilitySettingsService Compatibility Settings Service. + */ + public void setCompatibilitySettingsService(CompatibilitySettingsService compatibilitySettingsService) { + + this.compatibilitySettingsService = compatibilitySettingsService; + } + + /** + * Get CompatibilitySettingsService OSGi service. + * + * @return Compatibility Settings Service. + */ + public CompatibilitySettingsService getCompatibilitySettingsService() { + + return this.compatibilitySettingsService; + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java index 32e5717459..a06c4ba2f1 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java @@ -986,6 +986,16 @@ private boolean isCodeExpired(String tenantDomain, Enum recoveryScenario, Enum r notificationExpiryTimeInMinutes = Integer.parseInt( Utils.getRecoveryConfigs(IdentityRecoveryConstants.ConnectorConfig.EXPIRY_TIME, tenantDomain)); } + } else if (RecoveryScenarios.EMAIL_VERIFICATION.equals(recoveryScenario) || + (RecoveryScenarios.EMAIL_VERIFICATION_OTP.equals(recoveryScenario) + && !Utils.isLegacyEmailVerificationScenarioEnabled(tenantDomain))) { + /* + Codes issued for an administratively created user pending email verification. EMAIL_VERIFICATION_OTP + predates this branch and fell through to the generic recovery expiry, so it only moves to the dedicated + dial for organizations that have left the legacy scenario behind. + */ + notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.EMAIL_VERIFICATION_EXPIRY_TIME, tenantDomain)); } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.equals(recoveryScenario) || RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.equals(recoveryScenario)) { notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java index aebd9dce51..e320b0de08 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java @@ -42,6 +42,10 @@ import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; import org.wso2.carbon.identity.claim.metadata.mgt.util.ClaimConstants; +import org.wso2.carbon.identity.compatibility.settings.core.exception.CompatibilitySettingException; +import org.wso2.carbon.identity.compatibility.settings.core.model.CompatibilitySetting; +import org.wso2.carbon.identity.compatibility.settings.core.model.CompatibilitySettingGroup; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.event.IdentityEventConstants; @@ -1759,6 +1763,48 @@ private static int getSelfRegistrationCodeToleranceInMinutes(String tenantDomain } } + /** + * Check whether admin-initiated email verification codes should keep using the legacy + * {@link org.wso2.carbon.identity.recovery.RecoveryScenarios#SELF_SIGN_UP} recovery scenario. + *

+ * Historically a user created with the {@code verifyEmail} claim was recorded as a self sign-up, so its + * confirmation code expired on the self registration dial. The behaviour is retained for organizations that + * have not moved off it, and is governed by the {@code userOnboarding.enableLegacyEmailVerificationScenario} + * compatibility setting. Any failure to resolve the setting keeps the legacy behaviour, so a missing or + * unreachable service never changes how existing codes are issued or validated. + * + * @param tenantDomain Tenant domain. + * @return {@code true} if the legacy scenario should be used. + */ + public static boolean isLegacyEmailVerificationScenarioEnabled(String tenantDomain) { + + CompatibilitySettingsService compatibilitySettingsService = + IdentityRecoveryServiceDataHolder.getInstance().getCompatibilitySettingsService(); + if (compatibilitySettingsService == null) { + log.debug("Compatibility settings service is not available. Using the legacy email verification " + + "recovery scenario."); + return true; + } + try { + CompatibilitySetting setting = compatibilitySettingsService.getCompatibilitySettingsByGroupAndSetting( + tenantDomain, IdentityRecoveryConstants.USER_ONBOARDING_COMPATIBILITY_SETTING_GROUP, + IdentityRecoveryConstants.ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO); + CompatibilitySettingGroup group = setting == null ? null : setting.getCompatibilitySetting( + IdentityRecoveryConstants.USER_ONBOARDING_COMPATIBILITY_SETTING_GROUP); + String value = group == null ? null : group.getSettingValue( + IdentityRecoveryConstants.ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO); + if (StringUtils.isBlank(value)) { + return true; + } + return Boolean.parseBoolean(value); + } catch (CompatibilitySettingException e) { + log.error("Error while reading the compatibility setting: " + + IdentityRecoveryConstants.ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO + " for tenant: " + + tenantDomain + ". Using the legacy email verification recovery scenario.", e); + return true; + } + } + /** * Retrieves the ask password confirmation code tolerance period in minutes. * diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java index 705261f0c4..395b14ee70 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java @@ -252,6 +252,58 @@ public void testLoadExpiredCode() throws Exception { } } + @DataProvider(name = "emailVerificationCodeExpiry") + private Object[][] emailVerificationCodeExpiry() { + + // Scenario, legacy compatibility setting, whether the code is expected to still be valid. + return new Object[][] { + { RecoveryScenarios.EMAIL_VERIFICATION, false, true }, + { RecoveryScenarios.EMAIL_VERIFICATION, true, true }, + { RecoveryScenarios.EMAIL_VERIFICATION_OTP, false, true }, + { RecoveryScenarios.EMAIL_VERIFICATION_OTP, true, false } + }; + } + + /** + * Codes issued for an administratively created user pending email verification must expire on + * EmailVerification.ExpiryTime (stubbed at 20 minutes), not on the generic Recovery.ExpiryTime + * (stubbed at 10 minutes). The code under test is 11 minutes old, so the two dials disagree. + * EMAIL_VERIFICATION_OTP only moves to the dedicated dial once the organization has left the + * legacy scenario behind. + */ + @Test(dataProvider = "emailVerificationCodeExpiry") + public void testEmailVerificationCodeExpiry(RecoveryScenarios recoveryScenario, boolean isLegacyScenario, + boolean isExpectedValid) throws Exception { + + User user = createSampleUser(); + + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); + when(mockResultSet.getString("REMAINING_SETS")).thenReturn(null); + when(mockResultSet.getTimestamp(eq("TIME_CREATED"), any(Calendar.class))) + .thenReturn(new Timestamp(System.currentTimeMillis() - 660000)); + + mockExpiryTimes(); + mockUtilsErrors(); + mockedUtils.when(() -> Utils.isLegacyEmailVerificationScenarioEnabled(TEST_TENANT_DOMAIN)) + .thenReturn(isLegacyScenario); + + if (isExpectedValid) { + UserRecoveryData result = userRecoveryDataStore.load(user, recoveryScenario, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION, TEST_SECRET_CODE); + assertNotNull(result); + assertEquals(result.getRecoveryScenario(), recoveryScenario); + } else { + try { + userRecoveryDataStore.load(user, recoveryScenario, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION, TEST_SECRET_CODE); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + } + @DataProvider(name = "askPasswordUserOnboardScenarios") private Object[][] askPasswordUserOnboardScenarios() { @@ -300,6 +352,9 @@ private void mockExpiryTimes() { mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants .ConnectorConfig.EXPIRY_TIME, TEST_TENANT_DOMAIN)) .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.EMAIL_VERIFICATION_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("20"); mockedIdentityUtil.when(() -> IdentityUtil.getProperty(IdentityRecoveryConstants .ConnectorConfig.TENANT_ADMIN_ASK_PASSWORD_EXPIRY_TIME)) .thenReturn("10"); diff --git a/pom.xml b/pom.xml index 3f8473279c..9f77426983 100644 --- a/pom.xml +++ b/pom.xml @@ -301,6 +301,11 @@ org.wso2.carbon.identity.configuration.mgt.core ${carbon.identity.framework.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.compatibility.settings.core + ${carbon.identity.framework.version} + org.wso2.carbon.identity.framework org.wso2.carbon.identity.application.common From 9e969ddc3833f67d1aca33bb7370bd2253808961 Mon Sep 17 00:00:00 2001 From: sadilchamishka Date: Fri, 2 Oct 2026 15:08:00 +0530 Subject: [PATCH 2/3] Resolve the email verification OTP expiry independently of the compatibility setting --- .../recovery/store/JDBCRecoveryDataStore.java | 8 ++--- .../store/JDBCRecoveryDataStoreTest.java | 35 +++++++------------ 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java index a06c4ba2f1..f379be11c4 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java @@ -987,12 +987,10 @@ private boolean isCodeExpired(String tenantDomain, Enum recoveryScenario, Enum r Utils.getRecoveryConfigs(IdentityRecoveryConstants.ConnectorConfig.EXPIRY_TIME, tenantDomain)); } } else if (RecoveryScenarios.EMAIL_VERIFICATION.equals(recoveryScenario) || - (RecoveryScenarios.EMAIL_VERIFICATION_OTP.equals(recoveryScenario) - && !Utils.isLegacyEmailVerificationScenarioEnabled(tenantDomain))) { + RecoveryScenarios.EMAIL_VERIFICATION_OTP.equals(recoveryScenario)) { /* - Codes issued for an administratively created user pending email verification. EMAIL_VERIFICATION_OTP - predates this branch and fell through to the generic recovery expiry, so it only moves to the dedicated - dial for organizations that have left the legacy scenario behind. + Codes issued for an administratively created user pending email verification. EMAIL_VERIFICATION_OTP had + no branch here and fell through to the generic recovery expiry, which is the password recovery dial. */ notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants .ConnectorConfig.EMAIL_VERIFICATION_EXPIRY_TIME, tenantDomain)); diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java index 395b14ee70..aaaf65cf71 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java @@ -255,12 +255,13 @@ public void testLoadExpiredCode() throws Exception { @DataProvider(name = "emailVerificationCodeExpiry") private Object[][] emailVerificationCodeExpiry() { - // Scenario, legacy compatibility setting, whether the code is expected to still be valid. + // Scenario, legacy compatibility setting. The expiry is a property of the scenario alone, so the + // compatibility setting -- which only decides which scenario gets issued -- must not affect it. return new Object[][] { - { RecoveryScenarios.EMAIL_VERIFICATION, false, true }, - { RecoveryScenarios.EMAIL_VERIFICATION, true, true }, - { RecoveryScenarios.EMAIL_VERIFICATION_OTP, false, true }, - { RecoveryScenarios.EMAIL_VERIFICATION_OTP, true, false } + { RecoveryScenarios.EMAIL_VERIFICATION, false }, + { RecoveryScenarios.EMAIL_VERIFICATION, true }, + { RecoveryScenarios.EMAIL_VERIFICATION_OTP, false }, + { RecoveryScenarios.EMAIL_VERIFICATION_OTP, true } }; } @@ -268,12 +269,10 @@ private Object[][] emailVerificationCodeExpiry() { * Codes issued for an administratively created user pending email verification must expire on * EmailVerification.ExpiryTime (stubbed at 20 minutes), not on the generic Recovery.ExpiryTime * (stubbed at 10 minutes). The code under test is 11 minutes old, so the two dials disagree. - * EMAIL_VERIFICATION_OTP only moves to the dedicated dial once the organization has left the - * legacy scenario behind. */ @Test(dataProvider = "emailVerificationCodeExpiry") - public void testEmailVerificationCodeExpiry(RecoveryScenarios recoveryScenario, boolean isLegacyScenario, - boolean isExpectedValid) throws Exception { + public void testEmailVerificationCodeExpiry(RecoveryScenarios recoveryScenario, boolean isLegacyScenario) + throws Exception { User user = createSampleUser(); @@ -288,20 +287,10 @@ public void testEmailVerificationCodeExpiry(RecoveryScenarios recoveryScenario, mockedUtils.when(() -> Utils.isLegacyEmailVerificationScenarioEnabled(TEST_TENANT_DOMAIN)) .thenReturn(isLegacyScenario); - if (isExpectedValid) { - UserRecoveryData result = userRecoveryDataStore.load(user, recoveryScenario, - RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION, TEST_SECRET_CODE); - assertNotNull(result); - assertEquals(result.getRecoveryScenario(), recoveryScenario); - } else { - try { - userRecoveryDataStore.load(user, recoveryScenario, - RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION, TEST_SECRET_CODE); - fail(); - } catch (Exception e) { - assertTrue(e instanceof IdentityRecoveryClientException); - } - } + UserRecoveryData result = userRecoveryDataStore.load(user, recoveryScenario, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION, TEST_SECRET_CODE); + assertNotNull(result); + assertEquals(result.getRecoveryScenario(), recoveryScenario); } @DataProvider(name = "askPasswordUserOnboardScenarios") From 1dc4fc71438de426624edd0485ab334a5fbfe85c Mon Sep 17 00:00:00 2001 From: sadilchamishka Date: Fri, 2 Oct 2026 15:19:11 +0530 Subject: [PATCH 3/3] Add tests for the email verification scenario selection and compatibility setting fallbacks --- .../IdentityRecoveryServiceComponent.java | 2 +- .../IdentityRecoveryServiceDataHolder.java | 2 +- .../recovery/store/JDBCRecoveryDataStore.java | 4 - .../UserEmailVerificationHandlerTest.java | 55 ++++++++++++ .../identity/recovery/util/UtilsTest.java | 89 +++++++++++++++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java index a48ecc147d..d9f49298c8 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceComponent.java @@ -32,6 +32,7 @@ import org.wso2.carbon.identity.application.mgt.ApplicationManagementService; import org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerManager; import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.configuration.mgt.core.ConfigurationManager; import org.wso2.carbon.identity.consent.mgt.services.ConsentUtilityService; import org.wso2.carbon.identity.core.persistence.registry.RegistryResourceMgtService; @@ -80,7 +81,6 @@ import org.wso2.carbon.identity.recovery.username.NotificationUsernameRecoveryManager; import org.wso2.carbon.identity.user.functionality.mgt.UserFunctionalityManager; import org.wso2.carbon.identity.user.profile.mgt.association.federation.FederatedAssociationManager; -import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.workflow.mgt.WorkflowManagementService; import org.wso2.carbon.stratos.common.listeners.TenantMgtListener; import org.wso2.carbon.user.core.service.RealmService; diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java index 5d6523904d..a1f6ad68e2 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/IdentityRecoveryServiceDataHolder.java @@ -20,9 +20,9 @@ import org.wso2.carbon.consent.mgt.core.ConsentManager; import org.wso2.carbon.identity.application.mgt.ApplicationManagementService; -import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerManager; import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.configuration.mgt.core.ConfigurationManager; import org.wso2.carbon.identity.consent.mgt.services.ConsentUtilityService; import org.wso2.carbon.identity.core.persistence.registry.RegistryResourceMgtService; diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java index f379be11c4..431247aa39 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java @@ -988,10 +988,6 @@ private boolean isCodeExpired(String tenantDomain, Enum recoveryScenario, Enum r } } else if (RecoveryScenarios.EMAIL_VERIFICATION.equals(recoveryScenario) || RecoveryScenarios.EMAIL_VERIFICATION_OTP.equals(recoveryScenario)) { - /* - Codes issued for an administratively created user pending email verification. EMAIL_VERIFICATION_OTP had - no branch here and fell through to the generic recovery expiry, which is the password recovery dial. - */ notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants .ConnectorConfig.EMAIL_VERIFICATION_EXPIRY_TIME, tenantDomain)); } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.equals(recoveryScenario) || diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java index fb8b1d1d96..8697adc12f 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java @@ -675,6 +675,61 @@ public void testHandleEventPostAddUserVerifyEmailClaim() throws IdentityEventExc any())); } + @DataProvider(name = "emailVerificationScenarioData") + public Object[][] emailVerificationScenarioData() { + + // Legacy compatibility setting, send-OTP enabled, expected scenario, expected step. + return new Object[][]{ + {true, false, RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP}, + {false, false, RecoveryScenarios.EMAIL_VERIFICATION, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION}, + {true, true, RecoveryScenarios.EMAIL_VERIFICATION_OTP, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION}, + {false, true, RecoveryScenarios.EMAIL_VERIFICATION_OTP, + RecoverySteps.CONFIRM_PENDING_EMAIL_VERIFICATION} + }; + } + + /** + * An administratively created user pending email verification is not a self sign-up. The compatibility + * setting decides whether the code is still recorded as one. The OTP mode overrides both regardless, + * which is why the setting is varied across both of its values here. + */ + @Test(dataProvider = "emailVerificationScenarioData") + public void testHandleEventPostAddUserVerifyEmailClaimRecoveryScenario(boolean isLegacyScenario, + boolean isSendOTPEnabled, + RecoveryScenarios expectedScenario, + RecoverySteps expectedStep) + throws IdentityEventException, IdentityRecoveryException { + + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.EMAIL_ACCOUNT_LOCK_ON_CREATION, true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig + .EMAIL_VERIFICATION_NOTIFICATION_INTERNALLY_MANAGE, true); + mockedUtils.when(() -> Utils.getRecoveryConfigs( + IdentityRecoveryConstants.ConnectorConfig.EMAIL_VERIFICATION_SEND_OTP, TEST_TENANT_DOMAIN)) + .thenReturn(String.valueOf(isSendOTPEnabled)); + mockedUtils.when(() -> Utils.isLegacyEmailVerificationScenarioEnabled(TEST_TENANT_DOMAIN)) + .thenReturn(isLegacyScenario); + mockedUtils.when(() -> Utils.isAccountStateClaimExisting(anyString())).thenReturn(true); + + Claim claim = new Claim(); + claim.setClaimUri(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); + claim.setValue(Boolean.TRUE.toString()); + mockedUtils.when(Utils::getEmailVerifyTemporaryClaim).thenReturn(claim); + + Event event = createEvent(IdentityEventConstants.Event.POST_ADD_USER, IdentityRecoveryConstants.TRUE, + null, null, null); + userEmailVerificationHandler.handleEvent(event); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + + Assert.assertEquals(capturedRecoveryData.getRecoveryScenario(), expectedScenario); + Assert.assertEquals(capturedRecoveryData.getRecoveryStep(), expectedStep); + } + @Test public void testGetPriority() { diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java index bc0b6ad3c9..67771e010e 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java @@ -57,6 +57,10 @@ import org.wso2.carbon.identity.recovery.RecoverySteps; import org.wso2.carbon.identity.recovery.exception.SelfRegistrationClientException; import org.wso2.carbon.identity.recovery.exception.SelfRegistrationException; +import org.wso2.carbon.identity.compatibility.settings.core.exception.CompatibilitySettingException; +import org.wso2.carbon.identity.compatibility.settings.core.model.CompatibilitySetting; +import org.wso2.carbon.identity.compatibility.settings.core.model.CompatibilitySettingGroup; +import org.wso2.carbon.identity.compatibility.settings.core.service.CompatibilitySettingsService; import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; import org.wso2.carbon.identity.recovery.store.JDBCRecoveryDataStore; import org.wso2.carbon.identity.recovery.store.UserRecoveryDataStore; @@ -1745,6 +1749,91 @@ private void mockGetRecoveryConfig(String key, String value) throws IdentityGove .thenReturn(properties); } + @DataProvider(name = "legacyEmailVerificationScenarioData") + public Object[][] legacyEmailVerificationScenarioData() { + + // Stored setting value, expected result. + return new Object[][]{ + {"false", false}, + {"true", true}, + {"", true}, + {null, true} + }; + } + + /** + * The stored value decides the outcome, but anything unreadable must fall back to the legacy + * scenario so that an organization is never silently moved off the behaviour it has today. + */ + @Test(dataProvider = "legacyEmailVerificationScenarioData") + public void testIsLegacyEmailVerificationScenarioEnabled(String settingValue, boolean expected) + throws Exception { + + CompatibilitySettingsService compatibilitySettingsService = mock(CompatibilitySettingsService.class); + when(identityRecoveryServiceDataHolder.getCompatibilitySettingsService()) + .thenReturn(compatibilitySettingsService); + + CompatibilitySettingGroup group = new CompatibilitySettingGroup(); + group.setSettingGroup(IdentityRecoveryConstants.USER_ONBOARDING_COMPATIBILITY_SETTING_GROUP); + if (settingValue != null) { + group.addSetting(IdentityRecoveryConstants.ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO, settingValue); + } + CompatibilitySetting setting = new CompatibilitySetting(); + setting.addCompatibilitySetting(group); + + when(compatibilitySettingsService.getCompatibilitySettingsByGroupAndSetting(TENANT_DOMAIN, + IdentityRecoveryConstants.USER_ONBOARDING_COMPATIBILITY_SETTING_GROUP, + IdentityRecoveryConstants.ENABLE_LEGACY_EMAIL_VERIFICATION_SCENARIO)).thenReturn(setting); + + assertEquals(Utils.isLegacyEmailVerificationScenarioEnabled(TENANT_DOMAIN), expected); + } + + @Test + public void testIsLegacyEmailVerificationScenarioEnabledWhenServiceUnavailable() { + + when(identityRecoveryServiceDataHolder.getCompatibilitySettingsService()).thenReturn(null); + + assertTrue(Utils.isLegacyEmailVerificationScenarioEnabled(TENANT_DOMAIN)); + } + + @Test + public void testIsLegacyEmailVerificationScenarioEnabledWhenGroupMissing() throws Exception { + + CompatibilitySettingsService compatibilitySettingsService = mock(CompatibilitySettingsService.class); + when(identityRecoveryServiceDataHolder.getCompatibilitySettingsService()) + .thenReturn(compatibilitySettingsService); + + // An empty result stands for a setting group that is absent from the deployed metadata. + when(compatibilitySettingsService.getCompatibilitySettingsByGroupAndSetting(anyString(), anyString(), + anyString())).thenReturn(new CompatibilitySetting()); + + assertTrue(Utils.isLegacyEmailVerificationScenarioEnabled(TENANT_DOMAIN)); + } + + @Test + public void testIsLegacyEmailVerificationScenarioEnabledWhenNullSettingReturned() throws Exception { + + CompatibilitySettingsService compatibilitySettingsService = mock(CompatibilitySettingsService.class); + when(identityRecoveryServiceDataHolder.getCompatibilitySettingsService()) + .thenReturn(compatibilitySettingsService); + when(compatibilitySettingsService.getCompatibilitySettingsByGroupAndSetting(anyString(), anyString(), + anyString())).thenReturn(null); + + assertTrue(Utils.isLegacyEmailVerificationScenarioEnabled(TENANT_DOMAIN)); + } + + @Test + public void testIsLegacyEmailVerificationScenarioEnabledOnException() throws Exception { + + CompatibilitySettingsService compatibilitySettingsService = mock(CompatibilitySettingsService.class); + when(identityRecoveryServiceDataHolder.getCompatibilitySettingsService()) + .thenReturn(compatibilitySettingsService); + when(compatibilitySettingsService.getCompatibilitySettingsByGroupAndSetting(anyString(), anyString(), + anyString())).thenThrow(new CompatibilitySettingException("error", "error")); + + assertTrue(Utils.isLegacyEmailVerificationScenarioEnabled(TENANT_DOMAIN)); + } + private static String getUserStoreQualifiedUsername(String username, String userStoreDomainName) { return userStoreDomainName + UserCoreConstants.DOMAIN_SEPARATOR + username;