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 dba8971f6e..7c0bc39c03 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -16,6 +16,7 @@ package org.jivesoftware.openfire.net; +import com.google.common.annotations.VisibleForTesting; import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.Namespace; @@ -33,23 +34,18 @@ import org.jivesoftware.openfire.sasl.JiveSharedSecretSaslServer; import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.openfire.sasl.ScramSha1SaslServer; -import org.jivesoftware.openfire.session.ClientSession; -import org.jivesoftware.openfire.session.ConnectionSettings; -import org.jivesoftware.openfire.session.IncomingServerSession; -import org.jivesoftware.openfire.session.LocalClientSession; -import org.jivesoftware.openfire.session.LocalIncomingServerSession; -import org.jivesoftware.openfire.session.LocalSession; -import org.jivesoftware.openfire.session.ServerSession; -import org.jivesoftware.openfire.session.Session; +import org.jivesoftware.openfire.session.*; import org.jivesoftware.openfire.spi.ConnectionType; import org.jivesoftware.util.CertificateManager; import org.jivesoftware.util.JiveGlobals; +import org.jivesoftware.util.PropertyEventDispatcher; import org.jivesoftware.util.PropertyEventListener; import org.jivesoftware.util.SystemProperty; import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; import javax.security.sasl.Sasl; import javax.security.sasl.SaslException; import javax.security.sasl.SaslServer; @@ -165,7 +161,7 @@ public class SASLAuthentication { initMechanisms(); - org.jivesoftware.util.PropertyEventDispatcher.addListener( new PropertyEventListener() + PropertyEventDispatcher.addListener( new PropertyEventListener() { @Override public void propertySet( String property, Map params ) @@ -264,28 +260,30 @@ else if ( session instanceof LocalIncomingServerSession ) } } - public static Element getSASLMechanismsElement( ClientSession session ) + /** + * Generates an XML element that represents the available SASL mechanisms for a given client session. + * + * Based on the session and server configuration, it optionally returns null if no mechanisms are available and the + * configuration suppresses empty mechanism lists. + * + * @param session the client session for which the available SASL mechanisms are to be retrieved. + * @return an XML element listing the available SASL mechanisms, or null if no mechanisms are available + * and the configuration suppresses empty lists. + */ + public static Element getSASLMechanismsElement(@Nonnull final ClientSession session) { - final Element result = DocumentHelper.createElement( new QName( "mechanisms", new Namespace( "", SASL_NAMESPACE ) ) ); - for (String mech : getSupportedMechanisms()) { - if (mech.equals("EXTERNAL")) { - boolean trustedCert = false; - if (session.isEncrypted()) { - final Connection connection = ( (LocalClientSession) session ).getConnection(); - assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null. - if ( SKIP_PEER_CERT_REVALIDATION_CLIENT.getValue() ) { - // Trust that the peer certificate has been validated when TLS got established. - trustedCert = connection.getPeerCertificates() != null && connection.getPeerCertificates().length > 0; - } else { - // Re-evaluate the validity of the peer certificate. - final TrustStore trustStore = connection.getConfiguration().getTrustStore(); - trustedCert = trustStore.isTrusted( connection.getPeerCertificates() ); - } - } - if ( !trustedCert ) { - continue; // Do not offer EXTERNAL. - } - } + final Set availableMechanisms = getAvailableMechanismsForClientSession(session); + + // OF-2072: Return null instead of an empty element, if so configured. + if (JiveGlobals.getBooleanProperty("sasl.client.suppressEmpty", false) && availableMechanisms.isEmpty()) { + return null; + } + + final Element result = DocumentHelper.createElement( new QName("mechanisms", new Namespace("", SASL_NAMESPACE)) ); + for (final String mech : availableMechanisms) { + final Element mechanism = result.addElement("mechanism"); + mechanism.setText(mech); + if (mech.endsWith("-PLUS")) { // Prevent offering channel binding if the Connection implementation does not support it. final Connection connection = ( (LocalClientSession) session ).getConnection(); @@ -299,41 +297,36 @@ public static Element getSASLMechanismsElement( ClientSession session ) continue; } } - final Element mechanism = result.addElement("mechanism"); - mechanism.setText(mech); - } - - // OF-2072: Return null instead of an empty element, if so configured. - if ( JiveGlobals.getBooleanProperty("sasl.client.suppressEmpty", false) && result.elements().isEmpty() ) { - return null; } return result; } - public static Element getSASLMechanismsElement( LocalIncomingServerSession session ) + /** + * Generates an XML element that contains the SASL mechanisms available for a given server session. + * + * Depending on the configuration (property "sasl.server.suppressEmpty"), this method can return {@code null} + * instead of an empty mechanisms element if no SASL mechanisms are available. + * + * @param session the local incoming server session for which the available SASL mechanisms are determined. + * @return an XML {@code } element that lists all available SASL mechanisms for the given session, + * or {@code null} if no mechanisms are available and the configuration suppresses empty elements. + */ + public static Element getSASLMechanismsElement(@Nonnull final LocalIncomingServerSession session) { - final Element result = DocumentHelper.createElement( new QName( "mechanisms", new Namespace( "", SASL_NAMESPACE ) ) ); - if (session.isEncrypted()) { - final Connection connection = session.getConnection(); - final TrustStore trustStore = connection.getConfiguration().getTrustStore(); - final X509Certificate trusted = trustStore.getEndEntityCertificate( session.getConnection().getPeerCertificates() ); - - boolean haveTrustedCertificate = trusted != null; - if (trusted != null && session.getDefaultIdentity() != null) { - haveTrustedCertificate = verifyCertificate(trusted, session.getDefaultIdentity()); - } - if (haveTrustedCertificate) { - // Offer SASL EXTERNAL only if TLS has already been negotiated and the peer has a trusted cert. - final Element mechanism = result.addElement("mechanism"); - mechanism.setText("EXTERNAL"); - } - } + final Set availableMechanisms = getAvailableMechanismsForServerSession(session); // OF-2072: Return null instead of an empty element, if so configured. - if ( JiveGlobals.getBooleanProperty("sasl.server.suppressEmpty", false) && result.elements().isEmpty() ) { + if (JiveGlobals.getBooleanProperty("sasl.server.suppressEmpty", false) && availableMechanisms.isEmpty()) { return null; } + + final Element result = DocumentHelper.createElement( new QName("mechanisms", new Namespace("", SASL_NAMESPACE)) ); + for (final String mech : availableMechanisms) { + final Element mechanism = result.addElement("mechanism"); + mechanism.setText(mech); + } + return result; } @@ -376,6 +369,12 @@ public static Status handle(LocalSession session, Element doc) throw new SaslFailureException( Failure.INVALID_MECHANISM, "The configuration of Openfire does not contain or allow the mechanism." ); } + // Enforce session-specific eligibility (as advertised in stream features) See OF-3273. + if ( !getAvailableMechanismsForSession( session ).contains( mechanismName ) ) + { + throw new SaslFailureException( Failure.INVALID_MECHANISM, "The mechanism is not available for this session." ); + } + // OF-477: The SASL implementation requires the fully qualified host name (not the domain name!) of this server, // yet, most of the XMPP implemenations of DIGEST-MD5 will actually use the domain name. To account for that, // here, we'll use the host name, unless DIGEST-MD5 is being negotiated! @@ -453,27 +452,15 @@ else if (encoded.equals("=")) return Status.needResponse; } - // Success! - if ( session instanceof LocalIncomingServerSession ) - { - final LocalIncomingServerSession incomingServerSession = (LocalIncomingServerSession) session; - - // Flag that indicates if certificates of the remote server should be validated. - final boolean verify = JiveGlobals.getBooleanProperty( ConnectionSettings.Server.TLS_CERTIFICATE_VERIFY, true ); - if ( verify ) - { - if ( verifyCertificates( incomingServerSession.getConnection().getPeerCertificates(), saslServer.getAuthorizationID(), true ) ) - { - ( (LocalIncomingServerSession) session ).setAuthenticationMethod(ServerSession.AuthenticationMethod.SASL_EXTERNAL); - } - else - { - throw new SaslFailureException( Failure.NOT_AUTHORIZED, "Server-to-Server certificate verification failed." ); - } - } + if (saslServer.getAuthorizationID() != null && LockOutManager.getInstance().isAccountDisabled(saslServer.getAuthorizationID())) { + // Interception! This person is locked out, fail instead! + LockOutManager.getInstance().recordFailedLogin(saslServer.getAuthorizationID()); + throw new SaslFailureException(Failure.ACCOUNT_DISABLED); } - authenticationSuccessful( session, saslServer.getAuthorizationID(), challenge ); + // Success! Any mechanism-specific verification (such as certificate checks for EXTERNAL) is + // performed by the SaslServer implementation. + authenticationSuccessful( session, saslServer.getAuthorizationID(), saslServer.getMechanismName(), challenge ); session.removeSessionData( "SaslServer" ); session.setSessionData("SaslMechanism", saslServer.getMechanismName()); if (saslServer.getMechanismName().endsWith("-PLUS")) { @@ -551,16 +538,21 @@ private static void sendChallenge(Session session, byte[] challenge) { sendElement(session, "challenge", challenge); } - private static void authenticationSuccessful(LocalSession session, String username, - byte[] successData) { - if (username != null && LockOutManager.getInstance().isAccountDisabled(username)) { - // Interception! This person is locked out, fail instead! - LockOutManager.getInstance().recordFailedLogin(username); - authenticationFailed(session, Failure.ACCOUNT_DISABLED); - return; - } + /** + * Processes a successful SASL authentication. + * + * For client sessions, generates an authentication token. For inbound server sessions, marks the domain as + * validated and records the authentication method used. + * + * @param session the authenticated session (cannot be null). + * @param username the authorized identity from SASL (can be null for anonymous). + * @param mechanismName the name of the SASL mechanism that was used (cannot be null). + * @param successData mechanism-specific success data (can be null). + */ + @VisibleForTesting + static void authenticationSuccessful(LocalSession session, String username, String mechanismName, byte[] successData) + { sendElement(session, "success", successData); - // We only support SASL for c2s if (session instanceof ClientSession) { final AuthToken authToken; if (username == null) { @@ -571,13 +563,11 @@ private static void authenticationSuccessful(LocalSession session, String userna } ((LocalClientSession) session).setAuthToken(authToken); } - else if (session instanceof IncomingServerSession) { - String hostname = username; - // Add the validated domain as a valid domain. The remote server can - // now send packets from this address - ((LocalIncomingServerSession) session).addValidatedDomain(hostname); - ((LocalIncomingServerSession) session).setAuthenticationMethod(ServerSession.AuthenticationMethod.SASL_EXTERNAL); - Log.info("Inbound Server {} authenticated (via TLS)", username); + else if (session instanceof LocalIncomingServerSession serverSession) { + // Add the validated domain as a valid domain. The remote server can now send packets from this address. + serverSession.addValidatedDomain(username); + serverSession.setAuthenticationMethod(ServerSession.AuthenticationMethod.fromSaslMechanismName(mechanismName)); + Log.info("Inbound Server {} authenticated using SASL mechanism {}", username, mechanismName); } } @@ -745,6 +735,29 @@ public static Set getImplementedMechanisms() return result; } + /** + * Returns the set of SASL mechanisms available for the given session. + * + * @param session the session (cannot be null). + * @return a set of available mechanism names for the session (never null, possibly empty). + */ + @VisibleForTesting + static Set getAvailableMechanismsForSession( final LocalSession session ) + { + if ( session instanceof ClientSession ) + { + return getAvailableMechanismsForClientSession( (ClientSession) session ); + } + else if ( session instanceof LocalIncomingServerSession ) + { + return getAvailableMechanismsForServerSession( (LocalIncomingServerSession) session ); + } + else + { + return Collections.emptySet(); + } + } + /** * Returns a collection of SASL mechanism names that forms the source pool from which the mechanisms that are * eventually being offered to peers are obtained. @@ -791,4 +804,77 @@ private static void initMechanisms() } } } + + /** + * Determines and returns the set of SASL mechanisms that are available for a given client session. This includes + * mechanisms from the list of supported mechanisms, applying additional checks to ensure mechanism-specific + * requirements (e.g., encryption and certificate validation) are met. + * + * @param session The client session for which available SASL mechanisms need to be determined. + * Must not be null. + * @return A set of available SASL mechanism names for the specified client session. + * Will never be null but might be empty if no mechanisms are available. + */ + private static Set getAvailableMechanismsForClientSession(@Nonnull final ClientSession session ) + { + final Set result = new HashSet<>(); + for (String mech : getSupportedMechanisms()) { + if (mech.equals("EXTERNAL")) { + boolean trustedCert = false; + if (session.isEncrypted()) { + final Connection connection = ( (LocalClientSession) session ).getConnection(); + assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null. + if ( SKIP_PEER_CERT_REVALIDATION_CLIENT.getValue() ) { + // Trust that the peer certificate has been validated when TLS got established. + trustedCert = connection.getPeerCertificates() != null && connection.getPeerCertificates().length > 0; + } else { + // Re-evaluate the validity of the peer certificate. + final TrustStore trustStore = connection.getConfiguration().getTrustStore(); + trustedCert = trustStore.isTrusted( connection.getPeerCertificates() ); + } + } + if ( !trustedCert ) { + continue; // Do not offer EXTERNAL. + } + } + result.add(mech); + } + return result; + } + + /** + * Determines the set of available SASL mechanisms for the given server session. This method checks the session's + * encryption status and examines the trust relationship to determine if specific mechanisms (such as SASL EXTERNAL) + * can be offered. + * + * @param session the server session for which the available mechanisms are to be determined. + * Must not be null. + * @return a set of SASL mechanism names that can be offered for the specified session. + * If no mechanisms are available, an empty set is returned. + */ + private static Set getAvailableMechanismsForServerSession(@Nonnull final LocalIncomingServerSession session) + { + final Set result = new HashSet<>(); + + // Check if EXTERNAL is enabled in the supported mechanisms configuration + if (!getSupportedMechanisms().contains("EXTERNAL")) { + return result; + } + + if (session.isEncrypted()) { + final Connection connection = session.getConnection(); + final TrustStore trustStore = connection.getConfiguration().getTrustStore(); + final X509Certificate trusted = trustStore.getEndEntityCertificate( session.getConnection().getPeerCertificates() ); + + boolean haveTrustedCertificate = trusted != null; + if (trusted != null && session.getDefaultIdentity() != null) { + haveTrustedCertificate = verifyCertificate(trusted, session.getDefaultIdentity()); + } + if (haveTrustedCertificate) { + // Offer SASL EXTERNAL only if TLS has already been negotiated and the peer has a trusted cert. + result.add("EXTERNAL"); + } + } + return result; + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/ServerSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/ServerSession.java index 793b69ae46..ac67ceead0 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/ServerSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/ServerSession.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018-2023 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2018-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. @@ -20,7 +20,20 @@ public interface ServerSession extends Session { enum AuthenticationMethod { DIALBACK, SASL_EXTERNAL, - OTHER + OTHER; + + /** + * Maps a SASL mechanism name to the matching authentication method. + * + * @param mechanismName the SASL mechanism name (can be null). + * @return SASL_EXTERNAL when mechanismName is EXTERNAL (case-insensitive), otherwise OTHER. + */ + public static AuthenticationMethod fromSaslMechanismName(final String mechanismName) { + if (mechanismName != null && mechanismName.equalsIgnoreCase("EXTERNAL")) { + return SASL_EXTERNAL; + } + return OTHER; + } } /** diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java new file mode 100644 index 0000000000..e27182b2d5 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -0,0 +1,385 @@ +/* + * 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.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.StreamID; +import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.auth.AuthToken; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.session.LocalIncomingServerSession; +import org.jivesoftware.openfire.session.LocalSession; +import org.jivesoftware.openfire.session.ServerSession; +import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +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.mockito.ArgumentCaptor; + +import javax.security.sasl.SaslServer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SASLAuthentication}. + */ +public class SASLAuthenticationTest +{ + private static final String SASL_NAMESPACE = "urn:ietf:params:xml:ns:xmpp-sasl"; + + @BeforeAll + public static void setupClass() throws Exception + { + Fixtures.reconfigureOpenfireHome(); + Fixtures.disableDatabasePersistence(); + } + + @AfterAll + public static void tearDownClass() + { + Fixtures.clearExistingProperties(); + } + + @BeforeEach + public void setup() + { + Fixtures.clearExistingProperties(); + JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN); + + XMPPServer.setInstance(Fixtures.mockXMPPServer()); + SASLAuthentication.setEnabledMechanisms(Arrays.asList("PLAIN", "EXTERNAL")); + } + + /** + * Verifies that an unencrypted client session cannot use EXTERNAL when that mechanism is not available for the session. + * + * @see OF-3273: SASLAuthentication accepts mechanisms not advertised for the current connection/session + */ + @Test + public void shouldRejectExternalForUnencryptedClientSessionAsInvalidMechanism() + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("EXTERNAL")); + + // Verify result. + assertEquals(SASLAuthentication.Status.failed, status, "Expected SASL negotiation to fail when EXTERNAL is requested on an unencrypted client session."); + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertTrue(response.getValue().contains("OF-3273: SASLAuthentication accepts mechanisms not advertised for the current connection/session + */ + @Test + public void shouldRejectPlainForUnencryptedIncomingServerSessionAsInvalidMechanism() + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalIncomingServerSession session = new LocalIncomingServerSession(Fixtures.XMPP_DOMAIN, connection, streamID, "remote.example.org"); + + // Execute system under test. + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN")); + + // Verify result. + assertEquals(SASLAuthentication.Status.failed, status, "Expected SASL negotiation to fail when PLAIN is requested for an inbound server session that does not advertise it."); + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertTrue(response.getValue().contains("OF-3273: SASLAuthentication accepts mechanisms not advertised for the current connection/session + */ + @Test + public void shouldAcceptPlainForUnencryptedClientSessionAsEligibleMechanism() + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN")); + + // Verify result. + assertEquals(SASLAuthentication.Status.needResponse, status, "Expected PLAIN to be accepted and continue negotiation by issuing a challenge."); + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertFalse(response.getValue().contains(" mechanisms = SASLAuthentication.getAvailableMechanismsForSession(session); + + // Verify result. + assertTrue(mechanisms.contains("PLAIN"), "Expected PLAIN to be available for an unencrypted client session."); + assertFalse(mechanisms.contains("EXTERNAL"), "Expected EXTERNAL not to be available for an unencrypted client session without a trusted cert."); + } + + /** + * Verifies that getAvailableMechanismsForSession returns mechanisms for an incoming server session. + */ + @Test + public void shouldReturnAvailableMechanismsForIncomingServerSession() + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalIncomingServerSession session = new LocalIncomingServerSession(Fixtures.XMPP_DOMAIN, connection, streamID, "remote.example.org"); + + // Execute system under test. + final Set mechanisms = SASLAuthentication.getAvailableMechanismsForSession(session); + + // Verify result. + assertTrue(mechanisms.isEmpty(), "Expected no mechanisms to be available for an unencrypted server session without a trusted cert."); + } + + /** + * Verifies that getAvailableMechanismsForSession does not advertise EXTERNAL for an incoming server session + * when EXTERNAL is disabled in the global SASL mechanisms configuration. + * + * Regression test for: Stream features advertise EXTERNAL even when disabled in sasl.mechs + */ + @Test + public void shouldNotAdvertiseExternalForIncomingServerSessionWhenDisabledGlobally() + { + // Save the original enabled mechanisms to restore after the test. + final Set originalMechanisms = new HashSet<>(SASLAuthentication.getEnabledMechanisms()); + + try { + // Setup test fixture: Disable EXTERNAL in the global mechanisms configuration + SASLAuthentication.setEnabledMechanisms(Collections.singletonList("PLAIN")); // Only PLAIN, no EXTERNAL + + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(true); + + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalIncomingServerSession session = new LocalIncomingServerSession(Fixtures.XMPP_DOMAIN, connection, streamID, "remote.example.org"); + + // Execute system under test. + final Set mechanisms = SASLAuthentication.getAvailableMechanismsForSession(session); + + // Verify result. + assertFalse(mechanisms.contains("EXTERNAL"), "Expected EXTERNAL not to be advertised when disabled in global mechanisms configuration, even for encrypted sessions."); + } finally { + // Restore state to prevent affecting other unit tests. + SASLAuthentication.setEnabledMechanisms(new ArrayList<>(originalMechanisms)); + } + } + + /** + * Verifies that getAvailableMechanismsForSession handles unknown session types gracefully. + */ + @Test + public void shouldReturnEmptySetForUnknownSessionType() + { + // Setup test fixture. + final LocalSession unknownSession = mock(LocalSession.class); + + // Execute system under test. + final Set mechanisms = SASLAuthentication.getAvailableMechanismsForSession(unknownSession); + + // Verify result. + assertTrue(mechanisms.isEmpty(), "Expected empty set for an unknown session type."); + } + + /** + * Verifies that authenticationSuccessful generates an anonymous auth token for a client with no username. + */ + @Test + public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNull() + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0]); + + // Verify result. + final AuthToken authToken = session.getAuthToken(); + assertTrue(authToken.isAnonymous(), "Expected an anonymous auth token when username is null."); + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertTrue(response.getValue().contains(" response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertTrue(response.getValue().contains(" response = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(response.capture()); + assertTrue(response.getValue().contains("