From cc2c00097d3dce49fa9364873e229b0e665be2d3 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Wed, 2 Sep 2026 20:51:00 +0200 Subject: [PATCH 1/4] OF-2534: Support inline XEP-0198 in SASL2 Bind2 requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements XEP-0198 § 9.1: a client may now include `` in the Bind2 `` element of a SASL2 ``. Bind2StreamManagementHandler registers as a Bind2 inline handler, advertising 'urn:xmpp:sm:3' whenever stream management is active. StreamManager gains enableAndBuildElement, which returns the element instead of sending it, so that an inline caller can embed it. Failures are reported by throwing StreamManagementException carrying the error condition, which the handler turns into the `` element inside `` that § 9.1.1 requires. The standalone path builds the same element and delivers it as before. Enabling stream management requires an authenticated session, so this relies on setStatus(AUTHENTICATED) running ahead of processFeatureRequests, as of the preceding Bind2 change. Inline stream _resumption_ (§ 9.2) is not part of this change; the `` element is deliberately not yet advertised in the SASL2 `` element. Co-authored-by: dwd --- .../jivesoftware/openfire/SessionManager.java | 18 +- .../handler/Bind2StreamManagementHandler.java | 101 ++++++++ .../StreamManagementException.java | 42 ++++ .../streammanagement/StreamManager.java | 59 +++-- ...Bind2StreamManagementAvailabilityTest.java | 82 +++++++ .../Bind2StreamManagementHandlerTest.java | 219 ++++++++++++++++++ 6 files changed, 506 insertions(+), 15 deletions(-) create mode 100644 xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java create mode 100644 xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.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..91349d8ed1 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java @@ -38,6 +38,8 @@ import org.jivesoftware.openfire.session.*; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; +import org.jivesoftware.openfire.net.Bind2Request; import org.jivesoftware.openfire.streammanagement.TerminationDelegate; import org.jivesoftware.util.*; import org.jivesoftware.util.cache.*; @@ -270,6 +272,7 @@ public class SessionManager extends BasicModule implements ClusterEventListener private RoutingTable routingTable; private StreamIDFactory streamIDFactory; + private Bind2StreamManagementHandler bind2StreamManagementHandler; /** * Returns the instance of SessionManagerImpl being used by the XMPPServer. @@ -1897,10 +1900,15 @@ private Message createServerMessage(String subject, String body) { } @Override - public void start() throws IllegalStateException { + public synchronized void start() throws IllegalStateException { super.start(); localSessionManager.start(); + // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. + final Bind2StreamManagementHandler localHandler = new Bind2StreamManagementHandler(); + Bind2Request.registerElementHandler(localHandler); + bind2StreamManagementHandler = localHandler; // Only dereference any previous handler after registration succeeds, otherwise that previous handler can never be removed again. + // Run through the server sessions every 10% of the time of the maximum time that a session is allowed to be // detached, or every 3 minutes if the max time is outside the default boundaries. // TODO Reschedule task if getSessionDetachTime value changes. @@ -1915,8 +1923,14 @@ public void start() throws IllegalStateException { } @Override - public void stop() { + public synchronized void stop() { Log.debug("SessionManager: Stopping server"); + + if (bind2StreamManagementHandler != null) { + Bind2Request.unregisterElementHandler(bind2StreamManagementHandler); + bind2StreamManagementHandler = null; + } + // Stop threads that are sending packets to remote servers OutgoingSessionPromise.getInstance().shutdown(); if (JiveGlobals.getBooleanProperty("shutdownMessage.enabled")) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java new file mode 100644 index 0000000000..d6d3803277 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -0,0 +1,101 @@ +/* + * 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.StreamManagementException; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmpp.packet.PacketError; + +/** + * A {@link Bind2InlineHandler} that processes XEP-0198 Stream Management {@code } elements + * sent inline within a SASL2 Bind2 request (XEP-0388 / XEP-0386). + * + *

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; + } + + @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 + * 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("Received unexpected element '{}'; ignoring.", element.getName()); + return false; + } + final String resumeAttr = element.attributeValue("resume"); + final boolean resume = "true".equalsIgnoreCase(resumeAttr) || "1".equals(resumeAttr) || "yes".equalsIgnoreCase(resumeAttr); + bound.add(session.getStreamManager().enableAndBuildElement(getNamespace(), resume)); + return true; + } + + /** + * Adds the {@code } element that XEP-0198 § 9.1.1 requires inside {@code } when stream + * management could not be enabled. + * + * The condition is taken from the failure itself where one is available. A handler that declined the request + * without failing did so because the element was not an {@code }, which is a malformed request. + */ + @Override + public void handleFailure(LocalClientSession session, Element bound, Element element, Exception cause) { + final PacketError.Condition condition; + if (cause instanceof StreamManagementException sme) { + condition = sme.getCondition(); + } else if (cause == null) { + condition = PacketError.Condition.bad_request; + } else { + condition = PacketError.Condition.internal_server_error; + } + bound.addElement("failed", StreamManager.NAMESPACE_V3) + .addElement(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"); + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java new file mode 100644 index 0000000000..6e3cc38a93 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.streammanagement; + +import org.xmpp.packet.PacketError; + +import javax.annotation.Nonnull; + +/** + * Thrown when stream management could not be enabled for a session, carrying the error condition that XEP-0198 § 6 + * requires to be reported to the peer. + * + * The condition travels with the exception so that a caller which embeds the outcome in a larger response — such as a + * Bind2 inline request — can report the same reason that a standalone request would have received. + */ +public class StreamManagementException extends RuntimeException +{ + private final PacketError.Condition condition; + + public StreamManagementException(@Nonnull final PacketError.Condition condition, @Nonnull final String message) { + super(message); + this.condition = condition; + } + + @Nonnull + public PacketError.Condition getCondition() { + return condition; + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java index ff5c140c52..2f365f2e78 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2025 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2017-2026 Ignite Realtime Foundation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -243,24 +243,45 @@ private boolean allowResume() { * @param resume Whether the client is requesting a resumable session. */ private void enable( String namespace, boolean resume ) + { + final Element outcome; + try { + outcome = enableAndBuildElement(namespace, resume); + } catch (final StreamManagementException e) { + Log.debug("Unable to enable stream management for session {}: {}", session, e.getMessage()); + session.deliverRawText(buildFailedElement(namespace, e.getCondition()).asXML()); + return; + } + session.deliverRawText(outcome.asXML()); + } + + /** + * Enables stream management, returning the {@code } element rather than sending it. + * + * Leaving delivery to the caller allows an inline caller to embed the element in its enclosing response, as + * XEP-0198 § 9.1 requires of a Bind2 inline request. + * + * @param namespace the SM namespace to use + * @param resume whether the client requests a resumable session + * @return the {@code } element + * @throws StreamManagementException when stream management could not be enabled, carrying the condition to report + */ + @Nonnull + public Element enableAndBuildElement( String namespace, boolean resume ) throws StreamManagementException { boolean offerResume = allowResume(); - // Ensure that resource binding has occurred. if (!session.isAuthenticated()) { - this.namespace = namespace; - sendUnexpectedError(); - return; + throw new StreamManagementException(PacketError.Condition.unexpected_request, + "Stream management cannot be enabled before the session is authenticated."); } String smId = null; - synchronized ( this ) { - // Do nothing if already enabled if ( isEnabled() ) { - sendUnexpectedError(); - return; + throw new StreamManagementException(PacketError.Condition.unexpected_request, + "Stream management is already enabled for this session."); } this.namespace = namespace; @@ -271,7 +292,7 @@ private void enable( String namespace, boolean resume ) } } - // Send confirmation to the requestee. + // Build confirmation element. Element enabled = new DOMElement(QName.get("enabled", namespace)); if (this.resume) { enabled.addAttribute("resume", "true"); @@ -289,7 +310,7 @@ private void enable( String namespace, boolean resume ) } } } - session.deliverRawText(enabled.asXML()); + return enabled; } private void startResume(String namespace, String previd, long h) { @@ -456,12 +477,24 @@ private void sendUnexpectedError() { * @param error PacketError describing the failure. */ private void sendError(PacketError error) { - final Element failed = DocumentHelper.createElement(QName.get("failed", namespace)); - failed.addElement(QName.get(error.getCondition().toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas")); + final Element failed = buildFailedElement(namespace, error.getCondition()); session.deliverRawText(failed.asXML()); this.namespace = null; // isEnabled() is testing this. } + /** + * Constructs an XML element representing a failed stream management negotiation. + * + * @param namespace The namespace indicating the version of stream management being used. Must not be null. + * @param condition The specific error condition that caused the failure. Must not be null. + * @return An XML {@code } element containing information about the failure. + */ + private static Element buildFailedElement(@Nonnull final String namespace, @Nonnull final PacketError.Condition condition) { + final Element failed = DocumentHelper.createElement(QName.get("failed", namespace)); + failed.addElement(QName.get(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas")); + return failed; + } + /** * Checks if the amount of stanzas that the client acknowledges is equal to or less than the amount of stanzas that * we've sent to the client. diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java new file mode 100644 index 0000000000..c044c8008c --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.handler; + +import org.dom4j.Element; +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Bind2StreamManagementAvailabilityTest +{ + private Bind2StreamManagementHandler handler; + + @BeforeAll + static void configureOpenfire() throws Exception + { + Fixtures.reconfigureOpenfireHome(); + Fixtures.disableDatabasePersistence(); + } + + @AfterAll + static void clearProperties() + { + Fixtures.clearExistingProperties(); + } + + @BeforeEach + void registerHandler() + { + handler = new Bind2StreamManagementHandler(); + Bind2Request.registerElementHandler(handler); + } + + @AfterEach + void restoreState() + { + StreamManager.ACTIVE.setValue(StreamManager.ACTIVE.getDefaultValue()); + Bind2Request.unregisterElementHandler(handler); + handler = null; + } + + @Test + void followsDynamicStreamManagementSetting() + { + StreamManager.ACTIVE.setValue(false); + assertFalse(advertisesStreamManagement()); + + StreamManager.ACTIVE.setValue(true); + assertTrue(advertisesStreamManagement()); + + StreamManager.ACTIVE.setValue(false); + assertFalse(advertisesStreamManagement()); + } + + private static boolean advertisesStreamManagement() + { + final Element inline = Bind2Request.featureElement().element("inline"); + return inline.elements("feature").stream() + .anyMatch(feature -> StreamManager.NAMESPACE_V3.equals(feature.attributeValue("var"))); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java new file mode 100644 index 0000000000..37f2ca8890 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -0,0 +1,219 @@ +/* + * 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.StreamManagementException; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xmpp.packet.PacketError; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link Bind2StreamManagementHandler}. + */ +public class Bind2StreamManagementHandlerTest { + + private Bind2StreamManagementHandler handler; + private LocalClientSession mockSession; + private StreamManager mockStreamManager; + private Element boundElement; + + @BeforeEach + public void setUp() { + handler = new Bind2StreamManagementHandler(); + mockSession = mock(LocalClientSession.class); + mockStreamManager = mock(StreamManager.class); + when(mockSession.getStreamManager()).thenReturn(mockStreamManager); + boundElement = DocumentHelper.createElement(new QName("bound", new Namespace("", "urn:xmpp:bind:0"))); + } + + @Test + public void testGetNamespace() { + assertEquals(StreamManager.NAMESPACE_V3, handler.getNamespace()); + } + + @Test + public void testHandleEnableElementWithoutResume() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + final Element enabledElement = DocumentHelper.createElement( + new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3))); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(enabledElement); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false); + assertEquals(1, boundElement.elements().size()); + assertEquals("enabled", boundElement.elements().get(0).getName()); + } + + @Test + public void testHandleEnableElementWithResume() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "true"); + final Element enabledElement = DocumentHelper.createElement( + new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3))); + enabledElement.addAttribute("resume", "true"); + enabledElement.addAttribute("id", "someSmId"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(enabledElement); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + assertEquals(1, boundElement.elements().size()); + final Element addedEnabled = (Element) boundElement.elements().get(0); + assertEquals("enabled", addedEnabled.getName()); + assertEquals("true", addedEnabled.attributeValue("resume")); + } + + /** + * Verifies that a value which is not a lexical representation of xs:boolean does not request resumption. + * + * XEP-0198 § 3 note 5 admits only "true"/"1" and "false"/"0". Treating anything else as an affirmative would let a + * client believe it had a resumable stream when a conforming server would not have given it one. + */ + @Test + public void testResumeIsNotRequestedByANonBooleanValue() { + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + enable.addAttribute("resume", "foobar"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(DocumentHelper.createElement(QName.get("enabled", StreamManager.NAMESPACE_V3))); + + handler.handleElement(mockSession, boundElement, enable); + + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false); + } + + @Test + public void testHandleEnableElementWithResume1() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "1"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(DocumentHelper.createElement(QName.get("enabled", StreamManager.NAMESPACE_V3))); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + /** + * Verifies that a failure to enable stream management propagates with the condition that XEP-0198 requires, and is + * not delivered to the peer as a standalone stanza. + * + * The enclosing Bind2 request catches the failure and asks the handler to embed it in {@code }, as + * XEP-0198 § 9.1.1 requires. Sending it separately would put a {@code } on the stream before the + * {@code } that should contain it. + */ + @Test + public void testEnableFailurePropagatesWithoutStandaloneDelivery() { + // Setup test fixture: a session that has not authenticated. + final StreamManager streamManager = new StreamManager(mockSession); + when(mockSession.getStreamManager()).thenReturn(streamManager); + when(mockSession.isAuthenticated()).thenReturn(false); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + // Execute system under test. + final StreamManagementException e = assertThrows(StreamManagementException.class, + () -> handler.handleElement(mockSession, boundElement, enable)); + + // Verify result. + assertEquals(PacketError.Condition.unexpected_request, e.getCondition(), + "An enable request before authentication must report unexpected-request."); + assertTrue(boundElement.elements().isEmpty(), "Nothing may be added to when enabling failed."); + verify(mockSession, never()).deliverRawText(anyString()); + } + + /** + * Verifies that the condition carried by a failure reaches the {@code } element, rather than being + * replaced by a generic one. + */ + @Test + public void testFailureConditionIsPreservedInTheBoundElement() { + // Setup test fixture. + final StreamManagementException cause = new StreamManagementException( + PacketError.Condition.unexpected_request, "already enabled"); + + // Execute system under test. + handler.handleFailure(mockSession, boundElement, DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)), cause); + + // Verify result. + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas")), + "The condition that caused the failure must be reported, not a generic one."); + } + + @Test + public void testHandleNonEnableElementIsIgnored() { + // Setup: send an unexpected element name + final Element wrongElement = DocumentHelper.createElement( + new QName("disable", new Namespace("", StreamManager.NAMESPACE_V3))); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, wrongElement); + + // Verify + assertFalse(result); + verifyNoInteractions(mockStreamManager); + assertTrue(boundElement.elements().isEmpty()); + } + + @Test + public void testMalformedRequestProducesBadRequestFailure() { + final Element wrongElement = DocumentHelper.createElement(QName.get("disable", StreamManager.NAMESPACE_V3)); + + handler.handleFailure(mockSession, boundElement, wrongElement, null); + + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + + @Test + public void testProcessingExceptionProducesInternalServerErrorFailure() { + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + handler.handleFailure(mockSession, boundElement, enable, new IllegalStateException("test failure")); + + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("internal-server-error", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } +} From 07e99259e2cd314608797911b027c734da0504fc Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Thu, 3 Sep 2026 13:07:58 +0200 Subject: [PATCH 2/4] OF-2534: Prevent potential NullPointerException LocalClientSession#getAvailableStreamFeatures dereferenced a possibly null connection, unlike every other accessor in LocalSession. --- .../openfire/session/LocalClientSession.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java index 704c537f37..a0ba40ba3e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java @@ -831,16 +831,22 @@ else if (this.presence.isAvailable()) { @Override public List getAvailableStreamFeatures() { + final Connection connection = conn; + if (connection == null) { + // Detached or closed: there is no stream on which to advertise anything. + return Collections.emptyList(); + } + // Offer authenticate and registration only if TLS was not required or if required // then the connection is already encrypted - if (conn.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !conn.isEncrypted()) { + if (connection.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !connection.isEncrypted()) { return Collections.emptyList(); } final List result = new LinkedList<>(); // Include Stream Compression Mechanism - if (conn.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !conn.isCompressed()) { + if (connection.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !connection.isCompressed()) { final Element compression = DocumentHelper.createElement(QName.get("compression", "http://jabber.org/features/compress")); compression.addElement("method").addText("zlib"); result.add(compression); From abaca36f2926e15dd7b75943949fbb53e66bc121 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Thu, 3 Sep 2026 13:09:38 +0200 Subject: [PATCH 3/4] OF-2534: Do not retain authenticated state after a failed SASL2 negotiation A SASL2 negotiation that failed after the SASL exchange itself succeeded left the session partly authenticated. Three related defects are addressed in this commit: * A failed inline Bind2 left the auth token set. The session then advertised resource binding and stream management instead of SASL on the next stream header, and a retry as a different user inherited the previous attempt's identity. * An exception after the bind completed sent for a session that was already AUTHENTICATED and registered in the routing table. Such a failure now closes the stream with an error; one occurring before reaches the peer undoes the bind. * Anonymous SASL2+Bind2 could never succeed: SessionManager#bindResource dereferences the token's username, which is null for an anonymous token. Anonymous sessions now go through setAnonymousAuth(), as that method's contract already required. --- .../openfire/net/SASLAuthentication.java | 159 +++++++++++++---- .../openfire/net/SASLAuthenticationTest.java | 167 +++++++++++++++++- 2 files changed, 283 insertions(+), 43 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 41d6d7d0f6..3883f3efe0 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -43,6 +43,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmpp.packet.JID; +import org.xmpp.packet.StreamError; import javax.annotation.Nonnull; import javax.security.sasl.Sasl; @@ -642,15 +643,24 @@ else if ( decoded.length == 0 ) { failure = Failure.NOT_AUTHORIZED; } - SaslOutcome.authenticationFailed( session, failure, usingSASL2 ); - session.removeSessionData( "SaslServer" ); + + if (usingSASL2) { + abortSasl2(session, failure); + } else { + SaslOutcome.authenticationFailed(session, failure, usingSASL2); + session.removeSessionData("SaslServer"); + } return Status.failed; } catch( Exception ex ) { Log.warn( "An unexpected exception occurred during SASL negotiation. Affected session: {}", session, ex ); - SaslOutcome.authenticationFailed( session, Failure.NOT_AUTHORIZED, usingSASL2 ); - session.removeSessionData( "SaslServer" ); + if (usingSASL2) { + abortSasl2(session, Failure.NOT_AUTHORIZED); + } else { + SaslOutcome.authenticationFailed(session, Failure.NOT_AUTHORIZED, usingSASL2); + session.removeSessionData("SaslServer"); + } return Status.failed; } } @@ -804,7 +814,6 @@ else if (session instanceof LocalIncomingServerSession serverSession) { fastToken = FastSessionState.getRotatedToken(session); } FastSessionState.clearAuthenticationAttempt(session); - clientSession.setAuthToken(clientAuthToken); final FastToken finalFastToken = fastToken; final Bind2Request bind2Request = (Bind2Request) session.getSessionData("bind2-request"); @@ -812,43 +821,36 @@ else if (session instanceof LocalIncomingServerSession serverSession) { clientSession.removeSessionData("bind2-request"); final UserAgentInfo userAgentInfo = (UserAgentInfo) session.getSessionData("user-agent-info"); final String resource = bind2Request.generateResourceString(userAgentInfo); - final AuthToken authToken = clientSession.getAuthToken(); - SessionManager.getInstance().bindResource(clientSession, authToken, resource) - .whenComplete((result, throwable) -> { - try { + final JID preBindAddress = clientSession.getAddress(); + + if (clientAuthToken.isAnonymous()) { + // An anonymous session needs no conflict resolution: its node-part and resource are both the session's + // own generated identifier, so no other session can hold the same full JID. SessionManager#bindResource + // documents this and dereferences the (null) username, so it must not be used here. Note that this + // discards the resource that Bind2 generated; XEP-0386 leaves the assigned resource to the server. + clientSession.setAnonymousAuth(); + final JID bound = clientSession.getAddress(); + completeSasl2Bind2(clientSession, bind2Request, successData, finalFastToken, bound.toBareJID(), bound.getResource(), preBindAddress); + } else { + // A non-anonymous session performs regular resource binding. + final String bareJid = new JID(clientAuthToken.getUsername(), XMPPServer.getInstance().getServerInfo().getXMPPDomain(), null, true).toString(); + SessionManager.getInstance().bindResource(clientSession, clientAuthToken, resource) + .whenComplete((result, throwable) -> { if (throwable != null) { 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; - if (!bound) { + if (throwable != null || result != SessionManager.BindResult.BOUND) { Log.warn("Unable to bind resource '{}' for session '{}' during SASL2+Bind2 authentication. Bind result: {}", resource, clientSession, result); - SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); + abortSasl2(clientSession, Failure.TEMPORARY_AUTH_FAILURE); return; } - final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, resource, finalFastToken); - 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(); - if (specificFeatures != null) { - for (final org.dom4j.Element feature : specificFeatures) { - features.add(feature); - } - } - // Deliver these here. - clientSession.deliverRawText(success.asXML()); - clientSession.deliverRawText(features.asXML()); - } catch(Exception e) { - Log.warn("An exception occurred while processing SASL2+Bind2 for '{}' during SASL2+Bind2 authentication.", clientSession, e); - SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); - } - }); - // Response and features are sent asynchronously from the completion stage. + clientSession.setAuthToken(clientAuthToken); + completeSasl2Bind2(clientSession, bind2Request, successData, finalFastToken, bareJid, resource, preBindAddress); + }); + } } else { // No Bind2 request, or session already authenticated: send synchronously without . + clientSession.setAuthToken(clientAuthToken); final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, null, finalFastToken); session.deliverRawText(success.asXML()); } @@ -868,6 +870,95 @@ private static FastToken issueFastToken(final String username, final String clie return FastTokenManager.issueToken(username, clientId, mechanism); } + /** + * Aborts the SASL2 authentication process for a given session and handles the failure scenario. + * + * @param session The LocalSession object representing the session. Must not be null. + * @param failure The Failure object representing the reason for the authentication failure. Must not be null. + */ + private static void abortSasl2(@Nonnull final LocalSession session, @Nonnull final Failure failure) + { + if (session instanceof LocalClientSession clientSession) { + clientSession.setAuthToken(null); + } + session.removeSessionData("bind2-request"); + session.removeSessionData("user-agent-info"); + session.removeSessionData("SaslServer"); + FastSessionState.clearAuthenticationAttempt(session); + SaslOutcome.authenticationFailed(session, failure, true); + } + + /** + * Completes a SASL2 negotiation for which a resource has been bound: renders and delivers {@code }, + * then the post-authentication stream features. + *

