diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java
index 59208d01ad..91349d8ed1 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java
@@ -38,6 +38,8 @@
import org.jivesoftware.openfire.session.*;
import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
import org.jivesoftware.openfire.spi.ConnectionType;
+import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler;
+import org.jivesoftware.openfire.net.Bind2Request;
import org.jivesoftware.openfire.streammanagement.TerminationDelegate;
import org.jivesoftware.util.*;
import org.jivesoftware.util.cache.*;
@@ -270,6 +272,7 @@ public class SessionManager extends BasicModule implements ClusterEventListener
private RoutingTable routingTable;
private StreamIDFactory streamIDFactory;
+ private Bind2StreamManagementHandler bind2StreamManagementHandler;
/**
* Returns the instance of SessionManagerImpl being used by the XMPPServer.
@@ -1897,10 +1900,15 @@ private Message createServerMessage(String subject, String body) {
}
@Override
- public void start() throws IllegalStateException {
+ public synchronized void start() throws IllegalStateException {
super.start();
localSessionManager.start();
+ // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing.
+ final Bind2StreamManagementHandler localHandler = new Bind2StreamManagementHandler();
+ Bind2Request.registerElementHandler(localHandler);
+ bind2StreamManagementHandler = localHandler; // Only dereference any previous handler after registration succeeds, otherwise that previous handler can never be removed again.
+
// Run through the server sessions every 10% of the time of the maximum time that a session is allowed to be
// detached, or every 3 minutes if the max time is outside the default boundaries.
// TODO Reschedule task if getSessionDetachTime value changes.
@@ -1915,8 +1923,14 @@ public void start() throws IllegalStateException {
}
@Override
- public void stop() {
+ public synchronized void stop() {
Log.debug("SessionManager: Stopping server");
+
+ if (bind2StreamManagementHandler != null) {
+ Bind2Request.unregisterElementHandler(bind2StreamManagementHandler);
+ bind2StreamManagementHandler = null;
+ }
+
// Stop threads that are sending packets to remote servers
OutgoingSessionPromise.getInstance().shutdown();
if (JiveGlobals.getBooleanProperty("shutdownMessage.enabled")) {
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java
new file mode 100644
index 0000000000..b55a83b2e2
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jivesoftware.openfire.handler;
+
+import org.dom4j.Element;
+import org.jivesoftware.openfire.net.Bind2InlineHandler;
+import org.jivesoftware.openfire.session.LocalClientSession;
+import org.jivesoftware.openfire.streammanagement.StreamManagementException;
+import org.jivesoftware.openfire.streammanagement.StreamManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xmpp.packet.PacketError;
+
+/**
+ * A {@link Bind2InlineHandler} that processes XEP-0198 Stream Management {@code } elements
+ * sent inline within a SASL2 Bind2 request (XEP-0388 / XEP-0386).
+ *
+ *
specificFeatures = clientSession.getAvailableStreamFeatures();
- if (specificFeatures != null) {
- for (final org.dom4j.Element feature : specificFeatures) {
- features.add(feature);
- }
- }
- // Deliver these here.
- clientSession.deliverRawText(success.asXML());
- clientSession.deliverRawText(features.asXML());
- } catch(Exception e) {
- Log.warn("An exception occurred while processing SASL2+Bind2 for '{}' during SASL2+Bind2 authentication.", clientSession, e);
- SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true);
- }
- });
- // Response and features are sent asynchronously from the completion stage.
+ clientSession.setAuthToken(clientAuthToken);
+ completeSasl2Bind2(clientSession, bind2Request, successData, finalFastToken, bareJid, resource, preBindAddress);
+ });
+ }
} else {
// No Bind2 request, or session already authenticated: send synchronously without .
+ clientSession.setAuthToken(clientAuthToken);
final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, null, finalFastToken);
session.deliverRawText(success.asXML());
}
@@ -868,6 +870,95 @@ private static FastToken issueFastToken(final String username, final String clie
return FastTokenManager.issueToken(username, clientId, mechanism);
}
+ /**
+ * Aborts the SASL2 authentication process for a given session and handles the failure scenario.
+ *
+ * @param session The LocalSession object representing the session. Must not be null.
+ * @param failure The Failure object representing the reason for the authentication failure. Must not be null.
+ */
+ private static void abortSasl2(@Nonnull final LocalSession session, @Nonnull final Failure failure)
+ {
+ if (session instanceof LocalClientSession clientSession) {
+ clientSession.setAuthToken(null);
+ }
+ session.removeSessionData("bind2-request");
+ session.removeSessionData("user-agent-info");
+ session.removeSessionData("SaslServer");
+ FastSessionState.clearAuthenticationAttempt(session);
+ SaslOutcome.authenticationFailed(session, failure, true);
+ }
+
+ /**
+ * Completes a SASL2 negotiation for which a resource has been bound: renders and delivers {@code },
+ * then the post-authentication stream features.
+ *
+ * Failure is handled differently either side of the {@code } write. Before it, the peer has not been
+ * told anything, so the bind is undone and the negotiation fails. After it, authentication genuinely succeeded
+ * and the session is live and routable. A failure then is a stream-level problem rather than a SASL one.
+ *
+ * @param clientSession The LocalClientSession object representing the client session. Must not be null.
+ * @param bind2Request The Bind2Request object representing the bind request. Must not be null.
+ * @param successData The byte array representing the success data.
+ * @param fastToken The FastToken if one was issued.
+ * @param authorizationIdentity the bare JID authorization identity (e.g. user@domain or uuid@domain for anonymous).
+ * @param resource the bound resource, or null if no resource was bound.
+ * @param preBindAddress The session's address prior to the binding attempt. Must not be null.
+ */
+ private static void completeSasl2Bind2(@Nonnull final LocalClientSession clientSession,
+ @Nonnull final Bind2Request bind2Request,
+ final byte[] successData,
+ final FastToken fastToken,
+ final String authorizationIdentity,
+ final String resource,
+ @Nonnull final JID preBindAddress)
+ {
+ boolean successDelivered = false;
+ try
+ {
+ final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, resource, fastToken);
+ bind2Request.processFeatureRequests(clientSession, success);
+ clientSession.deliverRawText(success.asXML());
+ successDelivered = true;
+
+ SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound);
+
+ // Deliver stream features now that has been sent.
+ final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams"));
+ final List specificFeatures = clientSession.getAvailableStreamFeatures();
+ if (specificFeatures != null) {
+ specificFeatures.forEach(features::add);
+ }
+ clientSession.deliverRawText(features.asXML());
+ }
+ catch (final Exception e)
+ {
+ if (successDelivered) {
+ Log.warn("An exception occurred after SASL2+Bind2 success was delivered to '{}'. The session is authenticated and bound, so it is closed with a stream error rather than failed.", clientSession, e);
+ clientSession.close(new StreamError(StreamError.Condition.internal_server_error, "An error occurred while completing resource binding."));
+ } else {
+ Log.warn("An exception occurred while processing SASL2+Bind2 for '{}'. Undoing the resource binding.", clientSession, e);
+ unwindBind(clientSession, preBindAddress);
+ abortSasl2(clientSession, Failure.TEMPORARY_AUTH_FAILURE);
+ }
+ }
+ }
+
+ /**
+ * Reverses the session state installed by a successful resource binding, returning the session to the
+ * pre-binding state in which another SASL2 negotiation can be attempted.
+ *
+ * @param clientSession The LocalClientSession object representing the client session. Must not be null.
+ * @param preBindAddress The session's address prior to the binding attempt. Must not be null.
+ */
+ private static void unwindBind(@Nonnull final LocalClientSession clientSession, @Nonnull final JID preBindAddress)
+ {
+ // removeSession reads the auth token to decide which session-destroyed event to fire, so it must run before
+ // abortSasl2 clears that token - otherwise a named session is reported as an anonymous one.
+ SessionManager.getInstance().removeSession(clientSession);
+ clientSession.setStatus(Session.Status.CONNECTED);
+ clientSession.setAddress(preBindAddress);
+ }
+
/**
* Adds a new SASL mechanism to the list of supported SASL mechanisms by the server. The
* new mechanism will be offered to clients and connection managers as stream features.
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
index 704c537f37..a0ba40ba3e 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
@@ -831,16 +831,22 @@ else if (this.presence.isAvailable()) {
@Override
public List getAvailableStreamFeatures() {
+ final Connection connection = conn;
+ if (connection == null) {
+ // Detached or closed: there is no stream on which to advertise anything.
+ return Collections.emptyList();
+ }
+
// Offer authenticate and registration only if TLS was not required or if required
// then the connection is already encrypted
- if (conn.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !conn.isEncrypted()) {
+ if (connection.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !connection.isEncrypted()) {
return Collections.emptyList();
}
final List result = new LinkedList<>();
// Include Stream Compression Mechanism
- if (conn.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !conn.isCompressed()) {
+ if (connection.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !connection.isCompressed()) {
final Element compression = DocumentHelper.createElement(QName.get("compression", "http://jabber.org/features/compress"));
compression.addElement("method").addText("zlib");
result.add(compression);
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java
new file mode 100644
index 0000000000..6e3cc38a93
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jivesoftware.openfire.streammanagement;
+
+import org.xmpp.packet.PacketError;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Thrown when stream management could not be enabled for a session, carrying the error condition that XEP-0198 § 6
+ * requires to be reported to the peer.
+ *
+ * The condition travels with the exception so that a caller which embeds the outcome in a larger response — such as a
+ * Bind2 inline request — can report the same reason that a standalone request would have received.
+ */
+public class StreamManagementException extends RuntimeException
+{
+ private final PacketError.Condition condition;
+
+ public StreamManagementException(@Nonnull final PacketError.Condition condition, @Nonnull final String message) {
+ super(message);
+ this.condition = condition;
+ }
+
+ @Nonnull
+ public PacketError.Condition getCondition() {
+ return condition;
+ }
+}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java
index ff5c140c52..2f365f2e78 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017-2025 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2017-2026 Ignite Realtime Foundation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -243,24 +243,45 @@ private boolean allowResume() {
* @param resume Whether the client is requesting a resumable session.
*/
private void enable( String namespace, boolean resume )
+ {
+ final Element outcome;
+ try {
+ outcome = enableAndBuildElement(namespace, resume);
+ } catch (final StreamManagementException e) {
+ Log.debug("Unable to enable stream management for session {}: {}", session, e.getMessage());
+ session.deliverRawText(buildFailedElement(namespace, e.getCondition()).asXML());
+ return;
+ }
+ session.deliverRawText(outcome.asXML());
+ }
+
+ /**
+ * Enables stream management, returning the {@code } element rather than sending it.
+ *
+ * Leaving delivery to the caller allows an inline caller to embed the element in its enclosing response, as
+ * XEP-0198 § 9.1 requires of a Bind2 inline request.
+ *
+ * @param namespace the SM namespace to use
+ * @param resume whether the client requests a resumable session
+ * @return the {@code } element
+ * @throws StreamManagementException when stream management could not be enabled, carrying the condition to report
+ */
+ @Nonnull
+ public Element enableAndBuildElement( String namespace, boolean resume ) throws StreamManagementException
{
boolean offerResume = allowResume();
- // Ensure that resource binding has occurred.
if (!session.isAuthenticated()) {
- this.namespace = namespace;
- sendUnexpectedError();
- return;
+ throw new StreamManagementException(PacketError.Condition.unexpected_request,
+ "Stream management cannot be enabled before the session is authenticated.");
}
String smId = null;
-
synchronized ( this )
{
- // Do nothing if already enabled
if ( isEnabled() )
{
- sendUnexpectedError();
- return;
+ throw new StreamManagementException(PacketError.Condition.unexpected_request,
+ "Stream management is already enabled for this session.");
}
this.namespace = namespace;
@@ -271,7 +292,7 @@ private void enable( String namespace, boolean resume )
}
}
- // Send confirmation to the requestee.
+ // Build confirmation element.
Element enabled = new DOMElement(QName.get("enabled", namespace));
if (this.resume) {
enabled.addAttribute("resume", "true");
@@ -289,7 +310,7 @@ private void enable( String namespace, boolean resume )
}
}
}
- session.deliverRawText(enabled.asXML());
+ return enabled;
}
private void startResume(String namespace, String previd, long h) {
@@ -456,12 +477,24 @@ private void sendUnexpectedError() {
* @param error PacketError describing the failure.
*/
private void sendError(PacketError error) {
- final Element failed = DocumentHelper.createElement(QName.get("failed", namespace));
- failed.addElement(QName.get(error.getCondition().toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"));
+ final Element failed = buildFailedElement(namespace, error.getCondition());
session.deliverRawText(failed.asXML());
this.namespace = null; // isEnabled() is testing this.
}
+ /**
+ * Constructs an XML element representing a failed stream management negotiation.
+ *
+ * @param namespace The namespace indicating the version of stream management being used. Must not be null.
+ * @param condition The specific error condition that caused the failure. Must not be null.
+ * @return An XML {@code } element containing information about the failure.
+ */
+ private static Element buildFailedElement(@Nonnull final String namespace, @Nonnull final PacketError.Condition condition) {
+ final Element failed = DocumentHelper.createElement(QName.get("failed", namespace));
+ failed.addElement(QName.get(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"));
+ return failed;
+ }
+
/**
* Checks if the amount of stanzas that the client acknowledges is equal to or less than the amount of stanzas that
* we've sent to the client.
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java
new file mode 100644
index 0000000000..c044c8008c
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jivesoftware.openfire.handler;
+
+import org.dom4j.Element;
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.net.Bind2Request;
+import org.jivesoftware.openfire.streammanagement.StreamManager;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class Bind2StreamManagementAvailabilityTest
+{
+ private Bind2StreamManagementHandler handler;
+
+ @BeforeAll
+ static void configureOpenfire() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ static void clearProperties()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ void registerHandler()
+ {
+ handler = new Bind2StreamManagementHandler();
+ Bind2Request.registerElementHandler(handler);
+ }
+
+ @AfterEach
+ void restoreState()
+ {
+ StreamManager.ACTIVE.setValue(StreamManager.ACTIVE.getDefaultValue());
+ Bind2Request.unregisterElementHandler(handler);
+ handler = null;
+ }
+
+ @Test
+ void followsDynamicStreamManagementSetting()
+ {
+ StreamManager.ACTIVE.setValue(false);
+ assertFalse(advertisesStreamManagement());
+
+ StreamManager.ACTIVE.setValue(true);
+ assertTrue(advertisesStreamManagement());
+
+ StreamManager.ACTIVE.setValue(false);
+ assertFalse(advertisesStreamManagement());
+ }
+
+ private static boolean advertisesStreamManagement()
+ {
+ final Element inline = Bind2Request.featureElement().element("inline");
+ return inline.elements("feature").stream()
+ .anyMatch(feature -> StreamManager.NAMESPACE_V3.equals(feature.attributeValue("var")));
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java
new file mode 100644
index 0000000000..783870e8dd
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jivesoftware.openfire.handler;
+
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.dom4j.Namespace;
+import org.dom4j.QName;
+import org.jivesoftware.openfire.session.LocalClientSession;
+import org.jivesoftware.openfire.streammanagement.StreamManagementException;
+import org.jivesoftware.openfire.streammanagement.StreamManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.xmpp.packet.PacketError;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for {@link Bind2StreamManagementHandler}.
+ */
+public class Bind2StreamManagementHandlerTest {
+
+ private Bind2StreamManagementHandler handler;
+ private LocalClientSession mockSession;
+ private StreamManager mockStreamManager;
+ private Element boundElement;
+
+ @BeforeEach
+ public void setUp() {
+ handler = new Bind2StreamManagementHandler();
+ mockSession = mock(LocalClientSession.class);
+ mockStreamManager = mock(StreamManager.class);
+ when(mockSession.getStreamManager()).thenReturn(mockStreamManager);
+ boundElement = DocumentHelper.createElement(new QName("bound", new Namespace("", "urn:xmpp:bind:0")));
+ }
+
+ @Test
+ public void testGetNamespace() {
+ assertEquals(StreamManager.NAMESPACE_V3, handler.getNamespace());
+ }
+
+ @Test
+ public void testHandleEnableElementWithoutResume() {
+ // Setup
+ final Element enableElement = DocumentHelper.createElement(
+ new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3)));
+ final Element enabledElement = DocumentHelper.createElement(
+ new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3)));
+ when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false))
+ .thenReturn(enabledElement);
+
+ // Execute
+ final boolean result = handler.handleElement(mockSession, boundElement, enableElement);
+
+ // Verify
+ assertTrue(result);
+ verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false);
+ assertEquals(1, boundElement.elements().size());
+ assertEquals("enabled", boundElement.elements().get(0).getName());
+ }
+
+ @Test
+ public void testHandleEnableElementWithResume() {
+ // Setup
+ final Element enableElement = DocumentHelper.createElement(
+ new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3)));
+ enableElement.addAttribute("resume", "true");
+ final Element enabledElement = DocumentHelper.createElement(
+ new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3)));
+ enabledElement.addAttribute("resume", "true");
+ enabledElement.addAttribute("id", "someSmId");
+ when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true))
+ .thenReturn(enabledElement);
+
+ // Execute
+ final boolean result = handler.handleElement(mockSession, boundElement, enableElement);
+
+ // Verify
+ assertTrue(result);
+ verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true);
+ assertEquals(1, boundElement.elements().size());
+ final Element addedEnabled = (Element) boundElement.elements().get(0);
+ assertEquals("enabled", addedEnabled.getName());
+ assertEquals("true", addedEnabled.attributeValue("resume"));
+ }
+
+ /**
+ * Verifies that a value which is not a lexical representation of xs:boolean does not request resumption.
+ *
+ * XEP-0198 § 3 note 5 admits only "true"/"1" and "false"/"0". Treating anything else as an affirmative would let a
+ * client believe it had a resumable stream when a conforming server would not have given it one.
+ */
+ @Test
+ public void testResumeIsNotRequestedByANonBooleanValue() {
+ final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3));
+ enable.addAttribute("resume", "foobar");
+ when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false))
+ .thenReturn(DocumentHelper.createElement(QName.get("enabled", StreamManager.NAMESPACE_V3)));
+
+ handler.handleElement(mockSession, boundElement, enable);
+
+ verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false);
+ }
+
+ @Test
+ public void testHandleEnableElementWithResume1() {
+ // Setup
+ final Element enableElement = DocumentHelper.createElement(
+ new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3)));
+ enableElement.addAttribute("resume", "1");
+ when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true))
+ .thenReturn(DocumentHelper.createElement(QName.get("enabled", StreamManager.NAMESPACE_V3)));
+
+ // Execute
+ final boolean result = handler.handleElement(mockSession, boundElement, enableElement);
+
+ // Verify
+ assertTrue(result);
+ verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true);
+ }
+
+ /**
+ * Verifies that a failure to enable stream management propagates with the condition that XEP-0198 requires, and is
+ * not delivered to the peer as a standalone stanza.
+ *
+ * The enclosing Bind2 request catches the failure and asks the handler to embed it in {@code }, as
+ * XEP-0198 § 9.1.1 requires. Sending it separately would put a {@code } on the stream before the
+ * {@code } that should contain it.
+ */
+ @Test
+ public void testEnableFailurePropagatesWithoutStandaloneDelivery() {
+ // Setup test fixture: a session that has not authenticated.
+ final StreamManager streamManager = new StreamManager(mockSession);
+ when(mockSession.getStreamManager()).thenReturn(streamManager);
+ when(mockSession.isAuthenticated()).thenReturn(false);
+ final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3));
+
+ // Execute system under test.
+ final StreamManagementException e = assertThrows(StreamManagementException.class,
+ () -> handler.handleElement(mockSession, boundElement, enable));
+
+ // Verify result.
+ assertEquals(PacketError.Condition.unexpected_request, e.getCondition(),
+ "An enable request before authentication must report unexpected-request.");
+ assertTrue(boundElement.elements().isEmpty(), "Nothing may be added to when enabling failed.");
+ verify(mockSession, never()).deliverRawText(anyString());
+ }
+
+ /**
+ * Verifies that the condition carried by a failure reaches the {@code } element, rather than being
+ * replaced by a generic one.
+ */
+ @Test
+ public void testFailureConditionIsPreservedInTheBoundElement() {
+ // Setup test fixture.
+ final StreamManagementException cause = new StreamManagementException(
+ PacketError.Condition.unexpected_request, "already enabled");
+
+ // Execute system under test.
+ handler.handleFailure(mockSession, boundElement, DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)), cause);
+
+ // Verify result.
+ final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3));
+ assertNotNull(failed);
+ assertNotNull(failed.element(QName.get("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas")),
+ "The condition that caused the failure must be reported, not a generic one.");
+ }
+
+ @Test
+ public void testHandleNonEnableElementIsIgnored() {
+ // Setup: send an unexpected element name
+ final Element wrongElement = DocumentHelper.createElement(
+ new QName("disable", new Namespace("", StreamManager.NAMESPACE_V3)));
+
+ // Execute
+ final boolean result = handler.handleElement(mockSession, boundElement, wrongElement);
+
+ // Verify
+ assertFalse(result);
+ verifyNoInteractions(mockStreamManager);
+ assertTrue(boundElement.elements().isEmpty());
+ }
+
+ @Test
+ public void testMalformedRequestProducesBadRequestFailure() {
+ final Element wrongElement = DocumentHelper.createElement(QName.get("disable", StreamManager.NAMESPACE_V3));
+
+ handler.handleFailure(mockSession, boundElement, wrongElement, null);
+
+ final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3));
+ assertNotNull(failed);
+ assertNotNull(failed.element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas")));
+ }
+
+ @Test
+ public void testProcessingExceptionProducesInternalServerErrorFailure() {
+ final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3));
+
+ handler.handleFailure(mockSession, boundElement, enable, new IllegalStateException("test failure"));
+
+ final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3));
+ assertNotNull(failed);
+ assertNotNull(failed.element(QName.get("internal-server-error", "urn:ietf:params:xml:ns:xmpp-stanzas")));
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java
index 5fc741a1a0..a3fc82dc06 100644
--- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java
@@ -47,6 +47,7 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
+import org.xmpp.packet.JID;
import javax.security.sasl.SaslServer;
import java.util.*;
@@ -60,6 +61,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -312,10 +314,8 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2
when(bind2Request.generateResourceString(any())).thenReturn(anonymousUsername);
session.setSessionData("bind2-request", bind2Request);
- // Stub SessionManager.bindResource to complete successfully (synchronously).
final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager();
- when(sessionManager.bindResource(any(), any(), any()))
- .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND));
+ // Deliberately no bindResource stub: an anonymous session must not be bound through SessionManager.
// Execute system under test.
SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0], true);
@@ -337,6 +337,9 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2
final String responseValue2 = response.getAllValues().get(1);
assertTrue(responseValue2.contains(" failedBind = new CompletableFuture<>();
failedBind.completeExceptionally(new IllegalStateException("test failure"));
- when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())).thenReturn(failedBind);
+ when(XMPPServer.getInstance().getSessionManager().bindResource(notNull(), notNull(), notNull())).thenReturn(failedBind);
// Execute system under test.
SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true);
@@ -404,6 +408,7 @@ public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() throws Exception
"An unexpected failure to bind is not permanent, so the client must be told it may try again.");
verify(bind2Request, never()).processFeatureRequests(any(), any());
assertFalse(session.isAuthenticated(), "A session whose resource could not be bound must not be authenticated.");
+ assertNull(session.getAuthToken(), "A session whose resource could not be bound must not have an auth token.");
}
/**
@@ -423,8 +428,8 @@ public void bind2InlineHandlersRunAgainstAnAuthenticatedSession() throws Excepti
final Bind2Request bind2Request = mock(Bind2Request.class);
when(bind2Request.generateResourceString(any())).thenReturn("test-resource");
session.setSessionData("bind2-request", bind2Request);
- when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any()))
- .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND));
+ final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager();
+ stubSuccessfulBind(sessionManager);
final AtomicReference statusWhenHandlersRan = new AtomicReference<>();
when(bind2Request.processFeatureRequests(any(), any())).thenAnswer(invocation -> {
@@ -532,8 +537,7 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A
// Stub SessionManager.bindResource to complete successfully (synchronously).
final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager();
- when(sessionManager.bindResource(any(), any(), any()))
- .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND));
+ stubSuccessfulBind(sessionManager);
// Execute system under test.
SASLAuthentication.authenticationSuccessful(session, username, "PLAIN", new byte[0], true);
@@ -555,9 +559,125 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A
final String responseValue2 = response.getAllValues().get(1);
assertTrue(responseValue2.contains(" token = ArgumentCaptor.forClass(AuthToken.class);
+ verify(sessionManager).bindResource(same(session), token.capture(), eq("test-resource"));
+ assertNotNull(token.getValue(), "The bind must be driven by an authentication token, not null.");
+ assertFalse(token.getValue().isAnonymous(), "Expected the token of the user that authenticated.");
+ assertEquals("testuser", token.getValue().getUsername(), "Expected the token of the user that authenticated.");
+ }
+
+ /**
+ * Verifies that a failure occurring after the resource was bound, but before {@code } reached the peer,
+ * returns the session to its pre-binding state.
+ *
+ * The peer has been told the negotiation failed and may attempt another one. A session left authenticated, bound
+ * and holding a route would have that retry skip binding altogether, and would leave a routable session for an
+ * authentication the peer believes did not happen.
+ */
+ @Test
+ public void bind2FailureBeforeSuccessUnwindsTheBind() throws Exception
+ {
+ // Setup test fixture.
+ final Connection connection = mock(Connection.class);
+ final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection,
+ new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH);
+ final JID preBindAddress = session.getAddress();
+ final Bind2Request bind2Request = mock(Bind2Request.class);
+ when(bind2Request.generateResourceString(any())).thenReturn("test-resource");
+ when(bind2Request.processFeatureRequests(any(), any())).thenThrow(new IllegalStateException("test failure"));
+ session.setSessionData("bind2-request", bind2Request);
+ final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager();
+ stubSuccessfulBind(sessionManager);
+
+ // Execute system under test.
+ SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true);
+
+ // Verify result.
+ final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class);
+ verify(connection).deliverRawText(delivered.capture());
+ final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement();
+ assertEquals("failure", failure.getName(), "Expected the negotiation to fail, as was never delivered.");
+ assertNotNull(failure.element(QName.get("temporary-auth-failure", SASLAuthentication.SASL_NAMESPACE)),
+ "The client may retry, so the failure must not be permanent.");
+ verify(sessionManager).removeSession(session);
+ assertNull(session.getAuthToken(), "An undone bind must not leave an authentication token behind.");
+ assertEquals(Session.Status.CONNECTED, session.getStatus(), "An undone bind must not leave the session authenticated.");
+ assertSame(preBindAddress, session.getAddress(), "An undone bind must restore the pre-binding address.");
+ }
+
+ /**
+ * Verifies that a failure occurring after {@code } was delivered closes the stream with an error, rather
+ * than following it with a SASL {@code }.
+ *
+ * By that point the authentication genuinely succeeded and the session is live and routable. Two contradictory
+ * outcomes for one negotiation leave the peer with no defined behaviour, and the SASL failure would not remove the
+ * route in any case.
+ */
+ @Test
+ public void bind2FailureAfterSuccessClosesTheStream() throws Exception
+ {
+ // Setup test fixture: feature generation fails, which happens only after has been written.
+ final Connection connection = mock(Connection.class);
+ final ConnectionConfiguration connectionConfiguration = mock(ConnectionConfiguration.class);
+ when(connectionConfiguration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled);
+ when(connectionConfiguration.getCompressionPolicy()).thenThrow(new IllegalStateException("test failure"));
+ when(connection.getConfiguration()).thenReturn(connectionConfiguration);
+ final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection,
+ new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH);
+ final Bind2Request bind2Request = mock(Bind2Request.class);
+ when(bind2Request.generateResourceString(any())).thenReturn("test-resource");
+ session.setSessionData("bind2-request", bind2Request);
+ stubSuccessfulBind(XMPPServer.getInstance().getSessionManager());
+
+ // Execute system under test.
+ SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true);
+
+ // Verify result.
+ final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class);
+ verify(connection, times(2)).deliverRawText(delivered.capture());
+ assertEquals("success", DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement().getName(),
+ "Expected to have been delivered before the failure occurred.");
+ assertTrue(delivered.getAllValues().get(1).contains("internal-server-error"),
+ "Expected the stream to be closed with an error: " + delivered.getAllValues().get(1));
+ assertFalse(delivered.getAllValues().stream().anyMatch(xml -> xml.contains(" {
+ final LocalClientSession bound = invocation.getArgument(0);
+ final AuthToken token = invocation.getArgument(1);
+ final String resource = invocation.getArgument(2);
+ final String node = token.isAnonymous() ? resource : token.getUsername();
+ bound.setAddress(new JID(node, bound.getServerName(), resource, true));
+ bound.setAuthToken(token);
+ bound.setStatus(Session.Status.AUTHENTICATED);
+ return CompletableFuture.completedFuture(SessionManager.BindResult.BOUND);
+ });
+ }
}