From 585efa0ab2fde7cc4463afec5a17de7233faed77 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Fri, 3 Jul 2026 15:32:26 +0100 Subject: [PATCH 01/25] Integrate XEP-0198 Stream Management with SASL2 (XEP-0388) and Bind2 (XEP-0386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XEP-0198 was previously supported in isolation but had no integration with the SASL2 / Bind2 inline-feature negotiation that this branch also supports. This commit wires the two together at all three integration points defined by the specifications. 1. Inline feature advertisement (XEP-0388 §6.3.1) SASLAuthentication.getSASLMechanismsElement() now adds an element (urn:xmpp:sm:3) inside the child of the SASL2 feature, but only when stream management is globally active. 2. SM inside Bind2 (XEP-0386) A new Bind2InlineHandler implementation, Bind2StreamManagementHandler, handles elements that arrive inside the Bind2 element. It calls the new StreamManager.enableAndBuildElement() method (which performs the same work as the existing private enable() but returns the element instead of sending it) and adds the result to the element in the SASL2 stanza, so the client receives everything in a single round-trip. The handler is registered in SessionManager.start() and unregistered in SessionManager.stop(). 3. SM inside SASL2 (XEP-0388 §6.3.2) When a client includes a element inside its SASL2 stanza, SASLAuthentication.handle() stores it on the session. After SASL authentication succeeds, authenticationSuccessful() detects the stored element and calls the new StreamManager.processSasl2Resume() method. That method mirrors the existing startResume() logic but calls the new LocalSession.reattachForSasl2() instead of reattach(): the new variant takes over the connection and builds the element without sending it, so the caller can embed it inside the SASL2 stanza. Supporting refactors - StreamManager.onResume() is decomposed into buildResumedElement(), processClientAcknowledgementPublic(), and redeliverUnackedStanzas() so the SASL2 resume path can reuse the same logic without duplicating it. - StreamManager.enable() is decomposed into enableInternal() (returns the element) and the original enable() (sends it), with the new public enableAndBuildElement() delegating to enableInternal(). - LocalSession gains reattachForSasl2() alongside the existing reattach(). Tests - StreamManagerTest: three new tests for StreamManager.featureElement(). - Bind2StreamManagementHandlerTest: seven tests covering enable/resume attribute parsing, failure handling, and rejection of unexpected elements. - SASLAuthenticationTest: three new tests verifying that the inline feature is present in SASL2 advertisements when SM is active, absent when SM is inactive, and absent from SASL1 advertisements entirely. Co-authored-by: Junie --- .../jivesoftware/openfire/SessionManager.java | 9 + .../handler/Bind2StreamManagementHandler.java | 79 +++++++ .../openfire/session/LocalSession.java | 33 +++ .../streammanagement/StreamManager.java | 219 +++++++++++++++++- .../Bind2StreamManagementHandlerTest.java | 163 +++++++++++++ .../streammanagement/StreamManagerTest.java | 32 +++ 6 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java index 59208d01ad..ec1adcaef6 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java @@ -38,6 +38,9 @@ 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.StreamManager; import org.jivesoftware.openfire.streammanagement.TerminationDelegate; import org.jivesoftware.util.*; import org.jivesoftware.util.cache.*; @@ -1901,6 +1904,11 @@ public void start() throws IllegalStateException { super.start(); localSessionManager.start(); + // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. + if (StreamManager.isStreamManagementActive()) { + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + } + // 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. @@ -1917,6 +1925,7 @@ public void start() throws IllegalStateException { @Override public void stop() { Log.debug("SessionManager: Stopping server"); + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); // 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..20b008c362 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024-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.StreamManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link Bind2InlineHandler} that processes XEP-0198 Stream Management {@code } elements + * sent inline within a SASL2 Bind2 request (XEP-0388 / XEP-0386). + * + *

When a client includes an {@code } element in the {@code urn:xmpp:sm:3} namespace + * inside its Bind2 {@code } element, this handler delegates to the session's + * {@link StreamManager} to enable stream management (and optionally resumption) immediately + * after resource binding, without requiring a separate round-trip.

+ * + *

The {@code } response from the server is added as a child of the {@code } + * element in the SASL2 {@code } stanza.

+ * + * @see XEP-0198: Stream Management + * @see XEP-0388: Extensible SASL Profile + */ +public class Bind2StreamManagementHandler implements Bind2InlineHandler { + + private static final Logger Log = LoggerFactory.getLogger(Bind2StreamManagementHandler.class); + + @Override + public String getNamespace() { + return StreamManager.NAMESPACE_V3; + } + + /** + * Handles an {@code } element from a Bind2 inline feature request by enabling + * XEP-0198 stream management on the session. The {@code } response element + * produced by the stream manager is added as a child of the provided {@code bound} element. + * + *

Only {@code } elements are processed; any other element name is ignored.

+ * + * @param session the client session on which stream management should be enabled + * @param bound the {@code } element to which the {@code } response is added + * @param element the inline element from the Bind2 request (expected to be {@code }) + * @return {@code true} if the element was an {@code } and was processed; + * {@code false} if the element was not an {@code } or processing failed + */ + @Override + public boolean handleElement(LocalClientSession session, Element bound, Element element) { + if (!"enable".equals(element.getName())) { + Log.debug("Bind2StreamManagementHandler received unexpected element '{}'; ignoring.", element.getName()); + return false; + } + Log.debug("Processing inline SM for session {}", session.getAddress()); + final String namespace = element.getNamespaceURI(); + final String resumeAttr = element.attributeValue("resume"); + final boolean resume = "true".equalsIgnoreCase(resumeAttr) || "1".equals(resumeAttr) || "yes".equalsIgnoreCase(resumeAttr); + final Element enabled = session.getStreamManager().enableAndBuildElement(namespace, resume); + if (enabled != null) { + bound.add(enabled); + return true; + } + return false; + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index 9e0cbdcc90..328c91ccac 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -205,6 +205,39 @@ public void reattach(LocalSession connectionProvider, long h) { this.sessionManager.removeSession((LocalClientSession)connectionProvider); } + /** + * Reattaches the connection from {@code connectionProvider} to this session for a SASL2-based + * stream resumption (XEP-0198 + XEP-0388). Unlike {@link #reattach(LocalSession, long)}, this + * method does not send the {@code } element; the caller is responsible for + * embedding it in the SASL2 {@code } element before delivering it. + * + * @param connectionProvider the new (unauthenticated) session whose connection will be taken over + * @param h the client's acknowledgement counter + * @return the {@code } element to be embedded in the SASL2 {@code } + */ + public Element reattachForSasl2(LocalSession connectionProvider, long h) { + lock.lock(); + try { + Log.debug("Reattaching (SASL2) session with address {} and streamID {} using connection from session with address {} and streamID {}.", this.address, this.streamID, connectionProvider.getAddress(), connectionProvider.getStreamID()); + if (this.conn != null && !this.conn.isClosed()) + { + this.conn.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + } + this.conn = connectionProvider.releaseConnection(); + this.conn.reinit(this); + } finally { + lock.unlock(); + } + this.status = Session.Status.AUTHENTICATED; + this.sessionManager.removeDetached(this); + // Build the element but do NOT send it — the caller will embed it in . + final Element resumed = this.streamManager.buildResumedElement(); + this.streamManager.processClientAcknowledgementPublic(h); + this.streamManager.redeliverUnackedStanzas(new JID(null, this.serverName, null, true)); + this.sessionManager.removeSession((LocalClientSession) connectionProvider); + return resumed; + } + /** * Obtain the address of the session. The address is used by services like the core * server packet router to determine if a packet should be sent to the handler. 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..2e14b3207a 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -97,6 +97,16 @@ public static boolean isStreamManagementActive() { public static final String NAMESPACE_V2 = "urn:xmpp:sm:2"; public static final String NAMESPACE_V3 = "urn:xmpp:sm:3"; + /** + * Returns an XML element advertising XEP-0198 stream management as an inline feature for use + * in SASL2 (XEP-0388) inline feature advertisement. + * + * @return an {@code } element in the {@link #NAMESPACE_V3} namespace + */ + public static Element featureElement() { + return DocumentHelper.createElement(QName.get("sm", NAMESPACE_V3)); + } + /** * Session (stream) to client. */ @@ -243,13 +253,36 @@ private boolean allowResume() { * @param resume Whether the client is requesting a resumable session. */ private void enable( String namespace, boolean resume ) + { + final Element enabled = enableInternal(namespace, resume); + if (enabled != null) { + session.deliverRawText(enabled.asXML()); + } + } + + /** + * Enables stream management and returns the {@code } element without sending it. + * This allows callers (e.g. the SASL2 Bind2 handler) to embed the element in another stanza. + * + *

Returns {@code null} if enabling failed (an error stanza will have been sent already).

+ * + * @param namespace the SM namespace to use + * @param resume whether the client requests a resumable session + * @return the {@code } element, or {@code null} on failure + */ + public Element enableAndBuildElement( String namespace, boolean resume ) + { + return enableInternal(namespace, resume); + } + + private Element enableInternal( String namespace, boolean resume ) { boolean offerResume = allowResume(); // Ensure that resource binding has occurred. if (!session.isAuthenticated()) { this.namespace = namespace; sendUnexpectedError(); - return; + return null; } String smId = null; @@ -260,7 +293,7 @@ private void enable( String namespace, boolean resume ) if ( isEnabled() ) { sendUnexpectedError(); - return; + return null; } this.namespace = namespace; @@ -271,7 +304,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 +322,7 @@ private void enable( String namespace, boolean resume ) } } } - session.deliverRawText(enabled.asXML()); + return enabled; } private void startResume(String namespace, String previd, long h) { @@ -406,6 +439,145 @@ private void startResume(String namespace, String previd, long h) { Log.debug("Perform resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); } + /** + * Processes a SASL2-inline XEP-0198 {@code } element. Unlike the standard + * {@link #process(Element)} path, this method does not send the {@code } + * element directly; instead it returns it so the caller can embed it in the SASL2 + * {@code } element. + * + *

On any error the method sends the appropriate SM {@code } stanza itself and + * returns {@code null}.

+ * + * @param resumeElement the {@code } element from the SASL2 {@code } + * @return the {@code } element to embed in {@code }, or {@code null} if + * resumption failed + */ + public Element processSasl2Resume(Element resumeElement) { + final String namespace = resumeElement.getNamespaceURI(); + this.namespace = namespace; + + final String hValue = resumeElement.attributeValue("h"); + final long h; + try { + h = Long.parseLong(hValue); + } catch (NumberFormatException e) { + Log.warn("Client sends non-numeric value for SM 'h' in SASL2 resume: {}, session: {}", hValue, session); + sendUnexpectedError(); + return null; + } + if (h < 0) { + Log.warn("Client sends negative value for SM 'h' in SASL2 resume: {}, session: {}", h, session); + sendUnexpectedError(); + return null; + } + + final String previd = resumeElement.attributeValue("previd"); + + // Ensure that resource binding has NOT occurred. + if (!allowResume()) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); + sendUnexpectedError(); + return null; + } + if (session.isAuthenticated()) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} is already authenticated.", session); + sendUnexpectedError(); + return null; + } + AuthToken authToken = null; + if (session instanceof ClientSession) { + authToken = ((LocalClientSession) session).getAuthToken(); + } + if (authToken == null) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} does not provide any auth context.", session); + sendUnexpectedError(); + return null; + } + + // Decode previd. + String resource; + String streamId; + try { + StringTokenizer toks = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); + resource = toks.nextToken(); + streamId = toks.nextToken(); + } catch (Exception e) { + Log.debug("Exception from previd decode in SASL2 resume:", e); + sendUnexpectedError(); + return null; + } + + final JID fullJid; + if (authToken.isAnonymous()) { + fullJid = new JID(resource, session.getServerName(), resource, true); + } else { + fullJid = new JID(authToken.getUsername(), session.getServerName(), resource, true); + } + Log.debug("SASL2 resuming session for '{}'. Current session: {}", fullJid, session.getStreamID()); + + final ClientSession route = XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid); + if (route == null) { + Log.debug("Not able for client of '{}' to resume a session (SASL2) on this cluster node. No session was found.", fullJid); + if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { + CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); + } + sendError(new PacketError(PacketError.Condition.item_not_found)); + return null; + } + if (!(route instanceof LocalClientSession)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2) on this cluster node. The session can only be resumed on the original cluster node.", fullJid); + if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { + CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); + } + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + + final LocalClientSession otherSession = (LocalClientSession) route; + if (!otherSession.getStreamID().getID().equals(streamId)) { + sendError(new PacketError(PacketError.Condition.item_not_found)); + return null; + } + Log.debug("Found existing session for '{}' (SASL2 resume), checking status", fullJid); + + if (route.isClosed()) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as the preexisting session is already in process of being closed.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().resume) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed does not have the stream management resumption feature enabled.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (otherSession.getStreamManager().namespace == null) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().namespace.equals(namespace)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), namespace mismatch: {} vs {}.", fullJid, otherSession.getStreamManager().namespace, namespace); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as it reports it received more stanzas from us than that we've sent it.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.isDetached()) { + Log.debug("Existing session {} of '{}' is not detached (SASL2 resume); detaching.", otherSession.getStreamID(), fullJid); + Connection oldConnection = otherSession.getConnection(); + otherSession.setDetached(); + assert oldConnection != null; + oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + } + Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); + final Element resumed = otherSession.reattachForSasl2(session, h); + Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); + return resumed; + } + /** * Called when a session receives a closing stream tag, this prevents the * session from being detached. @@ -634,17 +806,52 @@ public void onClose(PacketRouter router, JID serverAddress) { } - public void onResume(JID serverAddress, long h) { - Log.debug("Agreeing to resume"); + /** + * Builds the XEP-0198 {@code } element for this session without sending it. + * This is used when the element needs to be embedded in another stanza (e.g. a SASL2 + * {@code } element) rather than sent standalone. + * + * @return the {@code } element + */ + public Element buildResumedElement() { Element resumed = new DOMElement(QName.get("resumed", namespace)); resumed.addAttribute("previd", Base64.getEncoder().encodeToString((session.getAddress().getResource() + "\0" + session.getStreamID().getID()).getBytes(StandardCharsets.UTF_8))); resumed.addAttribute("h", Long.toString(serverProcessedStanzas.get())); + return resumed; + } + + public void onResume(JID serverAddress, long h) { + Log.debug("Agreeing to resume"); + final Element resumed = buildResumedElement(); final Connection connection = session.getConnection(); assert connection != null; // While the client is resuming a session, the connection on which the session is resumed can't be null. connection.deliverRawText(resumed.asXML()); Log.debug("Resuming session: Ack for {}", h); processClientAcknowledgement(h); + redeliverUnackedStanzas(serverAddress); + } + + /** + * Processes the client's acknowledgement counter as part of a SASL2-based stream resumption. + * This is a package-accessible wrapper around the private {@link #processClientAcknowledgement(long)} + * for use by {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2}. + * + * @param h the client's acknowledgement counter + */ + public void processClientAcknowledgementPublic(long h) { + processClientAcknowledgement(h); + } + + /** + * Re-delivers unacknowledged stanzas after a stream resumption and sends a server request for + * acknowledgement. Called by both the standard and SASL2 resume paths. + * + * @param serverAddress the server's JID, used to stamp delayed stanzas + */ + public void redeliverUnackedStanzas(JID serverAddress) { Log.debug("Processing remaining unacked stanzas"); + final Connection connection = session.getConnection(); + assert connection != null; // Re-deliver unacknowledged stanzas from broken stream (XEP-0198) synchronized (this) { if(isEnabled()) { 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..95ead3e4ea --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024-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.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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")); + } + + @Test + public void testHandleEnableElementWithResumeYes() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "yes"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(DocumentHelper.createElement("enabled")); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + @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("enabled")); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + @Test + public void testHandleEnableElementWhenEnableFails() { + // Setup: enableAndBuildElement returns null (e.g. SM already enabled) + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(null); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertFalse(result); + assertTrue(boundElement.elements().isEmpty(), "No element should be added to on failure"); + } + + @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()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index 73a0e5fc6c..34e7f06426 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -15,10 +15,12 @@ */ package org.jivesoftware.openfire.streammanagement; +import org.dom4j.Element; import org.junit.jupiter.api.Test; import java.math.BigInteger; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -326,4 +328,34 @@ public void testValidateClientAcknowledgement_rollover_edgecase5_unsent() throws // Verify results. assertFalse(result); } + + @Test + public void testFeatureElementHasCorrectName() { + // Execute system under test. + final Element feature = StreamManager.featureElement(); + + // Verify results. + assertNotNull(feature); + assertEquals("sm", feature.getName()); + } + + @Test + public void testFeatureElementHasCorrectNamespace() { + // Execute system under test. + final Element feature = StreamManager.featureElement(); + + // Verify results. + assertNotNull(feature); + assertEquals(StreamManager.NAMESPACE_V3, feature.getNamespaceURI()); + } + + @Test + public void testFeatureElementIsDistinctOnEachCall() { + // Execute system under test. + final Element feature1 = StreamManager.featureElement(); + final Element feature2 = StreamManager.featureElement(); + + // Verify results: each call returns a new element instance. + assertNotSame(feature1, feature2); + } } From ac54f28e5d388d7bbc274dc42cce626b46a80f86 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Fri, 3 Jul 2026 15:38:42 +0100 Subject: [PATCH 02/25] Defer SM stanza redelivery until after stream features on SASL2 resume When a session is resumed inline via SASL2 (XEP-0388 + XEP-0198), the server must send pending unacknowledged stanzas only *after* the stream features that follow the element, not before. Previously, reattachForSasl2() called redeliverUnackedStanzas() directly, which meant stanzas were sent before was even delivered to the client, let alone the post-success stream features. Fix: - Add a boolean flag pendingSasl2Redelivery to StreamManager, with setPendingSasl2Redelivery(boolean) and redeliverIfPendingSasl2(JID). - reattachForSasl2() in LocalSession now sets the flag instead of calling redeliverUnackedStanzas() directly. - StanzaHandler calls redeliverIfPendingSasl2() immediately after delivering stream features following a successful SASL2 authenticate or response, ensuring the correct ordering: 1. (with embedded ) 2. stream features 3. unacknowledged stanzas redelivered Three new unit tests in StreamManagerTest verify the flag semantics. Co-authored-by: Junie --- .../openfire/net/StanzaHandler.java | 3 + .../openfire/session/LocalSession.java | 5 +- .../streammanagement/StreamManager.java | 34 +++++++++ .../streammanagement/StreamManagerTest.java | 75 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index e6a9beb163..2b9113e504 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -251,6 +251,7 @@ else if ("auth".equals(tag)) { } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { // Bind2: and features are delivered asynchronously by SASLAuthentication. startedSASL = false; + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } // If authenticatedAwaitingFeatures, and features are delivered asynchronously // by SASLAuthentication once Bind2 resource binding completes. @@ -267,6 +268,7 @@ else if ("auth".equals(tag)) { } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { // Bind2: and features are delivered asynchronously by SASLAuthentication. startedSASL = false; + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } // If authenticatedAwaitingFeatures, and features are delivered asynchronously // by SASLAuthentication once Bind2 resource binding completes. @@ -587,6 +589,7 @@ protected void saslSuccessful() { protected void sasl2Successful() { final Element features = generateFeatures(); connection.deliverRawText(features.asXML()); + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } /** diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index 328c91ccac..3bc25e38d9 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -233,7 +233,10 @@ public Element reattachForSasl2(LocalSession connectionProvider, long h) { // Build the element but do NOT send it — the caller will embed it in . final Element resumed = this.streamManager.buildResumedElement(); this.streamManager.processClientAcknowledgementPublic(h); - this.streamManager.redeliverUnackedStanzas(new JID(null, this.serverName, null, true)); + // Redelivery of unacked stanzas must happen AFTER stream features are sent following + // , so we only set the pending flag here; StanzaHandler will call + // redeliverIfPendingSasl2() after generateFeatures(). + this.streamManager.setPendingSasl2Redelivery(true); this.sessionManager.removeSession((LocalClientSession) connectionProvider); return resumed; } 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 2e14b3207a..bbaef4d525 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -144,6 +144,14 @@ public static Element featureElement() { */ private final Set terminationDelegates = new HashSet<>(); + /** + * Set to {@code true} when a SASL2-inline stream resumption has been processed but unacknowledged + * stanzas have not yet been redelivered. The redelivery must happen after stream features are sent + * following the SASL2 {@code }, so {@link StanzaHandler} calls + * {@link #redeliverIfPendingSasl2(JID)} at that point. + */ + private volatile boolean pendingSasl2Redelivery = false; + public StreamManager(LocalSession session) { String address; try { @@ -842,6 +850,32 @@ public void processClientAcknowledgementPublic(long h) { processClientAcknowledgement(h); } + /** + * Sets the pending SASL2 redelivery flag. When {@code true}, {@link #redeliverIfPendingSasl2(JID)} + * will redeliver unacknowledged stanzas. Called by + * {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2} to defer redelivery + * until after stream features have been sent. + * + * @param pending whether redelivery is pending + */ + public void setPendingSasl2Redelivery(boolean pending) { + this.pendingSasl2Redelivery = pending; + } + + /** + * If a SASL2-inline stream resumption is pending redelivery, redelivers unacknowledged stanzas + * now. This must be called by {@link org.jivesoftware.openfire.net.StanzaHandler} after stream + * features have been sent following the SASL2 {@code }. + * + * @param serverAddress the server's JID, used to stamp delayed stanzas + */ + public void redeliverIfPendingSasl2(JID serverAddress) { + if (pendingSasl2Redelivery) { + pendingSasl2Redelivery = false; + redeliverUnackedStanzas(serverAddress); + } + } + /** * Re-delivers unacknowledged stanzas after a stream resumption and sends a server request for * acknowledgement. Called by both the standard and SASL2 resume paths. diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index 34e7f06426..b8942bf92f 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -16,13 +16,17 @@ package org.jivesoftware.openfire.streammanagement; import org.dom4j.Element; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.session.LocalClientSession; import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; import java.math.BigInteger; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; /** * Unit tests that verify the implementation of {@link StreamManager}. @@ -358,4 +362,75 @@ public void testFeatureElementIsDistinctOnEachCall() { // Verify results: each call returns a new element instance. assertNotSame(feature1, feature2); } + + /** + * Verifies that a freshly constructed StreamManager does not have a pending SASL2 redelivery. + */ + @Test + public void testPendingSasl2RedeliveryIsFalseByDefault() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + // Clear interactions caused by the constructor (e.g. getHostAddress()). + clearInvocations(mockConnection); + + // Execute system under test: redeliverIfPendingSasl2 with no flag set should be a no-op. + final JID serverAddress = new JID(null, "example.org", null, true); + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Verify result: no interaction with the connection (no stanzas delivered). + verifyNoInteractions(mockConnection); + } + + /** + * Verifies that setting the pending SASL2 redelivery flag and then calling + * redeliverIfPendingSasl2 clears the flag (i.e. a second call is a no-op). + */ + @Test + public void testRedeliverIfPendingSasl2ClearsFlagAfterFirstCall() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + streamManager.setPendingSasl2Redelivery(true); + + final JID serverAddress = new JID(null, "example.org", null, true); + + // First call: flag is set, so redelivery runs (no unacked stanzas, but the flag is consumed). + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Second call: flag has been cleared, so this must be a no-op. + // We verify by resetting the mock and confirming no further deliveries occur. + clearInvocations(mockConnection); + streamManager.redeliverIfPendingSasl2(serverAddress); + verifyNoInteractions(mockConnection); + } + + /** + * Verifies that setPendingSasl2Redelivery(false) prevents redeliverIfPendingSasl2 from acting. + */ + @Test + public void testSetPendingSasl2RedeliveryFalsePreventsTrigger() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + // Clear interactions caused by the constructor (e.g. getHostAddress()). + clearInvocations(mockConnection); + + // Set then immediately clear the flag. + streamManager.setPendingSasl2Redelivery(true); + streamManager.setPendingSasl2Redelivery(false); + + // Execute system under test. + final JID serverAddress = new JID(null, "example.org", null, true); + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Verify result: no interaction with the connection. + verifyNoInteractions(mockConnection); + } } From 5adc07ba05664c5362080c9678eb0e28107f4817 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Fri, 28 Aug 2026 12:00:54 -0400 Subject: [PATCH 03/25] Update comment with devnote Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../org/jivesoftware/openfire/session/LocalSession.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index 3bc25e38d9..bd3a805208 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -233,9 +233,9 @@ public Element reattachForSasl2(LocalSession connectionProvider, long h) { // Build the element but do NOT send it — the caller will embed it in . final Element resumed = this.streamManager.buildResumedElement(); this.streamManager.processClientAcknowledgementPublic(h); - // Redelivery of unacked stanzas must happen AFTER stream features are sent following - // , so we only set the pending flag here; StanzaHandler will call - // redeliverIfPendingSasl2() after generateFeatures(). + // Redelivery of unacked stanzas must happen AFTER stream features are sent following . + // We only set the pending flag here; the code path that sends those features must call + // StreamManager#redeliverIfPendingSasl2() immediately afterwards. this.streamManager.setPendingSasl2Redelivery(true); this.sessionManager.removeSession((LocalClientSession) connectionProvider); return resumed; From 59ee280a8f2298509b55a1ebb84625e71a1c67b9 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Fri, 28 Aug 2026 12:09:25 -0400 Subject: [PATCH 04/25] Do not redeliver on Bind2 pending auth --- .../main/java/org/jivesoftware/openfire/net/StanzaHandler.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index 2b9113e504..f0290717c9 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -251,7 +251,6 @@ else if ("auth".equals(tag)) { } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { // Bind2: and features are delivered asynchronously by SASLAuthentication. startedSASL = false; - session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } // If authenticatedAwaitingFeatures, and features are delivered asynchronously // by SASLAuthentication once Bind2 resource binding completes. @@ -268,7 +267,6 @@ else if ("auth".equals(tag)) { } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { // Bind2: and features are delivered asynchronously by SASLAuthentication. startedSASL = false; - session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } // If authenticatedAwaitingFeatures, and features are delivered asynchronously // by SASLAuthentication once Bind2 resource binding completes. From 8f61ffa8f850e8a7cd2f02a79be2bd8bfc9a0918 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Fri, 28 Aug 2026 12:09:46 -0400 Subject: [PATCH 05/25] Redeliver 198 for WebSocket --- .../openfire/websocket/WebSocketClientStanzaHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java index 12f4911dc7..91dabfe549 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java @@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; +import org.xmpp.packet.JID; import org.xmpp.packet.StreamError; import java.io.IOException; @@ -209,6 +210,7 @@ protected void saslSuccessful() { @Override protected void sasl2Successful() { sendStreamFeatures(); + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } protected boolean isStartOfStream(final String xml) { From ef1e5fd394466f9ce5242dae248efc5b36bc6363 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 10:59:58 -0400 Subject: [PATCH 06/25] OF-: Return SASL2 stream resume outcomes --- .../streammanagement/StreamManager.java | 93 +++++++++++-------- .../streammanagement/StreamManagerTest.java | 14 +++ 2 files changed, 69 insertions(+), 38 deletions(-) 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 bbaef4d525..b9e2673377 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -453,16 +453,12 @@ private void startResume(String namespace, String previd, long h) { * element directly; instead it returns it so the caller can embed it in the SASL2 * {@code } element. * - *

On any error the method sends the appropriate SM {@code } stanza itself and - * returns {@code null}.

- * * @param resumeElement the {@code } element from the SASL2 {@code } - * @return the {@code } element to embed in {@code }, or {@code null} if - * resumption failed + * @return the SM result to embed in {@code }, including the session that owns the + * connection when resumption succeeds */ - public Element processSasl2Resume(Element resumeElement) { + public Sasl2ResumeResult processSasl2Resume(Element resumeElement) { final String namespace = resumeElement.getNamespaceURI(); - this.namespace = namespace; final String hValue = resumeElement.attributeValue("h"); final long h; @@ -470,27 +466,26 @@ public Element processSasl2Resume(Element resumeElement) { h = Long.parseLong(hValue); } catch (NumberFormatException e) { Log.warn("Client sends non-numeric value for SM 'h' in SASL2 resume: {}, session: {}", hValue, session); - sendUnexpectedError(); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); } - if (h < 0) { - Log.warn("Client sends negative value for SM 'h' in SASL2 resume: {}, session: {}", h, session); - sendUnexpectedError(); - return null; + if (h < 0 || h > MASK) { + Log.warn("Client sends out-of-range value for SM 'h' in SASL2 resume: {}, session: {}", h, session); + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); } final String previd = resumeElement.attributeValue("previd"); + if (previd == null || previd.isEmpty()) { + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); + } // Ensure that resource binding has NOT occurred. if (!allowResume()) { Log.debug("Unable to process SASL2 session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); - sendUnexpectedError(); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } if (session.isAuthenticated()) { Log.debug("Unable to process SASL2 session resumption attempt, as session {} is already authenticated.", session); - sendUnexpectedError(); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } AuthToken authToken = null; if (session instanceof ClientSession) { @@ -498,8 +493,7 @@ public Element processSasl2Resume(Element resumeElement) { } if (authToken == null) { Log.debug("Unable to process SASL2 session resumption attempt, as session {} does not provide any auth context.", session); - sendUnexpectedError(); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } // Decode previd. @@ -511,8 +505,7 @@ public Element processSasl2Resume(Element resumeElement) { streamId = toks.nextToken(); } catch (Exception e) { Log.debug("Exception from previd decode in SASL2 resume:", e); - sendUnexpectedError(); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); } final JID fullJid; @@ -529,49 +522,41 @@ public Element processSasl2Resume(Element resumeElement) { if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); } - sendError(new PacketError(PacketError.Condition.item_not_found)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); } if (!(route instanceof LocalClientSession)) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2) on this cluster node. The session can only be resumed on the original cluster node.", fullJid); if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); } - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); } final LocalClientSession otherSession = (LocalClientSession) route; if (!otherSession.getStreamID().getID().equals(streamId)) { - sendError(new PacketError(PacketError.Condition.item_not_found)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); } Log.debug("Found existing session for '{}' (SASL2 resume), checking status", fullJid); if (route.isClosed()) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as the preexisting session is already in process of being closed.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } if (!otherSession.getStreamManager().resume) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed does not have the stream management resumption feature enabled.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } if (otherSession.getStreamManager().namespace == null) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } if (!otherSession.getStreamManager().namespace.equals(namespace)) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2), namespace mismatch: {} vs {}.", fullJid, otherSession.getStreamManager().namespace, namespace); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); } if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as it reports it received more stanzas from us than that we've sent it.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return null; + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.undefined_condition); } if (!otherSession.isDetached()) { Log.debug("Existing session {} of '{}' is not detached (SASL2 resume); detaching.", otherSession.getStreamID(), fullJid); @@ -583,7 +568,39 @@ public Element processSasl2Resume(Element resumeElement) { Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); final Element resumed = otherSession.reattachForSasl2(session, h); Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); - return resumed; + return Sasl2ResumeResult.resumed(resumed, otherSession); + } + + public static final class Sasl2ResumeResult { + private final Element response; + private final LocalClientSession resumedSession; + + private Sasl2ResumeResult(Element response, LocalClientSession resumedSession) { + this.response = response; + this.resumedSession = resumedSession; + } + + public static Sasl2ResumeResult resumed(Element response, LocalClientSession resumedSession) { + return new Sasl2ResumeResult(response, resumedSession); + } + + public static Sasl2ResumeResult failed(String namespace, 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 new Sasl2ResumeResult(failed, null); + } + + public Element getResponse() { + return response; + } + + public LocalClientSession getResumedSession() { + return resumedSession; + } + + public boolean isResumed() { + return resumedSession != null; + } } /** diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index b8942bf92f..6797b69f92 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -16,10 +16,12 @@ package org.jivesoftware.openfire.streammanagement; import org.dom4j.Element; +import org.dom4j.QName; import org.jivesoftware.openfire.Connection; import org.jivesoftware.openfire.session.LocalClientSession; import org.junit.jupiter.api.Test; import org.xmpp.packet.JID; +import org.xmpp.packet.PacketError; import java.math.BigInteger; @@ -35,6 +37,18 @@ */ public class StreamManagerTest { + @Test + public void sasl2FailureBuildsEmbeddableFailedElement() { + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.failed( + StreamManager.NAMESPACE_V3, PacketError.Condition.bad_request); + + assertFalse(result.isResumed()); + assertNull(result.getResumedSession()); + assertEquals("failed", result.getResponse().getName()); + assertEquals(StreamManager.NAMESPACE_V3, result.getResponse().getNamespaceURI()); + assertNotNull(result.getResponse().element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + @Test public void testValidateClientAcknowledgement() throws Exception { From dbce2892ecfc3bef4c109ae86c380b8f2dd49595 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 11:02:24 -0400 Subject: [PATCH 07/25] OF-: Process stream resumption during SASL2 --- .../openfire/net/SASLAuthentication.java | 49 ++++++++++++++++--- .../openfire/net/SASLAuthenticationTest.java | 23 +++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 7845e1dbfb..b3cb90e8d5 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -41,6 +41,7 @@ import org.jivesoftware.openfire.event.SessionEventDispatcher; import org.jivesoftware.openfire.session.*; import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.util.CertificateManager; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.PropertyEventDispatcher; @@ -87,6 +88,8 @@ public class SASLAuthentication { private static final Logger Log = LoggerFactory.getLogger(SASLAuthentication.class); + static final String SASL2_RESUME_REQUEST = "sasl2-resume-request"; + static final String SASL2_RESUMED_SESSION = "sasl2-resumed-session"; // TODO how is this different from a singular entry in APPROVED_REALMS? Should these two properties be folded into eachother? public static final SystemProperty REALM = SystemProperty.Builder.ofType(String.class) @@ -751,8 +754,9 @@ public static Status handle(LocalSession session, Element doc, boolean usingSASL // the RFC, so we just strip any initial token. if (data != null) data = null; } - // Clear any unexecuted bind2-request + // Clear any unexecuted inline requests from an earlier attempt. session.removeSessionData("bind2-request"); + session.removeSessionData(SASL2_RESUME_REQUEST); if (usingSASL2 && session instanceof LocalClientSession) { Element userAgentElement = doc.element("user-agent"); if (userAgentElement != null) { @@ -766,6 +770,10 @@ public static Status handle(LocalSession session, Element doc, boolean usingSASL if (bind2Request != null) { session.setSessionData("bind2-request", bind2Request); } + final Element resumeRequest = doc.element(QName.get("resume", StreamManager.NAMESPACE_V3)); + if (resumeRequest != null) { + session.setSessionData(SASL2_RESUME_REQUEST, resumeRequest.createCopy()); + } } // intended fall-through @@ -809,15 +817,14 @@ else if ( decoded.length == 0 ) // performed by the SaslServer implementation. // Check before calling authenticationSuccessful whether a Bind2 request is pending; // if so, the response and stream features will be delivered asynchronously. - final boolean hasBind2Request = usingSASL2 && session.getSessionData("bind2-request") != null; - authenticationSuccessful( session, saslServer.getAuthorizationID(), saslServer.getMechanismName(), challenge, usingSASL2 ); + final boolean awaitingFeatures = authenticationSuccessful( session, saslServer.getAuthorizationID(), saslServer.getMechanismName(), challenge, usingSASL2 ); session.removeSessionData( "SaslServer" ); session.removeSessionData( SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY ); session.setSessionData("SaslMechanism", saslServer.getMechanismName()); if (requiresChannelBinding(saslServer.getMechanismName())) { session.setSessionData("ChannelBindingType", saslServer.getNegotiatedProperty(ScramSaslServer.PROPNAME_CHANNELBINDINGTYPE)); } - return hasBind2Request ? Status.authenticatedAwaitingFeatures : Status.authenticated; + return awaitingFeatures ? Status.authenticatedAwaitingFeatures : Status.authenticated; default: throw new IllegalStateException( "Unexpected data received while negotiating SASL authentication. Name of the offending root element: " + doc.getName() + " Namespace: " + doc.getNamespaceURI() ); @@ -837,6 +844,7 @@ else if ( decoded.length == 0 ) } authenticationFailed( session, failure, usingSASL2 ); session.removeSessionData( "SaslServer" ); + session.removeSessionData(SASL2_RESUME_REQUEST); return Status.failed; } catch( Exception ex ) @@ -844,6 +852,7 @@ else if ( decoded.length == 0 ) Log.warn( "An unexpected exception occurred during SASL negotiation. Affected session: {}", session, ex ); authenticationFailed( session, Failure.NOT_AUTHORIZED, usingSASL2 ); session.removeSessionData( "SaslServer" ); + session.removeSessionData(SASL2_RESUME_REQUEST); return Status.failed; } } @@ -949,7 +958,7 @@ private static void sendChallenge(Session session, byte[] challenge, boolean usi * @param usingSASL2 are we using SASL2? */ @VisibleForTesting - static void authenticationSuccessful(LocalSession session, String username, String mechanismName, byte[] successData, boolean usingSASL2) + static boolean authenticationSuccessful(LocalSession session, String username, String mechanismName, byte[] successData, boolean usingSASL2) { // The identity to report back to the peer. For clients this is a bare JID; for anonymous clients, the node-part is // the session's generated resource (see LocalClientSession#getAnonymousUsername). Must be resolved before the @@ -981,6 +990,19 @@ else if (session instanceof LocalIncomingServerSession serverSession) { if (usingSASL2) { if (session instanceof LocalClientSession clientSession) { + final Element resumeRequest = (Element) clientSession.removeSessionData(SASL2_RESUME_REQUEST); + final StreamManager.Sasl2ResumeResult resumeResult = resumeRequest == null ? null : + clientSession.getStreamManager().processSasl2Resume(resumeRequest); + if (resumeResult != null && resumeResult.isResumed()) { + // Successful resumption supersedes resource binding and other inline requests. + clientSession.removeSessionData("bind2-request"); + final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null, resumeResult.getResponse()); + final LocalClientSession resumedSession = resumeResult.getResumedSession(); + clientSession.setSessionData(SASL2_RESUMED_SESSION, resumedSession); + resumedSession.deliverRawText(success.asXML()); + return false; + } + final Element resumeResponse = resumeResult == null ? null : resumeResult.getResponse(); final Bind2Request bind2Request = (Bind2Request) session.getSessionData("bind2-request"); if (bind2Request != null && clientSession.getStatus() != Session.Status.AUTHENTICATED) { clientSession.removeSessionData("bind2-request"); @@ -996,7 +1018,7 @@ else if (session instanceof LocalIncomingServerSession serverSession) { Log.warn("An exception occurred while binding resource '{}' for session '{}' during SASL2+Bind2 authentication.", resource, clientSession, throwable); } final boolean bound = throwable == null && result == SessionManager.BindResult.BOUND; - final Element success = buildSasl2SuccessElement(finalSuccessData, finalAuthorizationIdentity, bound ? resource : null); + final Element success = buildSasl2SuccessElement(finalSuccessData, finalAuthorizationIdentity, bound ? resource : null, resumeResponse); if (bound) { bind2Request.processFeatureRequests(clientSession, success); } @@ -1021,9 +1043,10 @@ else if (session instanceof LocalIncomingServerSession serverSession) { } }); // Response and features are sent asynchronously from the completion stage. + return true; } else { // No Bind2 request, or session already authenticated: send synchronously without . - final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null); + final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null, resumeResponse); session.deliverRawText(success.asXML()); } } else { @@ -1034,6 +1057,7 @@ else if (session instanceof LocalIncomingServerSession serverSession) { } else { sendElement(session, "success", successData, false); } + return false; } /** @@ -1045,6 +1069,10 @@ else if (session instanceof LocalIncomingServerSession serverSession) { * @return the <success/> element. */ private static Element buildSasl2SuccessElement(byte[] successData, String authorizationIdentity, String resource) { + return buildSasl2SuccessElement(successData, authorizationIdentity, resource, null); + } + + private static Element buildSasl2SuccessElement(byte[] successData, String authorizationIdentity, String resource, Element inlineResponse) { final Element success = DocumentHelper.createElement(new QName("success", new Namespace("", SASL2_NAMESPACE))); if (successData != null && successData.length > 0) { final String data_b64 = Base64.getEncoder().encodeToString(successData).trim(); @@ -1055,9 +1083,16 @@ private static Element buildSasl2SuccessElement(byte[] successData, String autho authId.append('/').append(resource); } success.addElement("authorization-identifier").setText(authId.toString()); + if (inlineResponse != null) { + success.add(inlineResponse); + } return success; } + static LocalSession consumeSasl2ResumedSession(LocalSession connectionProvider) { + return (LocalSession) connectionProvider.removeSessionData(SASL2_RESUMED_SESSION); + } + private static void authenticationFailed(LocalSession session, Failure failure, boolean usingSASL2) { final Element reply = DocumentHelper.createElement(QName.get("failure", usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE)); if (usingSASL2) { 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 c2a241f821..cf9faea71b 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -34,6 +34,7 @@ import org.jivesoftware.openfire.session.LocalSession; import org.jivesoftware.openfire.session.ServerSession; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; @@ -55,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -369,6 +371,27 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 "Expected no resource in authorization-identifier for non-Bind2 SASL2 case."); } + @Test + public void shouldEmbedFailedStreamResumeInSasl2Success() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", "invalid"); + resume.addAttribute("h", "not-a-number"); + session.setSessionData(SASLAuthentication.SASL2_RESUME_REQUEST, resume); + + SASLAuthentication.authenticationSuccessful(session, "romeo", "PLAIN", new byte[0], true); + + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection, times(1)).deliverRawText(response.capture()); + final Element success = DocumentHelper.parseText(response.getValue()).getRootElement(); + final Element failed = success.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + /** * Verifies that authenticationSuccessful generates an anonymous auth token for a client with no username, using SASL2+Bind2, * and that the SASL2 success element contains a full JID authorization-identifier where node and resource are the same UUID. From 5464aab9044088034050a892b7ea0f846a7b6ea4 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 11:03:25 -0400 Subject: [PATCH 08/25] OF-: Adopt resumed session after SASL2 --- .../jivesoftware/openfire/net/StanzaHandler.java | 9 +++++++++ .../openfire/net/StanzaHandlerTest.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index f0290717c9..212d6a222e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -245,6 +245,7 @@ else if ("auth".equals(tag)) { usingSASL2 = true; saslStatus = SASLAuthentication.handle(session, doc, usingSASL2); if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + adoptSasl2ResumedSession(); // No Bind2: send features synchronously now. startedSASL = false; // Without a multi-step SASL mechanism, this can be reset here immediately, rather than in initiateSession (as SASL1 does). sasl2Successful(); @@ -262,6 +263,7 @@ else if ("auth".equals(tag)) { usingSASL2 = false; } if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + adoptSasl2ResumedSession(); startedSASL = false; // Symmetric with the single-step reset in the 'authenticate' branch. sasl2Successful(); } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { @@ -590,6 +592,13 @@ protected void sasl2Successful() { session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } + void adoptSasl2ResumedSession() { + final LocalSession resumedSession = SASLAuthentication.consumeSasl2ResumedSession(session); + if (resumedSession != null) { + setSession(resumedSession); + } + } + /** * Helper to generate stream:features, populated simply from the session., * diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java index 81389891bd..2e5512c404 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java @@ -36,6 +36,7 @@ import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -45,6 +46,21 @@ */ public class StanzaHandlerTest { + @Test + public void adoptsSessionThatOwnsResumedSasl2Connection() + { + final Connection connection = mock(Connection.class); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + final LocalClientSession resumedSession = mock(LocalClientSession.class); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMED_SESSION)).thenReturn(resumedSession); + handler.setSession(connectionProvider); + + handler.adoptSasl2ResumedSession(); + + assertSame(resumedSession, handler.session); + } + @BeforeAll public static void setupClass() throws Exception { From cee2059b96ec151845ce7b54fbfae4cbb20ba991 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 12:34:12 -0400 Subject: [PATCH 09/25] Advertise stream management as SASL2 inline feature --- .../openfire/net/SASLAuthentication.java | 4 +- .../openfire/net/Sasl2InlineFeaturesTest.java | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2InlineFeaturesTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index b3cb90e8d5..14f56540ef 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -538,7 +538,9 @@ static Element asSASLMechanismsElementForClientSessions(@Nonnull final Set Date: Sat, 29 Aug 2026 12:36:10 -0400 Subject: [PATCH 10/25] Honor dynamic stream management state for Bind2 --- .../jivesoftware/openfire/SessionManager.java | 4 +- .../handler/Bind2StreamManagementHandler.java | 5 ++ .../openfire/net/Bind2InlineHandler.java | 9 +++ .../openfire/net/Bind2Request.java | 5 +- ...Bind2StreamManagementAvailabilityTest.java | 78 +++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java index ec1adcaef6..a6e6ecb377 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java @@ -1905,9 +1905,7 @@ public void start() throws IllegalStateException { localSessionManager.start(); // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. - if (StreamManager.isStreamManagementActive()) { - Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); - } + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); // 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. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java index 20b008c362..27276d7c29 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -46,6 +46,11 @@ public String getNamespace() { return StreamManager.NAMESPACE_V3; } + @Override + public boolean isEnabled() { + return StreamManager.isStreamManagementActive(); + } + /** * Handles an {@code } element from a Bind2 inline feature request by enabling * XEP-0198 stream management on the session. The {@code } response element diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java index 5c6f7ac08a..229761e6ba 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java @@ -31,6 +31,15 @@ public interface Bind2InlineHandler { */ String getNamespace(); + /** + * Indicates whether this handler is currently available for advertisement and request processing. + * + * @return {@code true} when the inline feature is available + */ + default boolean isEnabled() { + return true; + } + /** * Process an inline element from a bind2 request. * diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java index 5539c6fca0..3e6b3287e8 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java @@ -94,7 +94,7 @@ public Element processFeatureRequests(LocalClientSession clientSession, Element String namespace = element.getNamespaceURI(); Bind2InlineHandler handler = elementHandlers.get(namespace); - if (handler != null) { + if (handler != null && handler.isEnabled()) { try { if (!handler.handleElement(clientSession, bound, element)) { Log.warn("Handler for namespace {} failed to process element", namespace); @@ -115,6 +115,9 @@ public static Element featureElement() { Element bind2 = DocumentHelper.createElement(new QName("bind", new Namespace("", "urn:xmpp:bind:0"))); Element bind2inline = bind2.addElement("inline"); for (Bind2InlineHandler handler : elementHandlers.values()) { + if (!handler.isEnabled()) { + continue; + } Element var = bind2inline.addElement("feature"); var.addAttribute("var", handler.getNamespace()); } 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..84bc25119e --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java @@ -0,0 +1,78 @@ +/* + * 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 +{ + @BeforeAll + static void configureOpenfire() throws Exception + { + Fixtures.reconfigureOpenfireHome(); + Fixtures.disableDatabasePersistence(); + } + + @AfterAll + static void clearProperties() + { + Fixtures.clearExistingProperties(); + } + + @BeforeEach + void registerHandler() + { + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + } + + @AfterEach + void restoreState() + { + StreamManager.ACTIVE.setValue(true); + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + + @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"))); + } +} From d283f23c5c3806ce775430401cadc166498153d4 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 12:37:06 -0400 Subject: [PATCH 11/25] Report resumed full JID in SASL2 success --- .../openfire/net/SASLAuthentication.java | 8 +++- .../openfire/net/Sasl2SuccessTest.java | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 14f56540ef..620f640326 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -998,8 +998,9 @@ else if (session instanceof LocalIncomingServerSession serverSession) { if (resumeResult != null && resumeResult.isResumed()) { // Successful resumption supersedes resource binding and other inline requests. clientSession.removeSessionData("bind2-request"); - final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null, resumeResult.getResponse()); final LocalClientSession resumedSession = resumeResult.getResumedSession(); + final String resumedAuthorizationIdentity = authorizationIdentityForSasl2Success(authorizationIdentity, resumedSession.getAddress()); + final Element success = buildSasl2SuccessElement(successData, resumedAuthorizationIdentity, null, resumeResult.getResponse()); clientSession.setSessionData(SASL2_RESUMED_SESSION, resumedSession); resumedSession.deliverRawText(success.asXML()); return false; @@ -1091,6 +1092,11 @@ private static Element buildSasl2SuccessElement(byte[] successData, String autho return success; } + @VisibleForTesting + static String authorizationIdentityForSasl2Success(String authenticatedIdentity, @Nullable JID resumedAddress) { + return resumedAddress == null ? authenticatedIdentity : resumedAddress.toString(); + } + static LocalSession consumeSasl2ResumedSession(LocalSession connectionProvider) { return (LocalSession) connectionProvider.removeSessionData(SASL2_RESUMED_SESSION); } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java new file mode 100644 index 0000000000..064135ade1 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.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.net; + +import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class Sasl2SuccessTest +{ + @Test + void usesResumedFullJidAsAuthorizationIdentifier() + { + final JID resumedAddress = new JID("romeo", "example.org", "balcony"); + + final String result = SASLAuthentication.authorizationIdentityForSasl2Success("romeo@example.org", resumedAddress); + + assertEquals("romeo@example.org/balcony", result); + } + + @Test + void retainsAuthenticatedIdentityWithoutResumption() + { + final String result = SASLAuthentication.authorizationIdentityForSasl2Success("romeo@example.org", null); + + assertEquals("romeo@example.org", result); + } +} From 3dd4db7b4aa0fb0924150738d2577310fd4f3e67 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 12:56:56 -0400 Subject: [PATCH 12/25] Unify stream resumption validation --- .../streammanagement/StreamManager.java | 246 ++++++------------ 1 file changed, 85 insertions(+), 161 deletions(-) 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 b9e2673377..1b6acedd48 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -336,104 +336,14 @@ private Element enableInternal( String namespace, boolean resume ) private void startResume(String namespace, String previd, long h) { Log.debug("Attempting resumption for {}, h={}", previd, h); this.namespace = namespace; - // Ensure that resource binding has NOT occurred. - if (!allowResume() ) { - Log.debug("Unable to process session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); - sendUnexpectedError(); - return; - } - if (session.isAuthenticated()) { - Log.debug("Unable to process session resumption attempt, as session {} is not authenticated.", session); - sendUnexpectedError(); - return; - } - AuthToken authToken = null; - // Ensure that resource binding has occurred. - if (session instanceof ClientSession) { - authToken = ((LocalClientSession) session).getAuthToken(); - } - if (authToken == null) { - Log.debug("Unable to process session resumption attempt, as session {} does not provide any auth context.", session); - sendUnexpectedError(); + final ResumeValidationResult validation = validateResumeRequest(namespace, previd, h); + if (!validation.isValid()) { + sendError(new PacketError(validation.getFailureCondition())); return; } - // Decode previd. - String resource; - String streamId; - try { - StringTokenizer toks = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); - resource = toks.nextToken(); - streamId = toks.nextToken(); - } catch (Exception e) { - Log.debug("Exception from previd decode:", e); - sendUnexpectedError(); - return; - } - final JID fullJid; - if ( authToken.isAnonymous() ){ - fullJid = new JID(resource, session.getServerName(), resource, true); - } else { - fullJid = new JID(authToken.getUsername(), session.getServerName(), resource, true); - } - Log.debug("Resuming session for '{}'. Current session: {}", fullJid, session.getStreamID()); - // Locate existing session. - final ClientSession route = XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid); - if (route == null) { - Log.debug("Not able for client of '{}' to resume a session on this cluster node. No session was found for this client.", fullJid); - if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { - // When the client tries to resume a connection on this host, it is unlikely to try other hosts. Remove any detached sessions living elsewhere in the cluster. (OF-2753) - CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); - } - sendError(new PacketError(PacketError.Condition.item_not_found)); - return; - } - - if (!(route instanceof LocalClientSession)) { - Log.debug("Not allowing a client of '{}' to resume a session on this cluster node. The session can only be resumed on the Openfire cluster node where the original session was connected.", fullJid); - if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { - // When the client tries to resume a connection on this host, it is unlikely to try other hosts. Remove any detached sessions living elsewhere in the cluster. (OF-2753) - CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); - } - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } - - final LocalClientSession otherSession = (LocalClientSession) route; - if (!otherSession.getStreamID().getID().equals(streamId)) { - sendError(new PacketError(PacketError.Condition.item_not_found)); - return; - } - Log.debug("Found existing session for '{}', checking status", fullJid); - - // OF-2811: Cannot resume a session that's already closed. That session is likely busy firing its 'closeListeners'. - if (route.isClosed()) { - Log.debug("Not allowing a client of '{}' to resume a session, as the preexisting session is already in process of being closed.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } - - // Previd identifies proper session. Now check SM status - if (!otherSession.getStreamManager().resume) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed does not have the stream management resumption feature enabled.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } - if (otherSession.getStreamManager().namespace == null) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } - if (!otherSession.getStreamManager().namespace.equals(namespace)) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed used a different version ({}) of the session management resumption feature as compared to the version that's requested now: {}.", fullJid, otherSession.getStreamManager().namespace, namespace); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } - if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { - Log.debug("Not allowing a client of '{}' to resume a session, as it reports it received more stanzas from us than that we've send it.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; - } + final LocalClientSession otherSession = validation.getSession(); + final JID fullJid = validation.getFullJid(); if (!otherSession.isDetached()) { Log.debug("Existing session {} of '{}' is not detached; detaching.", otherSession.getStreamID(), fullJid); Connection oldConnection = otherSession.getConnection(); @@ -478,97 +388,111 @@ public Sasl2ResumeResult processSasl2Resume(Element resumeElement) { return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); } - // Ensure that resource binding has NOT occurred. - if (!allowResume()) { - Log.debug("Unable to process SASL2 session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + final ResumeValidationResult validation = validateResumeRequest(namespace, previd, h); + if (!validation.isValid()) { + return Sasl2ResumeResult.failed(namespace, validation.getFailureCondition()); } - if (session.isAuthenticated()) { - Log.debug("Unable to process SASL2 session resumption attempt, as session {} is already authenticated.", session); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + + final LocalClientSession otherSession = validation.getSession(); + final JID fullJid = validation.getFullJid(); + if (!otherSession.isDetached()) { + Log.debug("Existing session {} of '{}' is not detached (SASL2 resume); detaching.", otherSession.getStreamID(), fullJid); + Connection oldConnection = otherSession.getConnection(); + otherSession.setDetached(); + assert oldConnection != null; + oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); } - AuthToken authToken = null; - if (session instanceof ClientSession) { - authToken = ((LocalClientSession) session).getAuthToken(); + Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); + final Element resumed = otherSession.reattachForSasl2(session, h); + Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); + return Sasl2ResumeResult.resumed(resumed, otherSession); + } + + private ResumeValidationResult validateResumeRequest(String namespace, String previd, long h) { + if (!allowResume() || session.isAuthenticated()) { + Log.debug("Unable to process session resumption attempt, as session {} is in a state where resumption is not allowed.", session); + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); } + + final AuthToken authToken = session instanceof LocalClientSession ? ((LocalClientSession) session).getAuthToken() : null; if (authToken == null) { - Log.debug("Unable to process SASL2 session resumption attempt, as session {} does not provide any auth context.", session); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + Log.debug("Unable to process session resumption attempt, as session {} does not provide any auth context.", session); + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); } - // Decode previd. - String resource; - String streamId; + final String resource; + final String streamId; try { - StringTokenizer toks = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); - resource = toks.nextToken(); - streamId = toks.nextToken(); + final StringTokenizer tokens = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); + resource = tokens.nextToken(); + streamId = tokens.nextToken(); } catch (Exception e) { - Log.debug("Exception from previd decode in SASL2 resume:", e); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); + Log.debug("Unable to decode SM previd for session {}.", session, e); + return ResumeValidationResult.failed(PacketError.Condition.bad_request); } - final JID fullJid; - if (authToken.isAnonymous()) { - fullJid = new JID(resource, session.getServerName(), resource, true); - } else { - fullJid = new JID(authToken.getUsername(), session.getServerName(), resource, true); - } - Log.debug("SASL2 resuming session for '{}'. Current session: {}", fullJid, session.getStreamID()); - + final JID fullJid = authToken.isAnonymous() + ? new JID(resource, session.getServerName(), resource, true) + : new JID(authToken.getUsername(), session.getServerName(), resource, true); final ClientSession route = XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid); - if (route == null) { - Log.debug("Not able for client of '{}' to resume a session (SASL2) on this cluster node. No session was found.", fullJid); - if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { - CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); - } - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); - } if (!(route instanceof LocalClientSession)) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2) on this cluster node. The session can only be resumed on the original cluster node.", fullJid); + Log.debug("Unable to resume '{}' on this cluster node because no local session was found.", fullJid); if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); } - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); + return ResumeValidationResult.failed(PacketError.Condition.item_not_found); } - final LocalClientSession otherSession = (LocalClientSession) route; - if (!otherSession.getStreamID().getID().equals(streamId)) { - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.item_not_found); + final LocalClientSession resumableSession = (LocalClientSession) route; + if (!resumableSession.getStreamID().getID().equals(streamId)) { + return ResumeValidationResult.failed(PacketError.Condition.item_not_found); + } + if (resumableSession.isClosed() + || !resumableSession.getStreamManager().resume + || resumableSession.getStreamManager().namespace == null + || !resumableSession.getStreamManager().namespace.equals(namespace)) { + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); + } + if (!resumableSession.getStreamManager().validateClientAcknowledgement(h)) { + return ResumeValidationResult.failed(PacketError.Condition.undefined_condition); } - Log.debug("Found existing session for '{}' (SASL2 resume), checking status", fullJid); + return ResumeValidationResult.valid(resumableSession, fullJid); + } + + private static final class ResumeValidationResult { + private final LocalClientSession session; + private final JID fullJid; + private final PacketError.Condition failureCondition; - if (route.isClosed()) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as the preexisting session is already in process of being closed.", fullJid); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + private ResumeValidationResult(LocalClientSession session, JID fullJid, PacketError.Condition failureCondition) { + this.session = session; + this.fullJid = fullJid; + this.failureCondition = failureCondition; } - if (!otherSession.getStreamManager().resume) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed does not have the stream management resumption feature enabled.", fullJid); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + + static ResumeValidationResult valid(LocalClientSession session, JID fullJid) { + return new ResumeValidationResult(session, fullJid, null); } - if (otherSession.getStreamManager().namespace == null) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + + static ResumeValidationResult failed(PacketError.Condition condition) { + return new ResumeValidationResult(null, null, condition); } - if (!otherSession.getStreamManager().namespace.equals(namespace)) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2), namespace mismatch: {} vs {}.", fullJid, otherSession.getStreamManager().namespace, namespace); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.unexpected_request); + + boolean isValid() { + return session != null; } - if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { - Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as it reports it received more stanzas from us than that we've sent it.", fullJid); - return Sasl2ResumeResult.failed(namespace, PacketError.Condition.undefined_condition); + + LocalClientSession getSession() { + return session; } - if (!otherSession.isDetached()) { - Log.debug("Existing session {} of '{}' is not detached (SASL2 resume); detaching.", otherSession.getStreamID(), fullJid); - Connection oldConnection = otherSession.getConnection(); - otherSession.setDetached(); - assert oldConnection != null; - oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + + JID getFullJid() { + return fullJid; + } + + PacketError.Condition getFailureCondition() { + return failureCondition; } - Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); - final Element resumed = otherSession.reattachForSasl2(session, h); - Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); - return Sasl2ResumeResult.resumed(resumed, otherSession); } public static final class Sasl2ResumeResult { From 226a0da15de2763e49c827a7fdffa613e61fb195 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 12:58:16 -0400 Subject: [PATCH 13/25] Enable stream management after Bind2 authentication --- .../openfire/net/SASLAuthentication.java | 4 +-- .../openfire/net/SASLAuthenticationTest.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 620f640326..8ce39bc95b 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -1022,11 +1022,9 @@ else if (session instanceof LocalIncomingServerSession serverSession) { } final boolean bound = throwable == null && result == SessionManager.BindResult.BOUND; final Element success = buildSasl2SuccessElement(finalSuccessData, finalAuthorizationIdentity, bound ? resource : null, resumeResponse); - if (bound) { - bind2Request.processFeatureRequests(clientSession, success); - } if (bound) { clientSession.setStatus(Session.Status.AUTHENTICATED); + bind2Request.processFeatureRequests(clientSession, success); SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound); } // Deliver stream features now that has been sent. 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 cf9faea71b..1f1fc7fd08 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -34,6 +34,7 @@ import org.jivesoftware.openfire.session.LocalSession; import org.jivesoftware.openfire.session.ServerSession; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.util.JiveGlobals; @@ -564,6 +565,40 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A } } + @Test + public void shouldEnableStreamManagementInlineWithSasl2AndBind2() throws Exception + { + try (final MockedStatic mockedEntityCaps = mockStatic(EntityCapabilitiesManager.class)) { + mockedEntityCaps.when(() -> EntityCapabilitiesManager.getLocalDomainVerHash(any())).thenReturn(null); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Connection connection = mock(Connection.class); + final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class); + when(configuration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(configuration.getCompressionPolicy()).thenReturn(Connection.CompressionPolicy.disabled); + when(connection.getConfiguration()).thenReturn(configuration); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + session.setSessionData("bind2-request", new Bind2Request("test-client", List.of(enable))); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + assertTrue(session.getStreamManager().isEnabled()); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + } + /** * Verifies that authenticationSuccessful marks the domain as validated for an inbound server session. */ From b3d7d2de19416e77bb29d2bfd0870ac1692f9aed Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:00:27 -0400 Subject: [PATCH 14/25] Skip stream features after inline resumption --- .../openfire/net/StanzaHandler.java | 4 ++- .../openfire/session/LocalSession.java | 5 ++-- .../streammanagement/StreamManager.java | 17 ++++++----- .../WebSocketClientStanzaHandler.java | 4 ++- .../openfire/net/StanzaHandlerTest.java | 29 +++++++++++++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index 212d6a222e..105dd8457f 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -587,9 +587,11 @@ protected void saslSuccessful() { * (e.g. RFC 7395 WebSocket) override this. */ protected void sasl2Successful() { + if (session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true))) { + return; + } final Element features = generateFeatures(); connection.deliverRawText(features.asXML()); - session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } void adoptSasl2ResumedSession() { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index bd3a805208..1332ff1752 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -233,9 +233,8 @@ public Element reattachForSasl2(LocalSession connectionProvider, long h) { // Build the element but do NOT send it — the caller will embed it in . final Element resumed = this.streamManager.buildResumedElement(); this.streamManager.processClientAcknowledgementPublic(h); - // Redelivery of unacked stanzas must happen AFTER stream features are sent following . - // We only set the pending flag here; the code path that sends those features must call - // StreamManager#redeliverIfPendingSasl2() immediately afterwards. + // Redelivery happens immediately after . Stream features must not be sent for a successfully + // resumed stream, so the stanza handler consumes this flag before considering feature delivery. this.streamManager.setPendingSasl2Redelivery(true); this.sessionManager.removeSession((LocalClientSession) connectionProvider); return resumed; 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 1b6acedd48..aeb625883e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -146,9 +146,9 @@ public static Element featureElement() { /** * Set to {@code true} when a SASL2-inline stream resumption has been processed but unacknowledged - * stanzas have not yet been redelivered. The redelivery must happen after stream features are sent - * following the SASL2 {@code }, so {@link StanzaHandler} calls - * {@link #redeliverIfPendingSasl2(JID)} at that point. + * stanzas have not yet been redelivered. A successfully resumed stream is re-established immediately + * after SASL2 {@code }; {@link StanzaHandler} calls {@link #redeliverIfPendingSasl2(JID)} + * instead of sending post-authentication stream features. */ private volatile boolean pendingSasl2Redelivery = false; @@ -795,7 +795,7 @@ public void processClientAcknowledgementPublic(long h) { * Sets the pending SASL2 redelivery flag. When {@code true}, {@link #redeliverIfPendingSasl2(JID)} * will redeliver unacknowledged stanzas. Called by * {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2} to defer redelivery - * until after stream features have been sent. + * until after the enclosing SASL2 {@code } has been sent. * * @param pending whether redelivery is pending */ @@ -805,16 +805,19 @@ public void setPendingSasl2Redelivery(boolean pending) { /** * If a SASL2-inline stream resumption is pending redelivery, redelivers unacknowledged stanzas - * now. This must be called by {@link org.jivesoftware.openfire.net.StanzaHandler} after stream - * features have been sent following the SASL2 {@code }. + * now. This is called after SASL2 {@code }; a {@code true} result tells the stanza handler + * that stream features must not be sent for the resumed stream. * * @param serverAddress the server's JID, used to stamp delayed stanzas + * @return {@code true} when this call completed an inline resumption and redelivered its queued stanzas */ - public void redeliverIfPendingSasl2(JID serverAddress) { + public boolean redeliverIfPendingSasl2(JID serverAddress) { if (pendingSasl2Redelivery) { pendingSasl2Redelivery = false; redeliverUnackedStanzas(serverAddress); + return true; } + return false; } /** diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java index 91dabfe549..9826565f73 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java @@ -209,8 +209,10 @@ protected void saslSuccessful() { */ @Override protected void sasl2Successful() { + if (session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true))) { + return; + } sendStreamFeatures(); - session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); } protected boolean isStartOfStream(final String xml) { diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java index 2e5512c404..f7096ca475 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java @@ -15,6 +15,8 @@ */ package org.jivesoftware.openfire.net; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; import org.dom4j.io.XMPPPacketReader; import org.jivesoftware.Fixtures; import org.jivesoftware.openfire.Connection; @@ -39,6 +41,8 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -61,6 +65,31 @@ public void adoptsSessionThatOwnsResumedSasl2Connection() assertSame(resumedSession, handler.session); } + @Test + public void successfulInlineResumptionDoesNotSendStreamFeatures() + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn(Fixtures.XMPP_DOMAIN); + final org.jivesoftware.openfire.streammanagement.StreamManager streamManager = + new org.jivesoftware.openfire.streammanagement.StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + streamManager.setPendingSasl2Redelivery(true); + final Element features = DocumentHelper.createElement("features"); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { + @Override + protected Element generateFeatures() { + return features; + } + }; + handler.setSession(session); + + handler.sasl2Successful(); + + verify(connection, never()).deliverRawText(features.asXML()); + } + @BeforeAll public static void setupClass() throws Exception { From 339054623d01fbbc8dea5fede675c303b90517b9 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:01:01 -0400 Subject: [PATCH 15/25] Test Bind2 fallback after failed stream resumption --- .../openfire/net/SASLAuthenticationTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 1f1fc7fd08..b4fd167b8a 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -599,6 +599,45 @@ public void shouldEnableStreamManagementInlineWithSasl2AndBind2() throws Excepti } } + @Test + public void failedInlineResumptionContinuesWithBind2AndFreshStreamManagement() throws Exception + { + try (final MockedStatic mockedEntityCaps = mockStatic(EntityCapabilitiesManager.class)) { + mockedEntityCaps.when(() -> EntityCapabilitiesManager.getLocalDomainVerHash(any())).thenReturn(null); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Connection connection = mock(Connection.class); + final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class); + when(configuration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(configuration.getCompressionPolicy()).thenReturn(Connection.CompressionPolicy.disabled); + when(connection.getConfiguration()).thenReturn(configuration); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", "invalid"); + resume.addAttribute("h", "0"); + session.setSessionData(SASLAuthentication.SASL2_RESUME_REQUEST, resume); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + session.setSessionData("bind2-request", new Bind2Request("test-client", List.of(enable))); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + assertNotNull(success.element(QName.get("failed", StreamManager.NAMESPACE_V3))); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + assertTrue(session.getStreamManager().isEnabled()); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + } + /** * Verifies that authenticationSuccessful marks the domain as validated for an inbound server session. */ From 7383a6258191003d4a4f787b2e8a27ebefdea598 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:02:22 -0400 Subject: [PATCH 16/25] Embed Bind2 stream management failures --- .../handler/Bind2StreamManagementHandler.java | 6 ++--- .../streammanagement/StreamManager.java | 26 +++++++++---------- .../Bind2StreamManagementHandlerTest.java | 15 +++++++++++ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java index 27276d7c29..efc60b373e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -74,9 +74,9 @@ public boolean handleElement(LocalClientSession session, Element bound, Element final String namespace = element.getNamespaceURI(); final String resumeAttr = element.attributeValue("resume"); final boolean resume = "true".equalsIgnoreCase(resumeAttr) || "1".equals(resumeAttr) || "yes".equalsIgnoreCase(resumeAttr); - final Element enabled = session.getStreamManager().enableAndBuildElement(namespace, resume); - if (enabled != null) { - bound.add(enabled); + final Element outcome = session.getStreamManager().enableAndBuildElement(namespace, resume); + if (outcome != null) { + bound.add(outcome); return true; } return false; 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 aeb625883e..b67d1076b8 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -262,21 +262,19 @@ private boolean allowResume() { */ private void enable( String namespace, boolean resume ) { - final Element enabled = enableInternal(namespace, resume); - if (enabled != null) { - session.deliverRawText(enabled.asXML()); - } + session.deliverRawText(enableInternal(namespace, resume).asXML()); } /** * Enables stream management and returns the {@code } element without sending it. * This allows callers (e.g. the SASL2 Bind2 handler) to embed the element in another stanza. * - *

Returns {@code null} if enabling failed (an error stanza will have been sent already).

+ *

The returned element is either {@code } or {@code }. It is never sent by this method, + * allowing an inline caller to embed either outcome in its enclosing response.

* * @param namespace the SM namespace to use * @param resume whether the client requests a resumable session - * @return the {@code } element, or {@code null} on failure + * @return the {@code } or {@code } outcome element */ public Element enableAndBuildElement( String namespace, boolean resume ) { @@ -288,9 +286,7 @@ private Element enableInternal( String namespace, boolean resume ) boolean offerResume = allowResume(); // Ensure that resource binding has occurred. if (!session.isAuthenticated()) { - this.namespace = namespace; - sendUnexpectedError(); - return null; + return buildFailedElement(namespace, PacketError.Condition.unexpected_request); } String smId = null; @@ -300,8 +296,7 @@ private Element enableInternal( String namespace, boolean resume ) // Do nothing if already enabled if ( isEnabled() ) { - sendUnexpectedError(); - return null; + return buildFailedElement(namespace, PacketError.Condition.unexpected_request); } this.namespace = namespace; @@ -577,12 +572,17 @@ 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. } + private static Element buildFailedElement(String namespace, 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/Bind2StreamManagementHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java index 95ead3e4ea..dae251ccf8 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -146,6 +146,21 @@ public void testHandleEnableElementWhenEnableFails() { assertTrue(boundElement.elements().isEmpty(), "No element should be added to on failure"); } + @Test + public void testEnableFailureIsEmbeddedInBoundWithoutStandaloneDelivery() { + final StreamManager streamManager = new StreamManager(mockSession); + when(mockSession.getStreamManager()).thenReturn(streamManager); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + final boolean result = handler.handleElement(mockSession, boundElement, enable); + + assertTrue(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"))); + verify(mockSession, never()).deliverRawText(anyString()); + } + @Test public void testHandleNonEnableElementIsIgnored() { // Setup: send an unexpected element name From f901fc8c6501738a865edebe9ddd09bd7c3bbfd7 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:02:53 -0400 Subject: [PATCH 17/25] Restore Bind2 handler availability tests --- .../jivesoftware/openfire/net/Bind2RequestProcessingTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java index 63122872bf..fdfba540df 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java @@ -88,6 +88,8 @@ public void setUp() { when(mockHandler1.getNamespace()).thenReturn("http://test1.namespace"); when(mockHandler2.getNamespace()).thenReturn("http://test2.namespace"); + when(mockHandler1.isEnabled()).thenReturn(true); + when(mockHandler2.isEnabled()).thenReturn(true); when(mockHandler1.handleElement(any(), any(), any())).thenReturn(true); when(mockHandler2.handleElement(any(), any(), any())).thenReturn(true); } From 6f06402eea42df7eb40abe335387e1904209f86e Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:04:21 -0400 Subject: [PATCH 18/25] Test inline resumption replay across transports --- .../openfire/net/StanzaHandlerTest.java | 42 +++++++++++++++++++ .../WebSocketClientStanzaHandlerTest.java | 24 +++++++++++ 2 files changed, 66 insertions(+) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java index f7096ca475..0bd872e442 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java @@ -27,11 +27,14 @@ import org.jivesoftware.openfire.sasl.AnonymousSaslServer; import org.jivesoftware.openfire.session.LocalClientSession; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.util.JiveGlobals; 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 org.xmpp.packet.Message; +import org.xmpp.packet.Packet; import java.util.Arrays; import java.util.Locale; @@ -39,7 +42,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentCaptor.forClass; +import org.mockito.ArgumentCaptor; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -90,6 +96,42 @@ protected Element generateFeatures() { verify(connection, never()).deliverRawText(features.asXML()); } + @Test + public void successfulInlineResumptionPrunesAcknowledgedStanzasAndReplaysTheRemainder() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn(Fixtures.XMPP_DOMAIN); + when(session.isAuthenticated()).thenReturn(true); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + streamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + final Message acknowledged = new Message(); + acknowledged.setID("acknowledged"); + final Message unacknowledged = new Message(); + unacknowledged.setID("unacknowledged"); + streamManager.sentStanza(acknowledged); + streamManager.sentStanza(unacknowledged); + streamManager.process(DocumentHelper.parseText("").getRootElement()); + streamManager.setPendingSasl2Redelivery(true); + final Element features = DocumentHelper.createElement("features"); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { + @Override + protected Element generateFeatures() { + return features; + } + }; + handler.setSession(session); + + handler.sasl2Successful(); + + final ArgumentCaptor replayed = forClass(Packet.class); + verify(connection).deliver(replayed.capture()); + assertEquals("unacknowledged", replayed.getValue().getID()); + verify(connection, never()).deliverRawText(features.asXML()); + } + @BeforeAll public static void setupClass() throws Exception { diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java index 14c39a171a..4cdd07af97 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java @@ -16,11 +16,16 @@ package org.jivesoftware.openfire.websocket; import org.dom4j.*; +import org.jivesoftware.openfire.PacketRouter; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; /** * Verifies the implementation of {@link WebSocketClientStanzaHandler} @@ -29,6 +34,25 @@ */ public class WebSocketClientStanzaHandlerTest { + @Test + public void successfulInlineResumptionDoesNotSendWebSocketStreamFeatures() + { + final WebSocketConnection connection = mock(WebSocketConnection.class); + final LocalClientSession session = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn("example.org"); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + streamManager.setPendingSasl2Redelivery(true); + final WebSocketClientStanzaHandler handler = + new WebSocketClientStanzaHandler(mock(PacketRouter.class), connection); + handler.setSession(session); + + handler.sasl2Successful(); + + verify(connection, never()).deliverRawText(anyString()); + } + /** * It is desired to collapse the 'open' element that's send as part of the websocket data exchange. This test * verifies that the {@link WebSocketClientStanzaHandler#withoutDeclaration(Document)} does not return an expanded From 850ee408d9a782b0feb3bc70f505b7beacaddae2 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:05:15 -0400 Subject: [PATCH 19/25] Test SASL2 resumed session ownership transfer --- .../session/LocalSessionSasl2ResumeTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java new file mode 100644 index 0000000000..d923cb0fb3 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java @@ -0,0 +1,66 @@ +/* + * 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 + */ +package org.jivesoftware.openfire.session; + +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.SessionManager; +import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.auth.AuthToken; +import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.*; + +class LocalSessionSasl2ResumeTest +{ + private SessionManager sessionManager; + + @BeforeEach + void setUp() + { + final XMPPServer server = Fixtures.mockXMPPServer(); + XMPPServer.setInstance(server); + sessionManager = server.getSessionManager(); + } + + @Test + void transfersConnectionOwnershipAndRemovesTheProviderSession() + { + final Connection oldConnection = mock(Connection.class); + when(oldConnection.isClosed()).thenReturn(true); + final LocalClientSession resumed = new LocalClientSession(Fixtures.XMPP_DOMAIN, oldConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + resumed.setAddress(new JID("romeo", Fixtures.XMPP_DOMAIN, "balcony")); + resumed.setAuthToken(AuthToken.generateUserToken("romeo")); + resumed.setStatus(Session.Status.AUTHENTICATED); + resumed.getStreamManager().enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + + final Connection newConnection = mock(Connection.class); + final LocalClientSession connectionProvider = new LocalClientSession(Fixtures.XMPP_DOMAIN, newConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + + resumed.reattachForSasl2(connectionProvider, 0); + + assertEquals(newConnection, resumed.getConnection()); + assertNull(connectionProvider.getConnection()); + assertEquals(Session.Status.AUTHENTICATED, resumed.getStatus()); + verify(newConnection).reinit(resumed); + verify(sessionManager).removeDetached(resumed); + verify(sessionManager, atLeastOnce()).removeSession(connectionProvider); + } +} From 9339f0b58b7e3d1d43660e7f2077d01b4e8165ff Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 13:05:55 -0400 Subject: [PATCH 20/25] Test successful inline stream resumption --- .../session/LocalSessionSasl2ResumeTest.java | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java index d923cb0fb3..c159aba90d 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java @@ -9,10 +9,14 @@ */ package org.jivesoftware.openfire.session; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.QName; import org.jivesoftware.Fixtures; import org.jivesoftware.openfire.Connection; import org.jivesoftware.openfire.SessionManager; import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.RoutingTable; import org.jivesoftware.openfire.auth.AuthToken; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; import org.jivesoftware.openfire.streammanagement.StreamManager; @@ -24,20 +28,56 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; class LocalSessionSasl2ResumeTest { + private XMPPServer server; private SessionManager sessionManager; @BeforeEach void setUp() { - final XMPPServer server = Fixtures.mockXMPPServer(); + server = Fixtures.mockXMPPServer(); XMPPServer.setInstance(server); sessionManager = server.getSessionManager(); } + @Test + void validatesAndTransfersAResumedSessionFromTheInlineRequest() + { + final Connection oldConnection = mock(Connection.class); + final LocalClientSession resumed = new LocalClientSession(Fixtures.XMPP_DOMAIN, oldConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + resumed.setAddress(new JID("romeo", Fixtures.XMPP_DOMAIN, "balcony")); + resumed.setAuthToken(AuthToken.generateUserToken("romeo")); + resumed.setStatus(Session.Status.AUTHENTICATED); + final Element enabled = resumed.getStreamManager().enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + when(sessionManager.isDetached(resumed)).thenReturn(true); + final RoutingTable routingTable = mock(RoutingTable.class); + when(server.getRoutingTable()).thenReturn(routingTable); + when(routingTable.getClientRoute(resumed.getAddress())).thenReturn(resumed); + + final Connection newConnection = mock(Connection.class); + final LocalClientSession connectionProvider = new LocalClientSession(Fixtures.XMPP_DOMAIN, newConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + connectionProvider.setAuthToken(AuthToken.generateUserToken("romeo")); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", enabled.attributeValue("id")); + resume.addAttribute("h", "0"); + + final StreamManager.Sasl2ResumeResult result = connectionProvider.getStreamManager().processSasl2Resume(resume); + + assertTrue(result.isResumed()); + assertEquals(resumed, result.getResumedSession()); + assertEquals("resumed", result.getResponse().getName()); + assertEquals(enabled.attributeValue("id"), result.getResponse().attributeValue("previd")); + assertEquals("0", result.getResponse().attributeValue("h")); + assertEquals(newConnection, resumed.getConnection()); + verify(newConnection).reinit(resumed); + } + @Test void transfersConnectionOwnershipAndRemovesTheProviderSession() { From fe18aa906e80fef0e6e28d6d9313f45c1e569bbf Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 15:04:39 -0400 Subject: [PATCH 21/25] Guarantee Bind2 stream management failure responses --- .../handler/Bind2StreamManagementHandler.java | 10 +++++++++ .../openfire/net/Bind2InlineHandler.java | 12 ++++++++++ .../openfire/net/Bind2Request.java | 2 ++ .../Bind2StreamManagementHandlerTest.java | 22 +++++++++++++++++++ .../net/Bind2RequestProcessingTest.java | 2 ++ 5 files changed, 48 insertions(+) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java index efc60b373e..d39143fd95 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -21,6 +21,7 @@ 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 @@ -81,4 +82,13 @@ public boolean handleElement(LocalClientSession session, Element bound, Element } return false; } + + @Override + public void handleFailure(LocalClientSession session, Element bound, Element element, Throwable cause) { + final PacketError.Condition condition = cause == null + ? PacketError.Condition.bad_request + : PacketError.Condition.internal_server_error; + final Element failed = bound.addElement("failed", StreamManager.NAMESPACE_V3); + failed.addElement(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"); + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java index 229761e6ba..3901e820d3 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java @@ -48,4 +48,16 @@ default boolean isEnabled() { * @return true if the element was handled successfully, false otherwise */ boolean handleElement(LocalClientSession session, Element bound, Element element); + + /** + * Gives a handler an opportunity to add the protocol-defined failure response after request processing failed. + * + * @param session the client session + * @param bound the Bind2 response element + * @param element the request that could not be processed + * @param cause the processing exception, or {@code null} when the handler returned {@code false} + */ + default void handleFailure(LocalClientSession session, Element bound, Element element, Throwable cause) { + // Most inline extensions do not define a failure response. + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java index 3e6b3287e8..d40b370c7c 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java @@ -98,9 +98,11 @@ public Element processFeatureRequests(LocalClientSession clientSession, Element try { if (!handler.handleElement(clientSession, bound, element)) { Log.warn("Handler for namespace {} failed to process element", namespace); + handler.handleFailure(clientSession, bound, element, null); } } catch (Exception e) { Log.error("Error processing element with namespace: " + namespace, e); + handler.handleFailure(clientSession, bound, element, e); } } else { Log.debug("No handler registered for namespace: {}", namespace); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java index dae251ccf8..922af7a261 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -175,4 +175,26 @@ public void testHandleNonEnableElementIsIgnored() { 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/Bind2RequestProcessingTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java index fdfba540df..8501fdca68 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java @@ -194,6 +194,7 @@ public void testProcessFeatureRequestsWithHandlerException() { assertNotNull(result); verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler1).handleFailure(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1), any(RuntimeException.class)); verify(mockHandler2).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); } @@ -208,6 +209,7 @@ public void testProcessFeatureRequestsWithHandlerReturnsFalse() { assertNotNull(result); verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler1).handleFailure(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1), isNull()); } @Test From 5d42ce1d6849ee3bd1023bd308a861d1a5b3a261 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 15:06:34 -0400 Subject: [PATCH 22/25] Fail SASL2 when Bind2 resource binding fails --- .../openfire/net/SASLAuthentication.java | 13 +++-- .../openfire/net/SASLAuthenticationTest.java | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 8ce39bc95b..200e4666ca 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -1021,12 +1021,15 @@ else if (session instanceof LocalIncomingServerSession serverSession) { Log.warn("An exception occurred while binding resource '{}' for session '{}' during SASL2+Bind2 authentication.", resource, clientSession, throwable); } final boolean bound = throwable == null && result == SessionManager.BindResult.BOUND; - final Element success = buildSasl2SuccessElement(finalSuccessData, finalAuthorizationIdentity, bound ? resource : null, resumeResponse); - if (bound) { - clientSession.setStatus(Session.Status.AUTHENTICATED); - bind2Request.processFeatureRequests(clientSession, success); - SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound); + if (!bound) { + Log.warn("Unable to bind resource '{}' for session '{}' during SASL2+Bind2 authentication. Bind result: {}", resource, clientSession, result); + authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); + return; } + final Element success = buildSasl2SuccessElement(finalSuccessData, finalAuthorizationIdentity, resource, resumeResponse); + clientSession.setStatus(Session.Status.AUTHENTICATED); + bind2Request.processFeatureRequests(clientSession, success); + 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(); 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 b4fd167b8a..22ed7269fe 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -565,6 +565,54 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A } } + @Test + public void bind2ConflictFailsSasl2WithoutSuccessOrFeatures() throws Exception + { + final Connection connection = mock(Connection.class); + 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("conflicting-resource"); + session.setSessionData("bind2-request", bind2Request); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.CONFLICT)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName()); + assertEquals(SASL2_NAMESPACE, failure.getNamespaceURI()); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASL_NAMESPACE))); + verify(bind2Request, never()).processFeatureRequests(any(), any()); + assertFalse(session.isAuthenticated()); + } + + @Test + public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() throws Exception + { + final Connection connection = mock(Connection.class); + 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); + final CompletableFuture failedBind = new CompletableFuture<>(); + failedBind.completeExceptionally(new IllegalStateException("test failure")); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())).thenReturn(failedBind); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName()); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASL_NAMESPACE))); + verify(bind2Request, never()).processFeatureRequests(any(), any()); + assertFalse(session.isAuthenticated()); + } + @Test public void shouldEnableStreamManagementInlineWithSasl2AndBind2() throws Exception { From e430fbd937c732c51b8399d95d3eb262718ecc36 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 15:09:29 -0400 Subject: [PATCH 23/25] Encapsulate SASL2 stream resumption completion --- .../openfire/net/SASLAuthentication.java | 8 +- .../openfire/net/StanzaHandler.java | 18 ++-- .../openfire/session/LocalSession.java | 11 +-- .../streammanagement/StreamManager.java | 72 +++++---------- .../WebSocketClientStanzaHandler.java | 6 +- .../openfire/net/StanzaHandlerTest.java | 20 +++-- .../session/LocalSessionSasl2ResumeTest.java | 2 +- .../streammanagement/StreamManagerTest.java | 88 +++++-------------- .../WebSocketClientStanzaHandlerTest.java | 26 ++++-- 9 files changed, 103 insertions(+), 148 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 200e4666ca..7c00572a71 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -89,7 +89,7 @@ public class SASLAuthentication { private static final Logger Log = LoggerFactory.getLogger(SASLAuthentication.class); static final String SASL2_RESUME_REQUEST = "sasl2-resume-request"; - static final String SASL2_RESUMED_SESSION = "sasl2-resumed-session"; + static final String SASL2_RESUMPTION_RESULT = "sasl2-resumption-result"; // TODO how is this different from a singular entry in APPROVED_REALMS? Should these two properties be folded into eachother? public static final SystemProperty REALM = SystemProperty.Builder.ofType(String.class) @@ -1001,7 +1001,7 @@ else if (session instanceof LocalIncomingServerSession serverSession) { final LocalClientSession resumedSession = resumeResult.getResumedSession(); final String resumedAuthorizationIdentity = authorizationIdentityForSasl2Success(authorizationIdentity, resumedSession.getAddress()); final Element success = buildSasl2SuccessElement(successData, resumedAuthorizationIdentity, null, resumeResult.getResponse()); - clientSession.setSessionData(SASL2_RESUMED_SESSION, resumedSession); + clientSession.setSessionData(SASL2_RESUMPTION_RESULT, resumeResult); resumedSession.deliverRawText(success.asXML()); return false; } @@ -1098,8 +1098,8 @@ static String authorizationIdentityForSasl2Success(String authenticatedIdentity, return resumedAddress == null ? authenticatedIdentity : resumedAddress.toString(); } - static LocalSession consumeSasl2ResumedSession(LocalSession connectionProvider) { - return (LocalSession) connectionProvider.removeSessionData(SASL2_RESUMED_SESSION); + static StreamManager.Sasl2ResumeResult consumeSasl2ResumptionResult(LocalSession connectionProvider) { + return (StreamManager.Sasl2ResumeResult) connectionProvider.removeSessionData(SASL2_RESUMPTION_RESULT); } private static void authenticationFailed(LocalSession session, Failure failure, boolean usingSASL2) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index 105dd8457f..1ee841032c 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -105,6 +105,8 @@ public abstract class StanzaHandler { */ protected LocalSession session; + private StreamManager.Sasl2ResumeResult sasl2Resumption; + /** * Router used to route incoming packets to the correct channels. */ @@ -587,17 +589,23 @@ protected void saslSuccessful() { * (e.g. RFC 7395 WebSocket) override this. */ protected void sasl2Successful() { - if (session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true))) { + if (sasl2Resumption != null && sasl2Resumption.completeAfterSuccess()) { + sasl2Resumption = null; return; } + deliverSasl2Features(); + } + + protected void deliverSasl2Features() { final Element features = generateFeatures(); connection.deliverRawText(features.asXML()); } - void adoptSasl2ResumedSession() { - final LocalSession resumedSession = SASLAuthentication.consumeSasl2ResumedSession(session); - if (resumedSession != null) { - setSession(resumedSession); + protected void adoptSasl2ResumedSession() { + final StreamManager.Sasl2ResumeResult result = SASLAuthentication.consumeSasl2ResumptionResult(session); + if (result != null && result.isResumed()) { + sasl2Resumption = result; + setSession(result.getResumedSession()); } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index 1332ff1752..523f468267 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -212,10 +212,8 @@ public void reattach(LocalSession connectionProvider, long h) { * embedding it in the SASL2 {@code } element before delivering it. * * @param connectionProvider the new (unauthenticated) session whose connection will be taken over - * @param h the client's acknowledgement counter - * @return the {@code } element to be embedded in the SASL2 {@code } */ - public Element reattachForSasl2(LocalSession connectionProvider, long h) { + public void reattachForSasl2(LocalSession connectionProvider) { lock.lock(); try { Log.debug("Reattaching (SASL2) session with address {} and streamID {} using connection from session with address {} and streamID {}.", this.address, this.streamID, connectionProvider.getAddress(), connectionProvider.getStreamID()); @@ -230,14 +228,7 @@ public Element reattachForSasl2(LocalSession connectionProvider, long h) { } this.status = Session.Status.AUTHENTICATED; this.sessionManager.removeDetached(this); - // Build the element but do NOT send it — the caller will embed it in . - final Element resumed = this.streamManager.buildResumedElement(); - this.streamManager.processClientAcknowledgementPublic(h); - // Redelivery happens immediately after . Stream features must not be sent for a successfully - // resumed stream, so the stanza handler consumes this flag before considering feature delivery. - this.streamManager.setPendingSasl2Redelivery(true); this.sessionManager.removeSession((LocalClientSession) connectionProvider); - return resumed; } /** 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 b67d1076b8..30552a74dd 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -144,14 +144,6 @@ public static Element featureElement() { */ private final Set terminationDelegates = new HashSet<>(); - /** - * Set to {@code true} when a SASL2-inline stream resumption has been processed but unacknowledged - * stanzas have not yet been redelivered. A successfully resumed stream is re-established immediately - * after SASL2 {@code }; {@link StanzaHandler} calls {@link #redeliverIfPendingSasl2(JID)} - * instead of sending post-authentication stream features. - */ - private volatile boolean pendingSasl2Redelivery = false; - public StreamManager(LocalSession session) { String address; try { @@ -398,7 +390,10 @@ public Sasl2ResumeResult processSasl2Resume(Element resumeElement) { oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); } Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); - final Element resumed = otherSession.reattachForSasl2(session, h); + otherSession.reattachForSasl2(session); + final StreamManager resumedStreamManager = otherSession.getStreamManager(); + final Element resumed = resumedStreamManager.buildResumedElement(); + resumedStreamManager.processClientAcknowledgement(h); Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); return Sasl2ResumeResult.resumed(resumed, otherSession); } @@ -493,6 +488,7 @@ PacketError.Condition getFailureCondition() { public static final class Sasl2ResumeResult { private final Element response; private final LocalClientSession resumedSession; + private boolean completed; private Sasl2ResumeResult(Element response, LocalClientSession resumedSession) { this.response = response; @@ -520,6 +516,24 @@ public LocalClientSession getResumedSession() { public boolean isResumed() { return resumedSession != null; } + + /** + * Completes a successful inline resumption after the enclosing SASL2 success has been sent. + * This method is idempotent. + * + * @return {@code true} when this result represents a resumed stream + */ + public synchronized boolean completeAfterSuccess() { + if (resumedSession == null) { + return false; + } + if (!completed) { + completed = true; + resumedSession.getStreamManager().redeliverUnackedStanzas( + new JID(null, resumedSession.getServerName(), null, true)); + } + return true; + } } /** @@ -780,46 +794,6 @@ public void onResume(JID serverAddress, long h) { redeliverUnackedStanzas(serverAddress); } - /** - * Processes the client's acknowledgement counter as part of a SASL2-based stream resumption. - * This is a package-accessible wrapper around the private {@link #processClientAcknowledgement(long)} - * for use by {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2}. - * - * @param h the client's acknowledgement counter - */ - public void processClientAcknowledgementPublic(long h) { - processClientAcknowledgement(h); - } - - /** - * Sets the pending SASL2 redelivery flag. When {@code true}, {@link #redeliverIfPendingSasl2(JID)} - * will redeliver unacknowledged stanzas. Called by - * {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2} to defer redelivery - * until after the enclosing SASL2 {@code } has been sent. - * - * @param pending whether redelivery is pending - */ - public void setPendingSasl2Redelivery(boolean pending) { - this.pendingSasl2Redelivery = pending; - } - - /** - * If a SASL2-inline stream resumption is pending redelivery, redelivers unacknowledged stanzas - * now. This is called after SASL2 {@code }; a {@code true} result tells the stanza handler - * that stream features must not be sent for the resumed stream. - * - * @param serverAddress the server's JID, used to stamp delayed stanzas - * @return {@code true} when this call completed an inline resumption and redelivered its queued stanzas - */ - public boolean redeliverIfPendingSasl2(JID serverAddress) { - if (pendingSasl2Redelivery) { - pendingSasl2Redelivery = false; - redeliverUnackedStanzas(serverAddress); - return true; - } - return false; - } - /** * Re-delivers unacknowledged stanzas after a stream resumption and sends a server request for * acknowledgement. Called by both the standard and SASL2 resume paths. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java index 9826565f73..a2feca77fc 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java @@ -33,7 +33,6 @@ import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; -import org.xmpp.packet.JID; import org.xmpp.packet.StreamError; import java.io.IOException; @@ -208,10 +207,7 @@ protected void saslSuccessful() { * updated features, as their own RFC 7395 frame. */ @Override - protected void sasl2Successful() { - if (session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true))) { - return; - } + protected void deliverSasl2Features() { sendStreamFeatures(); } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java index 0bd872e442..4343de9f11 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java @@ -63,7 +63,9 @@ public void adoptsSessionThatOwnsResumedSasl2Connection() final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection); final LocalClientSession connectionProvider = mock(LocalClientSession.class); final LocalClientSession resumedSession = mock(LocalClientSession.class); - when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMED_SESSION)).thenReturn(resumedSession); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), resumedSession); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); handler.setSession(connectionProvider); handler.adoptSasl2ResumedSession(); @@ -75,13 +77,16 @@ public void adoptsSessionThatOwnsResumedSasl2Connection() public void successfulInlineResumptionDoesNotSendStreamFeatures() { final Connection connection = mock(Connection.class); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); final LocalClientSession session = mock(LocalClientSession.class); when(session.getConnection()).thenReturn(connection); when(session.getServerName()).thenReturn(Fixtures.XMPP_DOMAIN); final org.jivesoftware.openfire.streammanagement.StreamManager streamManager = new org.jivesoftware.openfire.streammanagement.StreamManager(session); when(session.getStreamManager()).thenReturn(streamManager); - streamManager.setPendingSasl2Redelivery(true); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); final Element features = DocumentHelper.createElement("features"); final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { @Override @@ -89,7 +94,8 @@ protected Element generateFeatures() { return features; } }; - handler.setSession(session); + handler.setSession(connectionProvider); + handler.adoptSasl2ResumedSession(); handler.sasl2Successful(); @@ -114,7 +120,10 @@ public void successfulInlineResumptionPrunesAcknowledgedStanzasAndReplaysTheRema streamManager.sentStanza(acknowledged); streamManager.sentStanza(unacknowledged); streamManager.process(DocumentHelper.parseText("").getRootElement()); - streamManager.setPendingSasl2Redelivery(true); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); final Element features = DocumentHelper.createElement("features"); final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { @Override @@ -122,7 +131,8 @@ protected Element generateFeatures() { return features; } }; - handler.setSession(session); + handler.setSession(connectionProvider); + handler.adoptSasl2ResumedSession(); handler.sasl2Successful(); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java index c159aba90d..37aafcf289 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java @@ -94,7 +94,7 @@ void transfersConnectionOwnershipAndRemovesTheProviderSession() final LocalClientSession connectionProvider = new LocalClientSession(Fixtures.XMPP_DOMAIN, newConnection, new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); - resumed.reattachForSasl2(connectionProvider, 0); + resumed.reattachForSasl2(connectionProvider); assertEquals(newConnection, resumed.getConnection()); assertNull(connectionProvider.getConnection()); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index 6797b69f92..0b424fd665 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -15,6 +15,7 @@ */ package org.jivesoftware.openfire.streammanagement; +import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.QName; import org.jivesoftware.openfire.Connection; @@ -377,74 +378,33 @@ public void testFeatureElementIsDistinctOnEachCall() { assertNotSame(feature1, feature2); } - /** - * Verifies that a freshly constructed StreamManager does not have a pending SASL2 redelivery. - */ @Test - public void testPendingSasl2RedeliveryIsFalseByDefault() { - // Setup test fixture. - final LocalClientSession mockSession = mock(LocalClientSession.class); - final Connection mockConnection = mock(Connection.class); - when(mockSession.getConnection()).thenReturn(mockConnection); - final StreamManager streamManager = new StreamManager(mockSession); - // Clear interactions caused by the constructor (e.g. getHostAddress()). - clearInvocations(mockConnection); - - // Execute system under test: redeliverIfPendingSasl2 with no flag set should be a no-op. - final JID serverAddress = new JID(null, "example.org", null, true); - streamManager.redeliverIfPendingSasl2(serverAddress); - - // Verify result: no interaction with the connection (no stanzas delivered). - verifyNoInteractions(mockConnection); - } + public void failedSasl2ResumeResultDoesNotCompleteResumption() { + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.failed( + StreamManager.NAMESPACE_V3, PacketError.Condition.item_not_found); - /** - * Verifies that setting the pending SASL2 redelivery flag and then calling - * redeliverIfPendingSasl2 clears the flag (i.e. a second call is a no-op). - */ - @Test - public void testRedeliverIfPendingSasl2ClearsFlagAfterFirstCall() { - // Setup test fixture. - final LocalClientSession mockSession = mock(LocalClientSession.class); - final Connection mockConnection = mock(Connection.class); - when(mockSession.getConnection()).thenReturn(mockConnection); - final StreamManager streamManager = new StreamManager(mockSession); - streamManager.setPendingSasl2Redelivery(true); - - final JID serverAddress = new JID(null, "example.org", null, true); - - // First call: flag is set, so redelivery runs (no unacked stanzas, but the flag is consumed). - streamManager.redeliverIfPendingSasl2(serverAddress); - - // Second call: flag has been cleared, so this must be a no-op. - // We verify by resetting the mock and confirming no further deliveries occur. - clearInvocations(mockConnection); - streamManager.redeliverIfPendingSasl2(serverAddress); - verifyNoInteractions(mockConnection); + assertFalse(result.completeAfterSuccess()); } - /** - * Verifies that setPendingSasl2Redelivery(false) prevents redeliverIfPendingSasl2 from acting. - */ @Test - public void testSetPendingSasl2RedeliveryFalsePreventsTrigger() { - // Setup test fixture. - final LocalClientSession mockSession = mock(LocalClientSession.class); - final Connection mockConnection = mock(Connection.class); - when(mockSession.getConnection()).thenReturn(mockConnection); - final StreamManager streamManager = new StreamManager(mockSession); - // Clear interactions caused by the constructor (e.g. getHostAddress()). - clearInvocations(mockConnection); - - // Set then immediately clear the flag. - streamManager.setPendingSasl2Redelivery(true); - streamManager.setPendingSasl2Redelivery(false); - - // Execute system under test. - final JID serverAddress = new JID(null, "example.org", null, true); - streamManager.redeliverIfPendingSasl2(serverAddress); - - // Verify result: no interaction with the connection. - verifyNoInteractions(mockConnection); + public void successfulSasl2ResumeResultCompletesOnlyOnce() { + final LocalClientSession session = mock(LocalClientSession.class); + final Connection connection = mock(Connection.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn("example.org"); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + when(session.isAuthenticated()).thenReturn(true); + streamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + clearInvocations(connection); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement(QName.get("resumed", StreamManager.NAMESPACE_V3)), session); + + assertTrue(result.completeAfterSuccess()); + verify(connection).deliverRawText(""); + clearInvocations(connection); + + assertTrue(result.completeAfterSuccess()); + verifyNoInteractions(connection); } } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java index 4cdd07af97..dacb65bbd7 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java @@ -39,16 +39,32 @@ public void successfulInlineResumptionDoesNotSendWebSocketStreamFeatures() { final WebSocketConnection connection = mock(WebSocketConnection.class); final LocalClientSession session = mock(LocalClientSession.class); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); when(session.getConnection()).thenReturn(connection); when(session.getServerName()).thenReturn("example.org"); final StreamManager streamManager = new StreamManager(session); when(session.getStreamManager()).thenReturn(streamManager); - streamManager.setPendingSasl2Redelivery(true); - final WebSocketClientStanzaHandler handler = - new WebSocketClientStanzaHandler(mock(PacketRouter.class), connection); - handler.setSession(session); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData("sasl2-resumption-result")).thenReturn(result); + class TestHandler extends WebSocketClientStanzaHandler { + TestHandler(WebSocketConnection testConnection) { + super(mock(PacketRouter.class), testConnection); + } - handler.sasl2Successful(); + void adoptResumption() { + adoptSasl2ResumedSession(); + } + + void completeSasl2() { + sasl2Successful(); + } + } + final TestHandler handler = new TestHandler(connection); + handler.setSession(connectionProvider); + handler.adoptResumption(); + + handler.completeSasl2(); verify(connection, never()).deliverRawText(anyString()); } From 8055fa807c4e3d8a312b5f003481c13eab958533 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 15:09:48 -0400 Subject: [PATCH 24/25] Document SASL2 TLS override non-conformance --- i18n/src/main/resources/openfire_i18n.properties | 2 +- .../java/org/jivesoftware/openfire/net/SASLAuthentication.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/i18n/src/main/resources/openfire_i18n.properties b/i18n/src/main/resources/openfire_i18n.properties index 4a0f3b77de..bedb260e31 100644 --- a/i18n/src/main/resources/openfire_i18n.properties +++ b/i18n/src/main/resources/openfire_i18n.properties @@ -1326,7 +1326,7 @@ system_property.xmpp.auth.external.client.skip-cert-revalidation=Set to true to system_property.xmpp.auth.external.server.require-authzid=Require the peer to provide an authorization identity through SASL (typically in the Initial Response) when authenticating an inbound S2S connection that uses the EXTERNAL SASL mechanism. This is not required by the XMPP protocol specification, but it was required by Openfire versions prior to release 4.8.0. This configuration option is added to allow for backwards compatibility. system_property.xmpp.auth.external.server.skip-sending-authzid=Send an authorization identity in the Initial Response when attempting to authenticate using the SASL EXTERNAL mechanism with a remote XMPP domain. Sending the authzid in this manner is not required by the XMPP protocol specification, but is recommended in XEP-0178 for compatibility with older server implementations. system_property.xmpp.auth.sasl2=Enables support for SASL2 authentication (XEP-0388) -system_property.xmpp.auth.sasl2.require-tls=Require TLS in order to authenticate with SASL2 +system_property.xmpp.auth.sasl2.require-tls=Require TLS in order to authenticate with SASL2 (disabling this is not XEP-0388 compliant) system_property.xmpp.auth.ssl.default-trustmanager-impl=The class to use as the default TLS TrustManager (which checks certificates from peers). system_property.xmpp.auth.scram.mechanisms-per-user=Offer a client only the SCRAM mechanisms that the user it claims to be has credentials for, rather than those that every user is assumed to have. Requires the client to identify itself in the 'from' attribute of its stream header system_property.xmpp.client.csi.enabled=Controls if Client State Indication (XEP-0352) functionality is supported by Openfire. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 7c00572a71..9f068f1c14 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -166,6 +166,8 @@ public class SASLAuthentication { /** * Require TLS for SASL2. This is currently on by default, and means that SASL2 is not advertised in features without TLS. + * Disabling this option is intentionally supported for specialized deployments, but violates the XEP-0388 requirement + * that SASL2 is offered and used only after TLS negotiation. * * @see XEP-0388: Extensible SASL Profile */ From 38d85cebdea498f5f26784e4d54f5d924c1e0dd5 Mon Sep 17 00:00:00 2001 From: Dave Cridland Date: Sat, 29 Aug 2026 15:14:37 -0400 Subject: [PATCH 25/25] Test SASL2 Bind2 stream management integration --- .../openfire/sasl/SASLIntegrationTest.java | 45 +++++++++++++++++++ .../streammanagement/StreamManagerTest.java | 8 ++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java index b0b4ba1ec6..4a07aaf498 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java @@ -26,6 +26,8 @@ import org.jivesoftware.openfire.lockout.LockOutManager; import org.jivesoftware.openfire.lockout.LockOutProvider; import org.jivesoftware.openfire.net.SASLAuthentication; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; import org.jivesoftware.openfire.session.LocalClientSession; import org.jivesoftware.openfire.session.LocalIncomingServerSession; import org.jivesoftware.openfire.session.LocalSession; @@ -42,11 +44,13 @@ import org.mockito.quality.Strictness; import org.xmpp.packet.JID; import org.jivesoftware.util.cache.CacheFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -793,6 +797,47 @@ public void testAuthenticationWithSASL2AndBind2IncludesResource() throws Excepti verify(clientSession).setAuthToken(any(AuthToken.class)); } + @Test + public void testSasl2MechanismBind2AndStreamManagementEnableAreProcessedTogether() throws Exception { + final SessionManager sessionManager = mock(SessionManager.class); + when(xmppServer.getSessionManager()).thenReturn(sessionManager); + when(sessionManager.bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + when(clientSession.getStatus()).thenReturn(org.jivesoftware.openfire.session.Session.Status.CONNECTED); + when(clientSession.getAuthToken()).thenReturn(AuthToken.generateUserToken("test-user")); + when(clientSession.getAvailableStreamFeatures()).thenReturn(Collections.emptyList()); + final StreamManager streamManager = new StreamManager(clientSession); + when(clientSession.getStreamManager()).thenReturn(streamManager); + final AtomicBoolean authenticated = new AtomicBoolean(); + when(clientSession.isAuthenticated()).thenAnswer(invocation -> authenticated.get()); + doAnswer(invocation -> { + if (invocation.getArgument(0) == org.jivesoftware.openfire.session.Session.Status.AUTHENTICATED) { + authenticated.set(true); + } + return null; + }).when(clientSession).setStatus(any()); + sessionDataMap.put(SASLAuthentication.AVAILABLE_MECHANISMS_FOR_SESSION, Set.of("TEST-MECHANISM")); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Element authenticate = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + authenticate.addElement(QName.get("bind", "urn:xmpp:bind:0")) + .addElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + final SASLAuthentication.Status status = SASLAuthentication.handle(clientSession, authenticate, true); + + assertEquals(SASLAuthentication.Status.authenticatedAwaitingFeatures, status); + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(clientSession, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + @Test public void testSasl2DomainQualifiedAuthzidIsNormalized() throws Exception { // Setup test fixture: SASL yields an authzid that already carries a domain-part. diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index 0b424fd665..7c8e3304a8 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -396,15 +396,15 @@ public void successfulSasl2ResumeResultCompletesOnlyOnce() { when(session.getStreamManager()).thenReturn(streamManager); when(session.isAuthenticated()).thenReturn(true); streamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true); - clearInvocations(connection); + clearInvocations(session, connection); final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( DocumentHelper.createElement(QName.get("resumed", StreamManager.NAMESPACE_V3)), session); assertTrue(result.completeAfterSuccess()); - verify(connection).deliverRawText(""); - clearInvocations(connection); + verify(session).deliverRawText(""); + clearInvocations(session, connection); assertTrue(result.completeAfterSuccess()); - verifyNoInteractions(connection); + verifyNoInteractions(session, connection); } }