+ * Failure is handled differently either side of the {@code } write. Before it, the peer has not been + * told anything, so the bind is undone and the negotiation fails. After it, authentication genuinely succeeded + * and the session is live and routable. A failure then is a stream-level problem rather than a SASL one. + * + * @param clientSession The LocalClientSession object representing the client session. Must not be null. + * @param bind2Request The Bind2Request object representing the bind request. Must not be null. + * @param successData The byte array representing the success data. + * @param fastToken The FastToken if one was issued. + * @param authorizationIdentity the bare JID authorization identity (e.g. user@domain or uuid@domain for anonymous). + * @param resource the bound resource, or null if no resource was bound. + * @param preBindAddress The session's address prior to the binding attempt. Must not be null. + */ + private static void completeSasl2Bind2(@Nonnull final LocalClientSession clientSession, + @Nonnull final Bind2Request bind2Request, + final byte[] successData, + final FastToken fastToken, + final String authorizationIdentity, + final String resource, + @Nonnull final JID preBindAddress) + { + boolean successDelivered = false; + try + { + final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, resource, fastToken); + bind2Request.processFeatureRequests(clientSession, success); + clientSession.deliverRawText(success.asXML()); + successDelivered = true; + + SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound); + + // Deliver stream features now that has been sent. + final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); + final List specificFeatures = clientSession.getAvailableStreamFeatures(); + if (specificFeatures != null) { + specificFeatures.forEach(features::add); + } + clientSession.deliverRawText(features.asXML()); + } + catch (final Exception e) + { + if (successDelivered) { + Log.warn("An exception occurred after SASL2+Bind2 success was delivered to '{}'. The session is authenticated and bound, so it is closed with a stream error rather than failed.", clientSession, e); + clientSession.close(new StreamError(StreamError.Condition.internal_server_error, "An error occurred while completing resource binding.")); + } else { + Log.warn("An exception occurred while processing SASL2+Bind2 for '{}'. Undoing the resource binding.", clientSession, e); + unwindBind(clientSession, preBindAddress); + abortSasl2(clientSession, Failure.TEMPORARY_AUTH_FAILURE); + } + } + } + + /** + * Reverses the session state installed by a successful resource binding, returning the session to the + * pre-binding state in which another SASL2 negotiation can be attempted. + * + * @param clientSession The LocalClientSession object representing the client session. Must not be null. + * @param preBindAddress The session's address prior to the binding attempt. Must not be null. + */ + private static void unwindBind(@Nonnull final LocalClientSession clientSession, @Nonnull final JID preBindAddress) + { + // removeSession reads the auth token to decide which session-destroyed event to fire, so it must run before + // abortSasl2 clears that token - otherwise a named session is reported as an anonymous one. + SessionManager.getInstance().removeSession(clientSession); + clientSession.setStatus(Session.Status.CONNECTED); + clientSession.setAddress(preBindAddress); + } + /** * Adds a new SASL mechanism to the list of supported SASL mechanisms by the server. The * new mechanism will be offered to clients and connection managers as stream features.

diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java index 5fc741a1a0..a3fc82dc06 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -47,6 +47,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import org.xmpp.packet.JID; import javax.security.sasl.SaslServer; import java.util.*; @@ -60,6 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -312,10 +314,8 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 when(bind2Request.generateResourceString(any())).thenReturn(anonymousUsername); session.setSessionData("bind2-request", bind2Request); - // Stub SessionManager.bindResource to complete successfully (synchronously). final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); - when(sessionManager.bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + // Deliberately no bindResource stub: an anonymous session must not be bound through SessionManager. // Execute system under test. SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0], true); @@ -337,6 +337,9 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 final String responseValue2 = response.getAllValues().get(1); assertTrue(responseValue2.contains(" failedBind = new CompletableFuture<>(); failedBind.completeExceptionally(new IllegalStateException("test failure")); - when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())).thenReturn(failedBind); + when(XMPPServer.getInstance().getSessionManager().bindResource(notNull(), notNull(), notNull())).thenReturn(failedBind); // Execute system under test. SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); @@ -404,6 +408,7 @@ public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() throws Exception "An unexpected failure to bind is not permanent, so the client must be told it may try again."); verify(bind2Request, never()).processFeatureRequests(any(), any()); assertFalse(session.isAuthenticated(), "A session whose resource could not be bound must not be authenticated."); + assertNull(session.getAuthToken(), "A session whose resource could not be bound must not have an auth token."); } /** @@ -423,8 +428,8 @@ public void bind2InlineHandlersRunAgainstAnAuthenticatedSession() throws Excepti final Bind2Request bind2Request = mock(Bind2Request.class); when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); session.setSessionData("bind2-request", bind2Request); - when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); + stubSuccessfulBind(sessionManager); final AtomicReference statusWhenHandlersRan = new AtomicReference<>(); when(bind2Request.processFeatureRequests(any(), any())).thenAnswer(invocation -> { @@ -532,8 +537,7 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A // Stub SessionManager.bindResource to complete successfully (synchronously). final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); - when(sessionManager.bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + stubSuccessfulBind(sessionManager); // Execute system under test. SASLAuthentication.authenticationSuccessful(session, username, "PLAIN", new byte[0], true); @@ -555,9 +559,125 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A final String responseValue2 = response.getAllValues().get(1); assertTrue(responseValue2.contains(" token = ArgumentCaptor.forClass(AuthToken.class); + verify(sessionManager).bindResource(same(session), token.capture(), eq("test-resource")); + assertNotNull(token.getValue(), "The bind must be driven by an authentication token, not null."); + assertFalse(token.getValue().isAnonymous(), "Expected the token of the user that authenticated."); + assertEquals("testuser", token.getValue().getUsername(), "Expected the token of the user that authenticated."); + } + + /** + * Verifies that a failure occurring after the resource was bound, but before {@code } reached the peer, + * returns the session to its pre-binding state. + * + * The peer has been told the negotiation failed and may attempt another one. A session left authenticated, bound + * and holding a route would have that retry skip binding altogether, and would leave a routable session for an + * authentication the peer believes did not happen. + */ + @Test + public void bind2FailureBeforeSuccessUnwindsTheBind() throws Exception + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final JID preBindAddress = session.getAddress(); + final Bind2Request bind2Request = mock(Bind2Request.class); + when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); + when(bind2Request.processFeatureRequests(any(), any())).thenThrow(new IllegalStateException("test failure")); + session.setSessionData("bind2-request", bind2Request); + final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); + stubSuccessfulBind(sessionManager); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + // Verify result. + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName(), "Expected the negotiation to fail, as was never delivered."); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASLAuthentication.SASL_NAMESPACE)), + "The client may retry, so the failure must not be permanent."); + verify(sessionManager).removeSession(session); + assertNull(session.getAuthToken(), "An undone bind must not leave an authentication token behind."); + assertEquals(Session.Status.CONNECTED, session.getStatus(), "An undone bind must not leave the session authenticated."); + assertSame(preBindAddress, session.getAddress(), "An undone bind must restore the pre-binding address."); + } + + /** + * Verifies that a failure occurring after {@code } was delivered closes the stream with an error, rather + * than following it with a SASL {@code }. + * + * By that point the authentication genuinely succeeded and the session is live and routable. Two contradictory + * outcomes for one negotiation leave the peer with no defined behaviour, and the SASL failure would not remove the + * route in any case. + */ + @Test + public void bind2FailureAfterSuccessClosesTheStream() throws Exception + { + // Setup test fixture: feature generation fails, which happens only after has been written. + final Connection connection = mock(Connection.class); + final ConnectionConfiguration connectionConfiguration = mock(ConnectionConfiguration.class); + when(connectionConfiguration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(connectionConfiguration.getCompressionPolicy()).thenThrow(new IllegalStateException("test failure")); + when(connection.getConfiguration()).thenReturn(connectionConfiguration); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Bind2Request bind2Request = mock(Bind2Request.class); + when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); + session.setSessionData("bind2-request", bind2Request); + stubSuccessfulBind(XMPPServer.getInstance().getSessionManager()); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + // Verify result. + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + assertEquals("success", DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement().getName(), + "Expected to have been delivered before the failure occurred."); + assertTrue(delivered.getAllValues().get(1).contains("internal-server-error"), + "Expected the stream to be closed with an error: " + delivered.getAllValues().get(1)); + assertFalse(delivered.getAllValues().stream().anyMatch(xml -> xml.contains(" { + final LocalClientSession bound = invocation.getArgument(0); + final AuthToken token = invocation.getArgument(1); + final String resource = invocation.getArgument(2); + final String node = token.isAnonymous() ? resource : token.getUsername(); + bound.setAddress(new JID(node, bound.getServerName(), resource, true)); + bound.setAuthToken(token); + bound.setStatus(Session.Status.AUTHENTICATED); + return CompletableFuture.completedFuture(SessionManager.BindResult.BOUND); + }); + } } From f5e37e4e7096157ddb85870e861d87b94a634e6c Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Thu, 3 Sep 2026 13:19:59 +0200 Subject: [PATCH 4/4] fix copyright headers --- .../openfire/handler/Bind2StreamManagementHandler.java | 2 +- .../openfire/handler/Bind2StreamManagementHandlerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 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 d6d3803277..b55a83b2e2 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2026 Ignite Realtime Foundation. All rights reserved. + * 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. 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 37f2ca8890..783870e8dd 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2026 Ignite Realtime Foundation. All rights reserved. + * 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.