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..850690f9da 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java @@ -39,4 +39,30 @@ public interface Bind2InlineHandler { * @return true if the element was handled successfully, false otherwise */ boolean handleElement(LocalClientSession session, Element bound, Element element); + + /** + * Indicates whether this handler is currently available for advertisement and request processing. + * + * A handler whose feature is disabled by configuration is neither advertised in the Bind2 inline feature list nor + * invoked for a request that names its namespace, so that a peer is never offered something it cannot use. + * + * @return {@code true} when the inline feature is available + */ + default boolean isEnabled() { + return true; + } + + /** + * Gives a handler an opportunity to add the protocol-defined failure response after request processing failed. + * + * Not every inline extension defines one, so the default does nothing. + * + * @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, Exception 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 5539c6fca0..04f0f09fce 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java @@ -21,11 +21,12 @@ import org.dom4j.QName; import org.jivesoftware.openfire.auth.ScramUtils; import org.jivesoftware.openfire.session.LocalClientSession; -import org.jivesoftware.openfire.session.LocalSession; import org.jivesoftware.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.security.sasl.SaslException; import java.nio.charset.StandardCharsets; import java.util.*; @@ -94,16 +95,18 @@ 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); + Log.info("Handler for namespace {} failed to process element", namespace); + invokeFailureHandler(clientSession, bound, element, null, handler, namespace); } } catch (Exception e) { - Log.error("Error processing element with namespace: " + namespace, e); + Log.warn("Error processing element with namespace: {}", namespace, e); + invokeFailureHandler(clientSession, bound, element, e, handler, namespace); } } else { - Log.debug("No handler registered for namespace: {}", namespace); + Log.debug("No handler registered/enabled for namespace: {}", namespace); // We don't fail here because there's no obvious way we could fail. } } @@ -111,10 +114,33 @@ public Element processFeatureRequests(LocalClientSession clientSession, Element return bound; } + /** + * Invokes the failure-handler of a Bind2-handler, logging but otherwise suppressing any exception thrown by the + * failure-handler. + * + * @param clientSession the client session. + * @param bound the bound element. + * @param element the element that failed to be processed. + * @param cause the processing exception, or {@code null} when the handler returned {@code false}. + * @param handler the Bind2-handler that failed to process the element. + * @param namespace the namespace of the element. + */ + private static void invokeFailureHandler(final LocalClientSession clientSession, final Element bound, final Element element, @Nullable final Exception cause, @Nonnull final Bind2InlineHandler handler, final String namespace) + { + try { + handler.handleFailure(clientSession, bound, element, cause); + } catch (Exception ex) { + Log.warn("Error invoking failure handler after failing to process element with namespace: {}", namespace, ex); + } + } + 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()); } @@ -221,21 +247,21 @@ public String generateResourceString(UserAgentInfo userAgentInfo) { // Using a fixed constant here - building a rainbow table here for the case // where the client supplies no tag is going to be very expensive, so this // prevents an id recovery attack. - String valueToHmac = resource.toString() + "OpenfireResourceConstant"; + String valueToHmac = resource + "OpenfireResourceConstant"; // Compute HMAC - byte[] hmacResult = ScramUtils.computeHmac(keyBytes, valueToHmac); + byte[] hmacResult = ScramUtils.computeHmac(keyBytes, valueToHmac, "HmacSHA1"); // Convert first 8 bytes of HMAC to hex for resource suffix (16 chars) String hmacHex = StringUtils.encodeHex(Arrays.copyOf(hmacResult, 8)); // Construct final resource string - return resource.toString() + hmacHex; + return resource + hmacHex; } catch (SaslException e) { // Fall back to UUID in case of HMAC computation failure Log.error("Failed to compute HMAC for resource string", e); - return resource.toString() + UUID.randomUUID().toString(); + return resource.toString() + UUID.randomUUID(); } } } 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 887449ef89..41d6d7d0f6 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -820,14 +820,16 @@ 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 = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, bound ? resource : null, finalFastToken); - if (bound) { - bind2Request.processFeatureRequests(clientSession, success); - } - if (bound) { - clientSession.setStatus(Session.Status.AUTHENTICATED); - 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); + SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); + 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(); 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..49f3b6c523 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java @@ -29,6 +29,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -90,6 +91,8 @@ public void setUp() { when(mockHandler2.getNamespace()).thenReturn("http://test2.namespace"); when(mockHandler1.handleElement(any(), any(), any())).thenReturn(true); when(mockHandler2.handleElement(any(), any(), any())).thenReturn(true); + when(mockHandler1.isEnabled()).thenReturn(true); + when(mockHandler2.isEnabled()).thenReturn(true); } @AfterEach @@ -193,6 +196,7 @@ public void testProcessFeatureRequestsWithHandlerException() { assertNotNull(result); verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); verify(mockHandler2).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); + verify(mockHandler1).handleFailure(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1), any(RuntimeException.class)); } @Test @@ -206,6 +210,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 @@ -244,7 +249,7 @@ public void testFeatureElementWithNoHandlers() { Element inline = feature.element("inline"); assertNotNull(inline); - assertTrue(inline.elements("feature").isEmpty(), "Expected no advertised features when no handlers are registered"); + assertTrue(advertisedFeatures(inline).isEmpty(), "Expected no advertised features when no handlers are registered"); } @Test @@ -274,14 +279,10 @@ public void testFeatureElementAdvertisesBothHandlers() { assertNotNull(feature); Element inline = feature.element("inline"); assertNotNull(inline); - List features = inline.elements("feature"); - assertEquals(2, features.size()); - - List vars = features.stream() - .map(e -> e.attributeValue("var")) - .collect(Collectors.toList()); - assertTrue(vars.contains("http://test1.namespace"), "Expected http://test1.namespace to be advertised"); - assertTrue(vars.contains("http://test2.namespace"), "Expected http://test2.namespace to be advertised"); + Set featureVars = advertisedFeatures(inline); + assertEquals(2, featureVars.size()); + assertTrue(featureVars.contains("http://test1.namespace"), "Expected http://test1.namespace to be advertised"); + assertTrue(featureVars.contains("http://test2.namespace"), "Expected http://test2.namespace to be advertised"); } @Test @@ -294,8 +295,215 @@ public void testFeatureElementAfterUnregisteringHandler() { Element inline = feature.element("inline"); assertNotNull(inline); - List features = inline.elements("feature"); - assertEquals(1, features.size()); - assertEquals("http://test2.namespace", features.get(0).attributeValue("var")); + Set featureVars = advertisedFeatures(inline); + assertEquals(1, featureVars.size()); + assertTrue(featureVars.contains("http://test2.namespace"), "Expected http://test2.namespace to be advertised"); + } + + /** + * Verifies that a handler reports no failure when its request was processed successfully. + * + * A failure response that accompanies a successful request would be reported to the peer as though something had + * gone wrong, and for XEP-0198 would leave a client believing stream management had not been enabled when it had. + */ + @Test + public void testNoFailureIsReportedWhenProcessingSucceeds() + { + // Setup test fixture. + Bind2Request.registerElementHandler(mockHandler1); + final Bind2Request request = new Bind2Request("test-client", List.of(featureElement1)); + + // Execute system under test. + request.processFeatureRequests(mockSession, successElement); + + // Verify result. + verify(mockHandler1).handleElement(any(), any(), eq(featureElement1)); + verify(mockHandler1, never()).handleFailure(any(), any(), any(), any()); + } + + /** + * Verifies that a handler whose feature is unavailable is not invoked for a request that names its namespace. + * + * A peer can send an inline request for a feature that was advertised earlier in the stream but has since been + * disabled by configuration, so availability must be checked when the request is processed and not only when the + * feature list is built. + */ + @Test + public void testDisabledHandlerIsNotInvoked() + { + // Setup test fixture. + when(mockHandler1.isEnabled()).thenReturn(false); + Bind2Request.registerElementHandler(mockHandler1); + final Bind2Request request = new Bind2Request("test-client", List.of(featureElement1)); + + // Execute system under test. + request.processFeatureRequests(mockSession, successElement); + + // Verify result. + verify(mockHandler1, never()).handleElement(any(), any(), any()); + verify(mockHandler1, never()).handleFailure(any(), any(), any(), any()); + } + + /** + * Verifies that one unavailable handler does not prevent the others from processing their requests. + */ + @Test + public void testDisabledHandlerDoesNotSuppressOthers() + { + // Setup test fixture. + when(mockHandler1.isEnabled()).thenReturn(false); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + final Bind2Request request = new Bind2Request("test-client", List.of(featureElement1, featureElement2)); + + // Execute system under test. + request.processFeatureRequests(mockSession, successElement); + + // Verify result. + verify(mockHandler1, never()).handleElement(any(), any(), any()); + verify(mockHandler2).handleElement(any(), any(), eq(featureElement2)); + } + + /** + * Verifies that a handler whose feature is unavailable is not advertised. + * + * Advertising a feature that would then be ignored invites a peer to send a request that receives no response at + * all, which it cannot distinguish from one the server failed to process. + */ + @Test + public void testDisabledHandlerIsNotAdvertised() + { + // Setup test fixture. + when(mockHandler1.isEnabled()).thenReturn(false); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + // Execute system under test. + final Element inline = Bind2Request.featureElement().element("inline"); + + // Verify result. + assertFalse(advertisedFeatures(inline).contains("http://test1.namespace"), + "An unavailable feature must not be advertised."); + assertTrue(advertisedFeatures(inline).contains("http://test2.namespace"), + "An available feature must still be advertised alongside an unavailable one."); + } + + /** + * Verifies that availability is evaluated each time the feature list is built, rather than when the handler was + * registered. + * + * Whether a feature is available is typically governed by a dynamic configuration property, and stream features + * are regenerated more than once during a stream's lifetime. + */ + @Test + public void testAvailabilityIsEvaluatedPerAdvertisement() + { + // Setup test fixture. + Bind2Request.registerElementHandler(mockHandler1); + + // Execute system under test & verify result. + when(mockHandler1.isEnabled()).thenReturn(true); + assertTrue(advertisedFeatures(Bind2Request.featureElement().element("inline")).contains("http://test1.namespace"), + "An available feature must be advertised."); + + when(mockHandler1.isEnabled()).thenReturn(false); + assertFalse(advertisedFeatures(Bind2Request.featureElement().element("inline")).contains("http://test1.namespace"), + "A feature that has since become unavailable must no longer be advertised."); + + when(mockHandler1.isEnabled()).thenReturn(true); + assertTrue(advertisedFeatures(Bind2Request.featureElement().element("inline")).contains("http://test1.namespace"), + "A feature that has become available again must be advertised again."); + } + + /** + * Verifies that a handler which overrides neither of the two new methods behaves exactly as handlers did before + * they existed: it is available, and it tolerates being asked to report a failure. + * + * Handlers are contributed by plugins, which are not necessarily recompiled against a new interface. + */ + @Test + public void testHandlerWithoutOverridesRetainsPreviousBehaviour() + { + // Setup test fixture: a handler implementing only what the interface has always required. + final Bind2InlineHandler handler = new Bind2InlineHandler() { + @Override + public String getNamespace() { + return "http://legacy.namespace"; + } + + @Override + public boolean handleElement(LocalClientSession session, Element bound, Element element) { + return true; + } + }; + + // Execute system under test & verify result. + assertTrue(handler.isEnabled(), + "A handler that does not override isEnabled must be treated as available, as handlers were before the method existed."); + assertDoesNotThrow(() -> handler.handleFailure(mockSession, DocumentHelper.createElement(QName.get("bound", "urn:xmpp:bind:0")), featureElement1, null), + "A handler that does not override handleFailure must tolerate being asked to report one."); + } + + /** + * Verifies that whatever a handler adds to the response while reporting a failure survives into the result. + * + * This is what makes a protocol-defined failure response expressible at all; XEP-0198 § 9.1.1, for example, + * requires a <failed/> element inside <bound/> when stream management could not be enabled. + */ + @Test + public void testFailureResponseIsRetainedInTheBoundElement() + { + // Setup test fixture. + when(mockHandler1.handleElement(any(), any(), any())).thenReturn(false); + doAnswer(invocation -> { + final Element bound = invocation.getArgument(1); + bound.addElement("failed", "http://test1.namespace"); + return null; + }).when(mockHandler1).handleFailure(any(), any(), any(), any()); + Bind2Request.registerElementHandler(mockHandler1); + final Bind2Request request = new Bind2Request("test-client", List.of(featureElement1)); + + // Execute system under test. + final Element bound = request.processFeatureRequests(mockSession, successElement); + + // Verify result. + assertNotNull(bound.element(QName.get("failed", "http://test1.namespace")), + "A failure response added by a handler must be retained in the response that is sent to the peer."); + } + + /** + * Verifies that a handler which throws while reporting a failure does not prevent the remaining requests from + * being processed. + * + * Handlers are contributed by plugins, so a misbehaving one must not be able to fail the authentication that the + * bind request is part of. + */ + @Test + public void testExceptionWhileReportingFailureDoesNotPropagate() + { + // Setup test fixture. + when(mockHandler1.handleElement(any(), any(), any())).thenReturn(false); + doThrow(new RuntimeException("handler is broken")).when(mockHandler1).handleFailure(any(), any(), any(), any()); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + final Bind2Request request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + + // Execute system under test. + final Element bound = assertDoesNotThrow(() -> request.processFeatureRequests(mockSession, successElement)); + + // Verify result. + assertNotNull(bound); + verify(mockHandler1, times(1)).handleFailure(any(), any(), any(), any()); + verify(mockHandler2).handleElement(any(), any(), eq(featureElement2)); + } + + /** + * Returns the namespaces advertised as inline features. + */ + private static Set advertisedFeatures(final Element inline) + { + return inline.elements("feature").stream() + .map(feature -> feature.attributeValue("var")) + .collect(Collectors.toSet()); } } 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 e17bc5f6ad..5fc741a1a0 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -31,6 +31,7 @@ import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.openfire.sasl.SaslMechanismCatalog; import org.jivesoftware.openfire.sasl.TestSaslMechanism; +import org.jivesoftware.openfire.session.Session; import org.jivesoftware.openfire.spi.ConnectionConfiguration; import org.jivesoftware.openfire.StreamID; import org.jivesoftware.openfire.XMPPServer; @@ -50,12 +51,14 @@ import javax.security.sasl.SaslServer; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import static org.jivesoftware.openfire.net.SASLAuthentication.SASL_NAMESPACE; import static org.jivesoftware.openfire.net.SASLAuthentication.SASL2_NAMESPACE; import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; +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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -337,6 +340,107 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 } } + /** + * Verifies that a SASL2 authentication whose inline Bind2 request cannot be honoured fails, rather than reporting + * success for a session that has no resource bound. + * + * A client that receives {@code } without {@code } has no way to tell that binding failed: it + * believes it is authenticated, and the stream features that follow tell it nothing to the contrary. + */ + @Test + public void bind2ConflictFailsSasl2WithoutSuccessOrFeatures() 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 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)); + + // 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(), "A bind that could not be honoured must fail the authentication."); + assertEquals(SASLAuthentication.SASL2_NAMESPACE, failure.getNamespaceURI(), "The failure must be in the SASL2 namespace."); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASLAuthentication.SASL_NAMESPACE)), + "A bind conflict 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."); + } + + /** + * Verifies the same for a resource binding that fails by throwing, rather than by reporting a conflict. + */ + @Test + public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() 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 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); + + // 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(), "A bind that threw must fail the authentication."); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASLAuthentication.SASL_NAMESPACE)), + "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."); + } + + /** + * Verifies that inline feature handlers run against a session that is already authenticated. + * + * Some inline features are only permitted once a resource has been bound and the session is authenticated; + * XEP-0198 § 3 says as much of stream management, and its handler consults the session's state. Running the + * handlers first would make every such feature report an unexpected-request failure. + */ + @Test + public void bind2InlineHandlersRunAgainstAnAuthenticatedSession() 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 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 AtomicReference statusWhenHandlersRan = new AtomicReference<>(); + when(bind2Request.processFeatureRequests(any(), any())).thenAnswer(invocation -> { + statusWhenHandlersRan.set(session.getStatus()); + return DocumentHelper.createElement(QName.get("bound", "urn:xmpp:bind:0")); + }); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + // Verify result. + verify(bind2Request).processFeatureRequests(any(), any()); + assertEquals(Session.Status.AUTHENTICATED, statusWhenHandlersRan.get(), + "An inline feature handler must observe a session that is already authenticated, as some features are only permitted then."); + } + /** * Verifies that authenticationSuccessful generates a user auth token for a client with a username. * For SASL1, the success element has no authorization-identifier.