diff --git a/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java b/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java
index 74a13df189..8b81d58fd0 100644
--- a/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java
+++ b/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java
@@ -29,6 +29,7 @@
*/
package com.sun.identity.idm;
+import java.security.AccessController;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -40,6 +41,8 @@
import org.forgerock.i18n.LocalizedIllegalArgumentException;
import org.forgerock.openam.auditors.RepoAuditor;
import org.forgerock.openam.ldap.LDAPUtils;
+import org.forgerock.openam.session.SessionCache;
+import org.forgerock.openam.session.service.access.SessionQueryManager;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.RDN;
@@ -51,6 +54,7 @@
import com.sun.identity.common.CaseInsensitiveHashMap;
import com.sun.identity.common.CaseInsensitiveHashSet;
import com.sun.identity.idm.common.IdRepoUtils;
+import com.sun.identity.security.AdminTokenAction;
import com.sun.identity.setup.AMSetupServlet;
import com.sun.identity.shared.Constants;
import com.sun.identity.shared.debug.Debug;
@@ -459,6 +463,10 @@ public void setAttributes(Map attrMap) throws IdRepoException, SSOException
/**
* Changes password for the identity.
*
+ *
For a server-side user changing their own password, other stateful sessions are revoked while the current
+ * master session is preserved. Revocation failures are logged without failing the password change. This does not
+ * cover an administrator changing another user's password or Remote SDK requests handled directly by IdServices.
+ *
* @param oldPassword old password
* @param newPassword new password
* @throws IdRepoException If there are repository related error conditions.
@@ -471,6 +479,22 @@ public void changePassword(String oldPassword, String newPassword)
IdServices idServices = IdServicesFactory.getDataStoreServices();
idServices.changePassword(token, type, name, oldPassword,
newPassword, orgName, getDN());
+
+ try {
+ String callerId = token.getProperty(Constants.UNIVERSAL_IDENTIFIER);
+ if (IdType.USER.equals(type) && callerId != null
+ && DN.valueOf(callerId).equals(DN.valueOf(univIdWithoutDN))) {
+ String currentSessionId = token.getTokenID().toString();
+ if (token.isTokenRestricted()) {
+ currentSessionId = token.dereferenceRestrictedTokenID(
+ AccessController.doPrivileged(AdminTokenAction.getInstance()), currentSessionId);
+ }
+ new PasswordChangeSessionRevoker(InjectorHolder.getInstance(SessionQueryManager.class),
+ SessionCache.getInstance()).revoke(callerId, currentSessionId);
+ }
+ } catch (Exception e) {
+ debug.warning("Unable to revoke sessions after password change", e);
+ }
RepoAuditor auditor = newAuditor(token, orgName, univIdWithoutDN, null);
if(auditor != null) {
diff --git a/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java b/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java
new file mode 100644
index 0000000000..38644afb9e
--- /dev/null
+++ b/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java
@@ -0,0 +1,48 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.idm;
+
+import com.iplanet.dpro.session.Session;
+import com.iplanet.dpro.session.SessionException;
+import com.iplanet.dpro.session.SessionID;
+import com.sun.identity.shared.debug.Debug;
+import org.forgerock.openam.session.SessionCache;
+import org.forgerock.openam.session.service.access.SessionQueryManager;
+
+final class PasswordChangeSessionRevoker {
+ private static final Debug DEBUG = Debug.getInstance("amIdm");
+ private final SessionQueryManager query;
+ private final SessionCache cache;
+
+ PasswordChangeSessionRevoker(SessionQueryManager query, SessionCache cache) {
+ this.query = query;
+ this.cache = cache;
+ }
+
+ void revoke(String userId, String currentSessionId) throws SessionException {
+ for (String sessionId : query.getAllSessionsByUUID(userId).keySet()) {
+ if (sessionId.equals(currentSessionId)) {
+ continue;
+ }
+ try {
+ Session session = cache.getSession(new SessionID(sessionId), false, false);
+ session.destroySession(session);
+ } catch (Exception e) {
+ DEBUG.warning("Unable to revoke a session after password change", e);
+ }
+ }
+ }
+}
diff --git a/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java b/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java
new file mode 100644
index 0000000000..996dc13391
--- /dev/null
+++ b/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java
@@ -0,0 +1,224 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.idm;
+
+import static org.mockito.Mockito.*;
+import static org.testng.Assert.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.iplanet.am.util.SystemProperties;
+import com.iplanet.dpro.session.Session;
+import com.iplanet.dpro.session.SessionID;
+import com.iplanet.dpro.session.operations.ServerSessionOperationStrategy;
+import com.iplanet.dpro.session.operations.strategies.LocalOperations;
+import com.iplanet.dpro.session.service.InternalSession;
+import com.iplanet.dpro.session.service.InternalSessionEventBroker;
+import com.iplanet.dpro.session.service.SessionServerConfig;
+import com.iplanet.dpro.session.service.SessionState;
+import com.iplanet.dpro.session.share.SessionInfo;
+import com.iplanet.dpro.session.utils.SessionInfoFactory;
+import com.iplanet.sso.SSOException;
+import com.iplanet.sso.SSOToken;
+import com.iplanet.sso.SSOTokenID;
+import com.iplanet.sso.SSOTokenManager;
+import com.sun.identity.security.AdminTokenAction;
+import com.sun.identity.setup.AMSetupServlet;
+import com.sun.identity.shared.Constants;
+import com.sun.identity.shared.debug.Debug;
+import org.forgerock.guice.core.InjectorHolder;
+import org.forgerock.openam.session.SessionCache;
+import org.forgerock.openam.session.SessionCookies;
+import org.forgerock.openam.session.SessionCuller;
+import org.forgerock.openam.session.SessionPollerPool;
+import org.forgerock.openam.session.SessionServiceURLService;
+import org.forgerock.openam.session.authorisation.SessionChangeAuthorizer;
+import org.forgerock.openam.session.service.SessionAccessManager;
+import org.forgerock.openam.session.service.access.SessionQueryManager;
+import org.powermock.api.mockito.PowerMockito;
+import org.powermock.core.classloader.annotations.PowerMockIgnore;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.powermock.modules.testng.PowerMockTestCase;
+import org.powermock.reflect.Whitebox;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+@PrepareForTest({SystemProperties.class, InjectorHolder.class, IdServicesFactory.class,
+ AdminTokenAction.class, AMSetupServlet.class, SessionCache.class})
+@PowerMockIgnore({"jdk.internal.reflect.*", "jakarta.servlet.*"})
+public class AMIdentityPasswordChangeTest extends PowerMockTestCase {
+ private static final String USER_ID = "id=amadmin,ou=user,dc=config";
+
+ private final Map storedSessions = new HashMap<>();
+ private SessionCache cache;
+ private SessionAccessManager access;
+ private SSOToken caller;
+ private SSOToken appToken;
+ private IdServices idServices;
+ private SessionQueryManager query;
+ private AMIdentity identity;
+
+ @BeforeMethod
+ public void setup() throws Exception {
+ storedSessions.clear();
+ PowerMockito.mockStatic(SystemProperties.class, InjectorHolder.class, IdServicesFactory.class,
+ AdminTokenAction.class, AMSetupServlet.class);
+ when(SystemProperties.isServerMode()).thenReturn(true);
+ when(SystemProperties.getAsBoolean(Session.CACHED_BASE_POLLING_PROPERTY, false)).thenReturn(true);
+
+ SessionPollerPool poller = mock(SessionPollerPool.class);
+ when(poller.isPollingEnabled()).thenReturn(true);
+ when(poller.getCacheBasedPolling()).thenReturn(true);
+ cache = Whitebox.invokeConstructor(SessionCache.class, poller, mock(Debug.class));
+ PowerMockito.mockStatic(SessionCache.class);
+ when(SessionCache.getInstance()).thenReturn(cache);
+ // Culling is independent of revocation; avoid starting timer jobs in this test.
+ PowerMockito.whenNew(SessionCuller.class).withAnyArguments().thenReturn(mock(SessionCuller.class));
+
+ access = mock(SessionAccessManager.class);
+ when(access.getInternalSession(any(SessionID.class)))
+ .thenAnswer(invocation -> storedSessions.get(invocation.getArgument(0)));
+ doAnswer(invocation -> {
+ InternalSession session = invocation.getArgument(0);
+ storedSessions.remove(session.getID());
+ return null;
+ }).when(access).removeInternalSession(any(InternalSession.class));
+
+ SessionChangeAuthorizer authorizer = spy(Whitebox.invokeConstructor(SessionChangeAuthorizer.class,
+ mock(Debug.class), mock(SSOTokenManager.class)));
+ // Keep the real VALID-state check; delegation itself is outside this fixture.
+ doReturn(true).when(authorizer).hasTopLevelAdminRole(any(SessionID.class));
+ query = mock(SessionQueryManager.class);
+ LocalOperations local = Whitebox.invokeConstructor(LocalOperations.class, mock(Debug.class), access,
+ query, new SessionInfoFactory(), mock(SessionServerConfig.class),
+ mock(InternalSessionEventBroker.class), authorizer);
+ ServerSessionOperationStrategy strategy = mock(ServerSessionOperationStrategy.class);
+ when(strategy.getOperation(any(SessionID.class))).thenReturn(local);
+ when(InjectorHolder.getInstance(ServerSessionOperationStrategy.class)).thenReturn(strategy);
+ when(InjectorHolder.getInstance(SessionCache.class)).thenReturn(cache);
+ when(InjectorHolder.getInstance(SessionCookies.class)).thenReturn(mock(SessionCookies.class));
+ when(InjectorHolder.getInstance(SessionServiceURLService.class))
+ .thenReturn(mock(SessionServiceURLService.class));
+ when(InjectorHolder.getInstance(SessionQueryManager.class)).thenReturn(query);
+
+ appToken = mock(SSOToken.class);
+ AdminTokenAction adminAction = mock(AdminTokenAction.class);
+ when(AdminTokenAction.getInstance()).thenReturn(adminAction);
+ when(adminAction.run()).thenReturn(appToken);
+
+ idServices = mock(IdServices.class);
+ when(IdServicesFactory.getDataStoreServices()).thenReturn(idServices);
+ caller = mock(SSOToken.class);
+ when(caller.getProperty(Constants.UNIVERSAL_IDENTIFIER)).thenReturn(USER_ID);
+ SSOTokenID callerId = mock(SSOTokenID.class);
+ when(callerId.toString()).thenReturn("current");
+ when(caller.getTokenID()).thenReturn(callerId);
+ identity = new AMIdentity(caller);
+
+ addStoredSession("current");
+ addStoredSession("other");
+ Map sessions = new HashMap<>();
+ sessions.put("current", 1L);
+ sessions.put("other", 1L);
+ when(query.getAllSessionsByUUID(USER_ID)).thenReturn(sessions);
+ }
+
+ @Test
+ public void revokesUncachedSessionWithoutResettingIdleTime() throws Exception {
+ assertFalse(cache.hasSession(new SessionID("other")));
+ InternalSession other = storedSessions.get(new SessionID("other"));
+
+ identity.changePassword("old", "new");
+
+ assertFalse(storedSessions.containsKey(new SessionID("other")));
+ assertTrue(storedSessions.containsKey(new SessionID("current")));
+ verify(idServices).changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null);
+ verify(other, never()).setLatestAccessTime();
+ }
+
+ @Test
+ public void keepsMasterSessionWhenCallerUsesRestrictedToken() throws Exception {
+ when(caller.getTokenID().toString()).thenReturn("restricted");
+ when(caller.isTokenRestricted()).thenReturn(true);
+ when(caller.dereferenceRestrictedTokenID(appToken, "restricted")).thenReturn("current");
+ // Isolate the ID mismatch from the separate cache-miss bug.
+ cache.getSession(new SessionID("current"), false, false);
+ cache.getSession(new SessionID("other"), false, false);
+
+ identity.changePassword("old", "new");
+
+ assertTrue(storedSessions.containsKey(new SessionID("current")));
+ assertFalse(storedSessions.containsKey(new SessionID("other")));
+ }
+
+ @Test
+ public void doesNotRevokeSessionsWhenRestrictedTokenCannotBeResolved() throws Exception {
+ when(caller.isTokenRestricted()).thenReturn(true);
+ when(caller.dereferenceRestrictedTokenID(appToken, "current"))
+ .thenThrow(new SSOException("unavailable"));
+ cache.getSession(new SessionID("current"), false, false);
+ cache.getSession(new SessionID("other"), false, false);
+
+ identity.changePassword("old", "new");
+
+ assertEquals(storedSessions.size(), 2);
+ verify(idServices).changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null);
+ verifyZeroInteractions(query);
+ }
+
+ @Test
+ public void doesNotRevokeSessionsWhenChangingAnotherUsersPassword() throws Exception {
+ AMIdentity otherIdentity = new AMIdentity(caller, "id=another,ou=user,dc=config");
+
+ otherIdentity.changePassword("old", "new");
+
+ assertEquals(storedSessions.size(), 2);
+ verify(idServices).changePassword(caller, IdType.USER, "another", "old", "new", "dc=config", null);
+ verifyZeroInteractions(query);
+ }
+
+ @Test(expectedExceptions = SSOException.class)
+ public void doesNotRevokeSessionsWhenPasswordChangeFails() throws Exception {
+ doThrow(new SSOException("password change failed")).when(idServices)
+ .changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null);
+
+ try {
+ identity.changePassword("old", "new");
+ } finally {
+ assertEquals(storedSessions.size(), 2);
+ verifyZeroInteractions(query);
+ }
+ }
+
+ private void addStoredSession(String id) throws Exception {
+ SessionID sessionId = new SessionID(id);
+ InternalSession internal = mock(InternalSession.class);
+ when(internal.getID()).thenReturn(sessionId);
+ when(internal.getSessionID()).thenReturn(sessionId);
+ when(internal.getState()).thenReturn(SessionState.VALID);
+ SessionInfo info = new SessionInfo();
+ info.setSessionType("user");
+ info.setState("valid");
+ info.setClientID(USER_ID);
+ info.setClientDomain("dc=config");
+ info.setMaxTime(60);
+ info.setMaxIdle(30);
+ info.setMaxCaching(3);
+ when(internal.toSessionInfo()).thenReturn(info);
+ storedSessions.put(sessionId, internal);
+ }
+}
diff --git a/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java b/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java
new file mode 100644
index 0000000000..bc57e29695
--- /dev/null
+++ b/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java
@@ -0,0 +1,89 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.idm;
+
+import static org.mockito.Mockito.*;
+import static org.testng.Assert.assertEquals;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.iplanet.dpro.session.Session;
+import com.iplanet.dpro.session.SessionException;
+import com.iplanet.dpro.session.SessionID;
+import org.forgerock.openam.session.SessionCache;
+import org.forgerock.openam.session.service.access.SessionQueryManager;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.Test;
+
+public class PasswordChangeSessionRevokerTest {
+ @Test
+ public void revokesOtherSessionsAndKeepsCurrentSession() throws Exception {
+ SessionQueryManager query = mock(SessionQueryManager.class);
+ SessionCache cache = mock(SessionCache.class);
+ Session other = mock(Session.class);
+ Map sessions = new HashMap<>();
+ sessions.put("current", 1L);
+ sessions.put("other", 1L);
+ when(query.getAllSessionsByUUID("user")).thenReturn(sessions);
+ when(cache.getSession(any(), eq(false), eq(false))).thenReturn(other);
+
+ new PasswordChangeSessionRevoker(query, cache).revoke("user", "current");
+
+ ArgumentCaptor requestedId = ArgumentCaptor.forClass(SessionID.class);
+ verify(cache).getSession(requestedId.capture(), eq(false), eq(false));
+ assertEquals(requestedId.getValue().toString(), "other");
+ verify(other).destroySession(other);
+ }
+
+ @Test
+ public void continuesAfterOneSessionCannotBeDestroyed() throws Exception {
+ SessionQueryManager query = mock(SessionQueryManager.class);
+ SessionCache cache = mock(SessionCache.class);
+ Session other = mock(Session.class);
+ Map sessions = new HashMap<>();
+ sessions.put("one", 1L);
+ sessions.put("two", 1L);
+ when(query.getAllSessionsByUUID("user")).thenReturn(sessions);
+ when(cache.getSession(any(), eq(false), eq(false)))
+ .thenThrow(new SessionException("failed")).thenReturn(other);
+
+ new PasswordChangeSessionRevoker(query, cache).revoke("user", "current");
+
+ verify(other).destroySession(other);
+ }
+
+ @Test
+ public void continuesAfterDestroySessionFails() throws Exception {
+ SessionQueryManager query = mock(SessionQueryManager.class);
+ SessionCache cache = mock(SessionCache.class);
+ Session first = mock(Session.class);
+ Session second = mock(Session.class);
+ Map sessions = new LinkedHashMap<>();
+ sessions.put("first", 1L);
+ sessions.put("second", 1L);
+ when(query.getAllSessionsByUUID("user")).thenReturn(sessions);
+ when(cache.getSession(new SessionID("first"), false, false)).thenReturn(first);
+ when(cache.getSession(new SessionID("second"), false, false)).thenReturn(second);
+ doThrow(new SessionException("failed")).when(first).destroySession(first);
+
+ new PasswordChangeSessionRevoker(query, cache).revoke("user", "current");
+
+ verify(first).destroySession(first);
+ verify(second).destroySession(second);
+ }
+}