This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit cd8719eeb3ec69413c2aaacc0ff55c887dd7d45d
Author: Quan Tran <[email protected]>
AuthorDate: Wed Jun 24 11:03:26 2026 +0700

    JAMES-4210 Drive SMTP AUTH through SASL exchanges
    
    Replace embedded SMTP PLAIN/OIDC authentication logic with the shared 
SaslMechanism exchange flow, including LOGIN framing, final server data 
acknowledgement, SMTP session authentication state, and SASL-focused regression 
tests.
---
 .../james/protocols/smtp/SMTPConfiguration.java    |   6 -
 .../protocols/smtp/SMTPConfigurationImpl.java      |  12 -
 .../protocols/smtp/SMTPProtocolHandlerChain.java   |  45 +-
 .../apache/james/protocols/smtp/SMTPSession.java   |   2 -
 .../james/protocols/smtp/SMTPSessionImpl.java      |   5 -
 .../protocols/smtp/core/esmtp/AuthCmdHandler.java  | 600 +++++++++------------
 .../smtp/core/esmtp/AuthCmdHandlerTest.java        |  68 ---
 .../protocols/smtp/utils/BaseFakeSMTPSession.java  |   5 -
 .../apache/james/smtpserver/AuthAnnounceTest.java  |  42 +-
 .../org/apache/james/smtpserver/SMTPSaslTest.java  | 287 ++++++++--
 .../apache/james/smtpserver/SMTPServerTest.java    |   4 +-
 .../james/smtpserver/SMTPServerTestSystem.java     |  60 ++-
 12 files changed, 590 insertions(+), 546 deletions(-)

diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java
index a0eb940633..32ae29440f 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java
@@ -22,10 +22,8 @@
 package org.apache.james.protocols.smtp;
 
 import java.util.Locale;
-import java.util.Optional;
 import java.util.Set;
 
-import org.apache.james.jwt.OidcSASLConfiguration;
 import org.apache.james.protocols.api.ProtocolConfiguration;
 
 import com.google.common.collect.ImmutableSet;
@@ -97,10 +95,6 @@ public interface SMTPConfiguration extends 
ProtocolConfiguration {
      */
     boolean useAddressBracketsEnforcement();
 
-    boolean isPlainAuthEnabled();
-
-    Optional<OidcSASLConfiguration> saslConfiguration();
-
     default Set<String> disabledFeatures() {
         return ImmutableSet.of();
     }
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java
index 3890210d72..fe082f4723 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java
@@ -20,9 +20,6 @@
 
 package org.apache.james.protocols.smtp;
 
-import java.util.Optional;
-
-import org.apache.james.jwt.OidcSASLConfiguration;
 import org.apache.james.protocols.api.ProtocolConfigurationImpl;
 
 /**
@@ -86,13 +83,4 @@ public class SMTPConfigurationImpl extends 
ProtocolConfigurationImpl implements
         this.bracketsEnforcement = bracketsEnforcement;
     }
 
-    @Override
-    public boolean isPlainAuthEnabled() {
-        return true;
-    }
-
-    @Override
-    public Optional<OidcSASLConfiguration> saslConfiguration() {
-        return Optional.empty();
-    }
 }
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java
index 098d1a97bd..e17fdc847a 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java
@@ -19,13 +19,11 @@
 package org.apache.james.protocols.smtp;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.protocols.api.handler.CommandDispatcher;
 import org.apache.james.protocols.api.handler.CommandHandlerResultLogger;
-import org.apache.james.protocols.api.handler.ExtensibleHandler;
 import org.apache.james.protocols.api.handler.ProtocolHandler;
 import org.apache.james.protocols.api.handler.ProtocolHandlerChain;
 import org.apache.james.protocols.api.handler.ProtocolHandlerChainImpl;
@@ -49,7 +47,6 @@ import 
org.apache.james.protocols.smtp.core.esmtp.AuthCmdHandler;
 import org.apache.james.protocols.smtp.core.esmtp.EhloCmdHandler;
 import org.apache.james.protocols.smtp.core.esmtp.MailSizeEsmtpExtension;
 import org.apache.james.protocols.smtp.core.esmtp.StartTlsCmdHandler;
-import org.apache.james.protocols.smtp.hook.AuthHook;
 import org.apache.james.protocols.smtp.hook.Hook;
 
 /**
@@ -87,6 +84,7 @@ public class SMTPProtocolHandlerChain extends 
ProtocolHandlerChainImpl {
     protected List<ProtocolHandler> initDefaultHandlers() {
         List<ProtocolHandler> defaultHandlers = new ArrayList<>();
         defaultHandlers.add(new CommandDispatcher<SMTPSession>());
+        defaultHandlers.add(new AuthCmdHandler());
         defaultHandlers.add(new ExpnCmdHandler());
         defaultHandlers.add(new EhloCmdHandler(metricFactory));
         defaultHandlers.add(new HeloCmdHandler(metricFactory));
@@ -109,45 +107,4 @@ public class SMTPProtocolHandlerChain extends 
ProtocolHandlerChainImpl {
         return defaultHandlers;
     }
 
-    private synchronized boolean checkForAuth(ProtocolHandler handler) {
-        if (isReadyOnly()) {
-            throw new UnsupportedOperationException("Read-Only");
-        }
-        if (handler instanceof AuthHook) {
-            // check if we need to add the AuthCmdHandler
-            List<ExtensibleHandler> handlers = 
getHandlers(ExtensibleHandler.class);
-            for (ExtensibleHandler h: handlers) {
-                if (h.getMarkerInterfaces().contains(AuthHook.class)) {
-                    return true;
-                }
-            }
-            if (!add(new AuthCmdHandler())) {
-                return false;
-            }
-        }
-        return true;
-    }
-    
-    @Override
-    public boolean add(ProtocolHandler handler) {
-        checkForAuth(handler);
-        return super.add(handler);
-    }
-
-    @Override
-    public boolean addAll(Collection<? extends ProtocolHandler> c) {
-        return c.stream().allMatch(this::checkForAuth) && super.addAll(c);
-    }
-
-    @Override
-    public boolean addAll(int index, Collection<? extends ProtocolHandler> c) {
-        return c.stream().allMatch(this::checkForAuth) && super.addAll(index, 
c);
-    }
-
-    @Override
-    public void add(int index, ProtocolHandler element) {
-        checkForAuth(element);
-        super.add(index, element);
-    }
-  
 }
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java
index 960bab9085..dfdefa0f7e 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java
@@ -91,8 +91,6 @@ public interface SMTPSession extends ProtocolSession {
     int getRcptCount();
 
 
-    boolean supportsOAuth();
-
     long currentMessageSize();
 
     void setCurrentMessageSize(long increment);
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java
index b5b35ac5c9..1d13efdb64 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java
@@ -86,11 +86,6 @@ public class SMTPSessionImpl extends ProtocolSessionImpl 
implements SMTPSession
             .orElse(0);
     }
 
-    @Override
-    public boolean supportsOAuth() {
-        return getConfiguration().saslConfiguration().isPresent() && 
isAuthAnnounced();
-    }
-
     @Override
     public boolean isAuthAnnounced() {
         return 
getConfiguration().isAuthAnnounced(getRemoteAddress().getAddress().getHostAddress(),
 isTLSStarted());
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java
index 95055945cd..e17d6a5bf5 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java
@@ -21,24 +21,28 @@
 package org.apache.james.protocols.smtp.core.esmtp;
 
 import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
 import java.util.Base64;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.util.function.Function;
+import java.util.stream.Stream;
 
-import org.apache.commons.lang3.StringUtils;
 import org.apache.james.core.Username;
-import org.apache.james.jwt.OidcSASLConfiguration;
 import org.apache.james.protocols.api.Request;
 import org.apache.james.protocols.api.Response;
 import org.apache.james.protocols.api.handler.CommandHandler;
 import org.apache.james.protocols.api.handler.ExtensibleHandler;
 import org.apache.james.protocols.api.handler.LineHandler;
 import org.apache.james.protocols.api.handler.WiringException;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismNames;
+import org.apache.james.protocols.api.sasl.SaslStep;
 import org.apache.james.protocols.smtp.SMTPResponse;
 import org.apache.james.protocols.smtp.SMTPRetCode;
 import org.apache.james.protocols.smtp.SMTPSession;
@@ -46,15 +50,13 @@ import org.apache.james.protocols.smtp.dsn.DSNStatus;
 import org.apache.james.protocols.smtp.hook.AuthHook;
 import org.apache.james.protocols.smtp.hook.HookResult;
 import org.apache.james.protocols.smtp.hook.HookResultHook;
-import org.apache.james.protocols.smtp.hook.HookReturnCode;
 import org.apache.james.protocols.smtp.hook.MailParametersHook;
+import org.apache.james.protocols.smtp.hook.SaslAuthResultHook;
 import org.apache.james.util.AuditTrail;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -63,10 +65,7 @@ import com.google.common.collect.ImmutableSet;
 /**
  * handles AUTH command
  *
- * Note: we could extend this to use java5 sasl standard libraries and provide 
client
- * support against a server implemented via non-james specific hooks.
- * This would allow us to reuse hooks between imap4/pop3/smtp and eventually 
different
- * system (simple pluggabilty against external authentication services).
+ * Authentication is delegated to configured SASL mechanisms.
  */
 public class AuthCmdHandler
     implements CommandHandler<SMTPSession>, EhloExtension, ExtensibleHandler, 
MailParametersHook {
@@ -80,11 +79,14 @@ public class AuthCmdHandler
     private static final Response ALREADY_AUTH = new 
SMTPResponse(SMTPRetCode.BAD_SEQUENCE, 
DSNStatus.getStatus(DSNStatus.PERMANENT,DSNStatus.DELIVERY_OTHER) + " User has 
previously authenticated. "
             + " Further authentication is not required!").immutable();
     private static final Response SYNTAX_ERROR = new 
SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, 
DSNStatus.getStatus(DSNStatus.PERMANENT,DSNStatus.DELIVERY_INVALID_ARG) + " 
Usage: AUTH (authentication type) <challenge>").immutable();
-    private static final Response AUTH_READY_PLAIN = new 
SMTPResponse(SMTPRetCode.AUTH_READY, "OK. Continue authentication").immutable();
     private static final Response AUTH_READY_USERNAME_LOGIN = new 
SMTPResponse(SMTPRetCode.AUTH_READY, "VXNlcm5hbWU6").immutable(); // base64 
encoded "Username:"
     private static final Response AUTH_READY_PASSWORD_LOGIN = new 
SMTPResponse(SMTPRetCode.AUTH_READY, "UGFzc3dvcmQ6").immutable(); // base64 
encoded "Password:
+    private static final Response AUTH_SUCCEEDED = new 
SMTPResponse(SMTPRetCode.AUTH_OK, "Authentication Successful").immutable();
     private static final Response AUTH_FAILED = new 
SMTPResponse(SMTPRetCode.AUTH_FAILED, "Authentication Failed").immutable();
     private static final Response UNKNOWN_AUTH_TYPE = new 
SMTPResponse(SMTPRetCode.PARAMETER_NOT_IMPLEMENTED, "Unrecognized 
Authentication Type").immutable();
+    private static final Response SERVER_ERROR = new 
SMTPResponse(SMTPRetCode.LOCAL_ERROR, "Unable to process request").immutable();
+
+    private static final SmtpSaslBridge SASL_BRIDGE = new SmtpSaslBridge();
 
     private abstract static class AbstractSMTPLineHandler implements 
LineHandler<SMTPSession> {
 
@@ -122,18 +124,18 @@ public class AuthCmdHandler
      */
     protected static final String AUTH_TYPE_LOGIN = "LOGIN";
 
-    /**
-     * The text string for the SMTP AUTH type OAUTHBEARER.
-     */
-    protected static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER";
-    protected static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2";
-
-    /**
-     * The AuthHooks
-     */
-    private List<AuthHook> hooks;
-
-    private List<HookResultHook> rHooks;
+    private ImmutableList<SaslMechanism> saslMechanisms = ImmutableList.of();
+    private ImmutableList<SaslMechanism> effectiveSaslMechanisms = 
ImmutableList.of();
+    private Optional<SaslAuthenticator> saslAuthenticator = Optional.empty();
+    private ImmutableList<AuthHook> authHooks = ImmutableList.of();
+    private ImmutableList<HookResultHook> hookResultHooks = ImmutableList.of();
+    private ImmutableList<SaslAuthResultHook> saslAuthResultHooks = 
ImmutableList.of();
+
+    public void configureSaslMechanisms(ImmutableList<SaslMechanism> 
saslMechanisms, SaslAuthenticator saslAuthenticator) {
+        this.saslMechanisms = saslMechanisms;
+        this.saslAuthenticator = Optional.of(saslAuthenticator);
+        updateEffectiveSaslMechanisms();
+    }
 
     /**
      * handles AUTH command
@@ -165,356 +167,273 @@ public class AuthCmdHandler
                 argument = argument.substring(0,argument.indexOf(" "));
             }
             String authType = argument.toUpperCase(Locale.US);
-            if (authType.equals(AUTH_TYPE_PLAIN) && 
session.getConfiguration().isPlainAuthEnabled()) {
-                return handlePlainContinuation(session, initialResponse);
-            } else if (authType.equals(AUTH_TYPE_LOGIN) && 
session.getConfiguration().isPlainAuthEnabled()) {
-                return handleLoginAuthContinuation(session, initialResponse);
-            } else if ((authType.equals(AUTH_TYPE_OAUTHBEARER) || 
authType.equals(AUTH_TYPE_XOAUTH2)) && session.supportsOAuth()) {
-                return handleOauth2Continuation(session, initialResponse);
-            } else {
-                return doUnknownAuth(authType);
-            }
+            return handleSaslAuthentication(session, authType, 
Optional.ofNullable(initialResponse).map(String::trim));
         }
     }
 
-    private Response handlePlainContinuation(SMTPSession session, String 
initialResponse) {
-        return Optional.ofNullable(initialResponse)
-        .map(String::trim)
-        .map(userpass -> doPlainAuth(session, userpass))
-        .orElseGet(() -> {
-            session.pushLineHandler(new AbstractSMTPLineHandler() {
-                @Override
-                protected Response onCommand(SMTPSession session, String l) {
-                    return doPlainAuth(session, l);
-                }
-            });
-            return AUTH_READY_USERNAME_LOGIN;
-        });
-    }
+    private Response handleSaslAuthentication(SMTPSession session, String 
authType, Optional<String> initialResponse) {
+        if (authType.equals(AUTH_TYPE_LOGIN)) {
+            return handleLoginFraming(session, initialResponse);
+        }
 
-    private Response handleLoginAuthContinuation(SMTPSession session, String 
initialResponse) {
-        return Optional.ofNullable(initialResponse)
-            .map(String::trim)
-            .map(user -> doLoginAuthPass(session, user))
-            .orElseGet(() -> {
-                session.pushLineHandler(new AbstractSMTPLineHandler() {
-                    @Override
-                    protected Response onCommand(SMTPSession session, String 
l) {
-                        return doLoginAuthPass(session, l);
-                    }
-                });
-                return AUTH_READY_USERNAME_LOGIN;
-            });
+        Optional<SaslMechanism> maybeMechanism = 
findAvailableMechanism(session, authType);
+        if (maybeMechanism.isEmpty()) {
+            return doUnknownAuth(authType);
+        }
+
+        SaslMechanism mechanism = maybeMechanism.get();
+        SaslExchange exchange;
+        try {
+            SaslInitialRequest request = SASL_BRIDGE.initialRequest(authType, 
initialResponse);
+            exchange = startExchange(session, mechanism, request);
+            return handleFirstSaslStep(session, authType, exchange);
+        } catch (IllegalArgumentException e) {
+            LOGGER.info("Could not decode parameters for AUTH {}", authType, 
e);
+            return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could 
not decode parameters for AUTH " + authType);
+        }
     }
 
-    private Response handleOauth2Continuation(SMTPSession session, String 
initialResponse) {
-        return Optional.ofNullable(initialResponse)
-            .map(token -> doOauth2Authentication(session, token))
-            .orElseGet(() -> {
-                session.pushLineHandler(new AbstractSMTPLineHandler() {
-                    @Override
-                    protected Response onCommand(SMTPSession session, String 
l) {
-                        Response response = doOauth2Authentication(session, l);
-                        session.popLineHandler();
-                        return response;
-                    }
-                });
-                return new SMTPResponse(SMTPRetCode.AUTH_READY, "");
-            });
+    private Response handleFirstSaslStep(SMTPSession session, String authType, 
SaslExchange exchange) {
+        try {
+            SaslStep step = exchange.firstStep();
+            return handleSaslStep(session, authType, exchange, step);
+        } catch (RuntimeException e) {
+            SASL_BRIDGE.close(exchange);
+            throw e;
+        }
     }
 
-    private Response doOauth2Authentication(SMTPSession session, String 
initialResponseString) {
-        return session.getConfiguration().saslConfiguration()
-            .map(oidcSASLConfiguration -> hooks.stream()
-                .flatMap(hook -> Optional.ofNullable(executeHook(session, hook,
-                    hook2 -> hook2.doSasl(session, oidcSASLConfiguration, 
initialResponseString))).stream())
-                .filter(response -> 
!SMTPRetCode.AUTH_FAILED.equals(response.getRetCode()))
-                .findFirst()
-                .orElseGet(() -> failSasl(oidcSASLConfiguration, session)))
-            .orElseGet(() -> doUnknownAuth(AUTH_TYPE_OAUTHBEARER));
+    private Response handleSaslStep(SMTPSession session, String authType, 
SaslExchange exchange, SaslStep step) {
+        return switch (step) {
+            case SaslStep.Challenge challenge -> {
+                session.pushLineHandler(saslLineHandler(authType, exchange));
+                yield SASL_BRIDGE.challenge(challenge);
+            }
+            case SaslStep.Success success -> handleSaslSuccess(session, 
authType, exchange, success);
+            case SaslStep.Failure failure -> handleSaslFailure(session, 
authType, exchange, failure.failure());
+        };
     }
 
-    private Response failSasl(OidcSASLConfiguration saslConfiguration, 
SMTPSession session) {
-        String rawResponse = 
String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}",
-            saslConfiguration.getScope(),
-            saslConfiguration.getOidcConfigurationURL().toString());
+    private Response handleSaslContinuation(SMTPSession session, String 
authType, SaslExchange exchange, String line) {
+        try {
+            SaslStep step = SASL_BRIDGE.onClientResponse(exchange, 
line.getBytes(session.getCharset()));
+            return switch (step) {
+                case SaslStep.Challenge challenge -> 
SASL_BRIDGE.challenge(challenge);
+                case SaslStep.Success success -> {
+                    session.popLineHandler();
+                    yield handleSaslSuccess(session, authType, exchange, 
success);
+                }
+                case SaslStep.Failure failure -> {
+                    session.popLineHandler();
+                    yield handleSaslFailure(session, authType, exchange, 
failure.failure());
+                }
+            };
+        } catch (IllegalArgumentException e) {
+            LOGGER.info("Could not decode parameters for AUTH {}", authType, 
e);
+            session.popLineHandler();
+            SASL_BRIDGE.close(exchange);
+            return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could 
not decode parameters for AUTH " + authType);
+        } catch (RuntimeException e) {
+            session.popLineHandler();
+            SASL_BRIDGE.close(exchange);
+            throw e;
+        }
+    }
 
-        session.pushLineHandler(new AbstractSMTPLineHandler() {
-            @Override
-            protected Response onCommand(SMTPSession session, String l) {
+    private LineHandler<SMTPSession> saslLineHandler(String authType, 
SaslExchange exchange) {
+        return (session, line) -> {
+            if (SASL_BRIDGE.isAbort(line)) {
                 session.popLineHandler();
-
-                return AUTH_FAILED;
+                SASL_BRIDGE.abort(exchange);
+                return AUTH_ABORTED;
             }
-        });
-        return new SMTPResponse("334", 
Base64.getEncoder().encodeToString(rawResponse.getBytes()));
+            return handleSaslContinuation(session, authType, exchange, new 
String(line, session.getCharset()));
+        };
     }
 
-    /**
-     * Carries out the Plain AUTH SASL exchange.
-     *
-     * According to RFC 2595 the client must send: [authorize-id] \0 
authenticate-id \0 password.
-     *
-     * >>> AUTH PLAIN dGVzdAB0ZXN0QHdpei5leGFtcGxlLmNvbQB0RXN0NDI=
-     * Decoded: test\[email protected]\000tEst42
-     *
-     * >>> AUTH PLAIN dGVzdAB0ZXN0AHRFc3Q0Mg==
-     * Decoded: test\000test\000tEst42
-     *
-     * @param session SMTP session object
-     * @param line the initial response line passed in with the AUTH command
-     */
-    private Response doPlainAuth(SMTPSession session, String line) {
+    private Response handleSaslSuccess(SMTPSession session, String authType, 
SaslExchange exchange, SaslStep.Success success) {
+        if (success.serverData().isPresent()) {
+            
session.pushLineHandler(successDataAcknowledgementLineHandler(authType, 
exchange, success));
+            return SASL_BRIDGE.successData(success);
+        }
         try {
+            return applySaslSuccess(session, authType, exchange, success);
+        } finally {
+            SASL_BRIDGE.close(exchange);
+        }
+    }
 
-            AuthValues authValues =
-                    Optional.ofNullable(decodeBase64(line))
-                            .flatMap(AuthCmdHandler::parseAuthValues)
-                            .orElseThrow(() -> new 
IllegalArgumentException("Can't parse line as authentication values"));
-
-            Response response;
+    private LineHandler<SMTPSession> 
successDataAcknowledgementLineHandler(String authType, SaslExchange exchange, 
SaslStep.Success success) {
+        return (session, line) -> 
handleSaslSuccessDataAcknowledgement(session, authType, exchange, success, new 
String(line, session.getCharset()));
+    }
 
-            if (authValues.password.isEmpty()) {
-                response = doDelegation(session, authValues.username);
-            } else {
-                response = doAuthTest(session, 
Optional.of(authValues.username), authValues.password, AUTH_TYPE_PLAIN);
+    private Response handleSaslSuccessDataAcknowledgement(SMTPSession session, 
String authType, SaslExchange exchange,
+                                                          SaslStep.Success 
success, String line) {
+        session.popLineHandler();
+        boolean aborted = false;
+        try {
+            byte[] bytes = line.getBytes(session.getCharset());
+            if (SASL_BRIDGE.isAbort(bytes)) {
+                aborted = true;
+                SASL_BRIDGE.abort(exchange);
+                return AUTH_ABORTED;
+            }
+            if (!SASL_BRIDGE.isEmptyClientResponse(bytes)) {
+                return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, 
"Could not decode parameters for AUTH " + authType);
+            }
+            return applySaslSuccess(session, authType, exchange, success);
+        } finally {
+            if (!aborted) {
+                SASL_BRIDGE.close(exchange);
             }
-
-            session.popLineHandler();
-            return response;
-        } catch (Exception e) {
-            LOGGER.info("Could not decode parameters for AUTH PLAIN", e);
-            return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could 
not decode parameters for AUTH PLAIN");
         }
     }
 
-    @VisibleForTesting
-    static Optional<AuthValues> parseAuthValues(String input) {
-
-        List<String> parts = 
Splitter.on('\0').splitToStream(input).filter(token -> 
!token.isBlank()).toList();
-
-        return switch (parts.size()) {
-            case 1 -> Optional.of(new AuthValues(Username.of(parts.get(0)), 
Optional.empty()));
-            // If we got here, this is what happened.  RFC 2595
-            // says that "the client may leave the authorization
-            // identity empty to indicate that it is the same as
-            // the authentication identity."  As noted above,
-            // that would be represented as a decoded string of
-            // the form: "\0authenticate-id\0password".  The
-            // first call to nextToken will skip the empty
-            // authorize-id, and give us the authenticate-id,
-            // which we would store as the authorize-id.  The
-            // second call will give us the password, which we
-            // think is the authenticate-id (user).  Then when
-            // we ask for the password, there are no more
-            // elements, leading to the exception we just
-            // caught.  So we need to move the user to the
-            // password, and the authorize_id to the user.
-            case 2 -> Optional.of(new AuthValues(Username.of(parts.get(0)), 
Optional.of(parts.get(1))));
-            case 3 -> Optional.of(new AuthValues(Username.of(parts.get(1)), 
Optional.of(parts.get(2))));
-            default -> Optional.empty();
-        };
+    private Response applySaslSuccess(SMTPSession session, String authType, 
SaslExchange exchange, SaslStep.Success success) {
+        Username username = success.identity().authorizationId();
+        session.setUsername(username);
+        session.setRelayingAllowed(true);
+        saslAuthResultHooks.forEach(hook -> hook.onSuccess(session, authType, 
success.identity()));
+
+        AUTHENTICATION_DEDICATED_LOGGER.debug("AUTH method {} succeeded", 
authType);
+
+        AuditTrail.entry()
+            .username(username::asString)
+            .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress()))
+            .sessionId(session::getSessionID)
+            .protocol("SMTP")
+            .action("AUTH")
+            .parameters(() -> ImmutableMap.of("authType", authType))
+            .log("SMTP Authentication succeeded.");
+
+        return AuthHookSaslMechanism.terminalResponse(exchange)
+            .orElse(AUTH_SUCCEEDED);
     }
 
-    record AuthValues(Username username, Optional<String> password) {
+    private Response handleSaslFailure(SMTPSession session, String authType, 
SaslExchange exchange, SaslFailure failure) {
+        try {
+            saslAuthResultHooks.forEach(hook -> hook.onFailure(session, 
authType, failure));
+
+            failure.authenticationId().ifPresent(username -> AuditTrail.entry()
+                .username(username::asString)
+                .remoteIP(() -> 
Optional.ofNullable(session.getRemoteAddress()))
+                .protocol("SMTP")
+                .action("AUTH")
+                .parameters(() -> ImmutableMap.of("authType", authType))
+                .log("SMTP Authentication failed."));
+
+            return AuthHookSaslMechanism.terminalResponse(exchange)
+                .orElseGet(() -> switch (failure.type()) {
+                    case MALFORMED -> new 
SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters 
for AUTH " + authType);
+                    case SERVER_ERROR -> SERVER_ERROR;
+                    case INVALID_CREDENTIALS, AUTHENTICATION_FAILED, 
USER_DOES_NOT_EXIST, DELEGATION_FORBIDDEN -> AUTH_FAILED;
+                });
+        } finally {
+            SASL_BRIDGE.close(exchange);
+        }
     }
 
-    private String decodeBase64(String line) {
-        if (line != null) {
-            String lineWithoutTrailingCrLf = StringUtils.replace(line, "\r\n", 
"");
-            return new 
String(Base64.getDecoder().decode(lineWithoutTrailingCrLf), 
StandardCharsets.UTF_8);
+    private Response handleLoginFraming(SMTPSession session, Optional<String> 
initialResponse) {
+        Optional<SaslMechanism> plain = findAvailableMechanism(session, 
SaslMechanismNames.PLAIN);
+        if (plain.isEmpty()) {
+            return doUnknownAuth(AUTH_TYPE_LOGIN);
         }
-        return null;
+
+        return initialResponse
+            .map(user -> promptLoginPasswordThenDelegateToPlain(session, 
plain.get(), user))
+            .orElseGet(() -> {
+                session.pushLineHandler(new AbstractSMTPLineHandler() {
+                    @Override
+                    protected Response onCommand(SMTPSession session, String 
line) {
+                        session.popLineHandler();
+                        return promptLoginPasswordThenDelegateToPlain(session, 
plain.get(), line);
+                    }
+                });
+                return AUTH_READY_USERNAME_LOGIN;
+            });
     }
 
-    /**
-     * Carries out the Login AUTH SASL exchange.
-     *
-     * @param session SMTP session object
-     * @param user the user passed in with the AUTH command
-     */
-    private Response doLoginAuthPass(SMTPSession session, String user) {
-        session.popLineHandler();
+    private Response promptLoginPasswordThenDelegateToPlain(SMTPSession 
session, SaslMechanism plain, String encodedUsername) {
         session.pushLineHandler(new AbstractSMTPLineHandler() {
             @Override
-            protected Response onCommand(SMTPSession session, String l) {
-                return doLoginAuthPassCheck(session, asUsername(user), l);
+            protected Response onCommand(SMTPSession session, String 
encodedPassword) {
+                session.popLineHandler();
+                return delegateLoginCredentialsToPlain(session, plain, 
decodeLoginUsername(encodedUsername), encodedPassword);
             }
         });
         return AUTH_READY_PASSWORD_LOGIN;
     }
 
-    private Optional<Username> asUsername(String user) {
-        try {
-            return Optional.of(Username.of(decodeBase64(user)));
-        } catch (Exception e) {
-            LOGGER.info("Failed parsing base64 username {}", user, e);
-            return Optional.empty();
+    private Response delegateLoginCredentialsToPlain(SMTPSession session, 
SaslMechanism plain, Optional<Username> username, String encodedPassword) {
+        Optional<String> password = decodeLoginPassword(username, 
encodedPassword);
+        if (username.isEmpty() || password.isEmpty()) {
+            return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could 
not decode parameters for AUTH " + AUTH_TYPE_LOGIN);
         }
+        SaslInitialRequest request = new 
SaslInitialRequest(SaslMechanismNames.PLAIN,
+            Optional.of(toPlainInitialResponse(username.get(), 
password.get())));
+        SaslExchange exchange = startExchange(session, plain, request);
+        return handleFirstSaslStep(session, AUTH_TYPE_LOGIN, exchange);
     }
 
-    private Response doLoginAuthPassCheck(SMTPSession session, 
Optional<Username> username, String pass) {
-        session.popLineHandler();
-        // Authenticate user
-        return doAuthTest(session, username, sanitizePassword(username, pass), 
"LOGIN");
+    private byte[] toPlainInitialResponse(Username username, String password) {
+        return ("\0" + username.asString() + "\0" + 
password).getBytes(StandardCharsets.UTF_8);
     }
 
-    private Optional<String> sanitizePassword(Optional<Username> username, 
String pass) {
+    private Optional<Username> decodeLoginUsername(String response) {
         try {
-            return Optional.of(decodeBase64(pass));
-        } catch (Exception e) {
-            LOGGER.info("Failed parsing base64 password for user {}", 
username, e);
-            // Ignored - this parse error will be
-            // addressed in the if clause below
+            return Optional.of(Username.of(decodeSaslLoginResponse(response)));
+        } catch (IllegalArgumentException e) {
+            LOGGER.info("Could not decode LOGIN username", e);
             return Optional.empty();
         }
     }
 
-    protected Response doDelegation(SMTPSession session, Username username) {
-        List<AuthHook> hooks = Optional.ofNullable(getHooks())
-            .orElse(List.of());
-
-        for (AuthHook rawHook : hooks) {
-            rawHook.doDelegation(session, username);
-            Response res = executeHook(session, rawHook, hook -> 
rawHook.doDelegation(session, username));
-
-            if (res != null) {
-                if (SMTPRetCode.AUTH_FAILED.equals(res.getRetCode())) {
-                    LOGGER.warn("{} was not authorized to connect as {}", 
session.getUsername(), username);
-                } else if (SMTPRetCode.AUTH_OK.equals(res.getRetCode())) {
-                    LOGGER.info("{} was authorized to connect as {}", 
session.getUsername(), username);
-                }
-                return res;
-            }
+    private Optional<String> decodeLoginPassword(Optional<Username> username, 
String response) {
+        try {
+            return Optional.of(decodeSaslLoginResponse(response));
+        } catch (IllegalArgumentException e) {
+            LOGGER.info("Could not decode LOGIN password for user {}", 
username, e);
+            return Optional.empty();
         }
+    }
 
-        LOGGER.info("DELEGATE failed from {}@{}", username, 
session.getRemoteAddress().getAddress().getHostAddress());
-        return AUTH_FAILED;
+    private String decodeSaslLoginResponse(String response) {
+        return new String(Base64.getDecoder().decode(response.replace("\r\n", 
"")), StandardCharsets.UTF_8);
     }
 
-    protected Response doAuthTest(SMTPSession session, Optional<Username> 
username, Optional<String> pass, String authType) {
-        if (username.isEmpty() || pass.isEmpty()) {
-            return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS,"Could 
not decode parameters for AUTH " + authType);
-        }
+    private Optional<SaslMechanism> findAvailableMechanism(SMTPSession 
session, String authType) {
+        return effectiveSaslMechanisms
+            .stream()
+            .filter(mechanism -> mechanism.name().equalsIgnoreCase(authType))
+            .filter(mechanism -> 
mechanism.isAvailableOnTransport(session.isTLSStarted()))
+            .findFirst();
+    }
 
-        List<AuthHook> hooks = getHooks();
-
-        if (hooks != null) {
-            for (AuthHook rawHook : hooks) {
-                Response res = executeHook(session, rawHook, hook -> 
hook.doAuth(session, username.get(), pass.get()));
-
-                if (res != null) {
-                    if (SMTPRetCode.AUTH_FAILED.equals(res.getRetCode())) {
-                        AUTHENTICATION_DEDICATED_LOGGER.info("AUTH method {} 
failed", authType);
-                    } else if (SMTPRetCode.AUTH_OK.equals(res.getRetCode())) {
-                        // TODO: Make this string a more useful debug message
-                        AUTHENTICATION_DEDICATED_LOGGER.debug("AUTH method {} 
succeeded", authType);
-
-                        AuditTrail.entry()
-                            .username(username.get()::asString)
-                            .remoteIP(() -> 
Optional.ofNullable(session.getRemoteAddress()))
-                            .sessionId(session::getSessionID)
-                            .protocol("SMTP")
-                            .action("AUTH")
-                            .parameters(() -> ImmutableMap.of("authType", 
authType))
-                            .log("SMTP Authentication succeeded.");
-                    }
-                    return res;
-                }
-            }
+    private SaslExchange startExchange(SMTPSession session, SaslMechanism 
mechanism, SaslInitialRequest request) {
+        if (mechanism instanceof AuthHookSaslMechanism authHookSaslMechanism) {
+            return authHookSaslMechanism.start(request, session);
         }
-
-        AUTHENTICATION_DEDICATED_LOGGER.info("AUTH method {} failed from 
{}@{}", authType, username, 
session.getRemoteAddress().getAddress().getHostAddress());
-
-        AuditTrail.entry()
-            .username(username.get()::asString)
-            .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress()))
-            .protocol("SMTP")
-            .action("AUTH")
-            .parameters(() -> ImmutableMap.of("authType", authType))
-            .log("SMTP Authentication failed.");
-
-        return AUTH_FAILED;
+        return mechanism.start(request, saslAuthenticator());
     }
 
-    private Response executeHook(SMTPSession session, AuthHook rawHook, 
Function<AuthHook, HookResult> tc) {
-        LOGGER.debug("executing  hook {}", rawHook);
-
-        long start = System.currentTimeMillis();
-        HookResult hRes = tc.apply(rawHook);
-        long executionTime = System.currentTimeMillis() - start;
-
-        HookResult finalHookResult = Optional.ofNullable(rHooks)
-                .orElse(ImmutableList.of()).stream()
-                .peek(rHook -> LOGGER.debug("executing  hook {}", rHook))
-                .reduce(hRes, (a, b) -> b.onHookResult(session, a, 
executionTime, rawHook), (a, b) -> {
-                    throw new UnsupportedOperationException();
-                });
-
-        return calcDefaultSMTPResponse(finalHookResult);
+    private SaslAuthenticator saslAuthenticator() {
+        return saslAuthenticator
+            .orElseThrow(() -> new IllegalStateException("SASL authenticator 
is not configured"));
     }
 
-    /**
-     * Calculate the SMTPResponse for the given result
-     *
-     * @param result the HookResult which should converted to SMTPResponse
-     * @return the calculated SMTPResponse for the given HookReslut
-     */
-    protected Response calcDefaultSMTPResponse(HookResult result) {
-        if (result != null) {
-            HookReturnCode returnCode = result.getResult();
-
-            String smtpReturnCode = 
Optional.ofNullable(result.getSmtpRetCode())
-                .or(() -> retrieveDefaultSmtpReturnCode(returnCode))
-                .orElse(null);
-
-            String smtpDescription = 
Optional.ofNullable(result.getSmtpDescription())
-                .or(() -> retrieveDefaultSmtpDescription(returnCode))
-                .orElse(null);
-
-            if 
(HookReturnCode.Action.ACTIVE_ACTIONS.contains(returnCode.getAction())) {
-                SMTPResponse response =  new SMTPResponse(smtpReturnCode, 
smtpDescription);
-
-                if (returnCode.isDisconnected()) {
-                    response.setEndSession(true);
-                }
-                return response;
-            } else if (returnCode.isDisconnected()) {
-                return Response.DISCONNECT;
-            }
-        }
-        return null;
-
+    private void updateEffectiveSaslMechanisms() {
+        this.effectiveSaslMechanisms = saslMechanisms.stream()
+            .map(this::adaptPlainMechanismForLegacyAuthHooks)
+            .collect(ImmutableList.toImmutableList());
     }
 
-    private Optional<String> retrieveDefaultSmtpDescription(HookReturnCode 
returnCode) {
-        switch (returnCode.getAction()) {
-            case DENY:
-                return Optional.of("Authentication Failed");
-            case DENYSOFT:
-                return Optional.of("Temporary problem. Please try again 
later");
-            case OK:
-                return Optional.of("Authentication Succesfull");
-            case DECLINED:
-            case NONE:
-                break;
-        }
-        return Optional.empty();
-    }
-
-    private Optional<String> retrieveDefaultSmtpReturnCode(HookReturnCode 
returnCode) {
-        switch (returnCode.getAction()) {
-            case DENY:
-                return Optional.of(SMTPRetCode.AUTH_FAILED);
-            case DENYSOFT:
-                return Optional.of(SMTPRetCode.LOCAL_ERROR);
-            case OK:
-                return Optional.of(SMTPRetCode.AUTH_OK);
-            case DECLINED:
-            case NONE:
-                break;
+    private SaslMechanism adaptPlainMechanismForLegacyAuthHooks(SaslMechanism 
mechanism) {
+        if (!authHooks.isEmpty() && 
mechanism.name().equalsIgnoreCase(SaslMechanismNames.PLAIN)) {
+            // Legacy AuthHooks own PLAIN authentication: replace, rather than 
append to, the configured mechanism
+            // so a declined hook remains a terminal authentication failure as 
it was before SASL modularization.
+            return new AuthHookSaslMechanism(mechanism, authHooks, 
hookResultHooks);
         }
-        return Optional.empty();
+        return mechanism;
     }
 
     /**
@@ -535,15 +454,7 @@ public class AuthCmdHandler
     @Override
     public List<String> getImplementedEsmtpFeatures(SMTPSession session) {
         if (session.isAuthAnnounced()) {
-            ImmutableList.Builder<String> authTypesBuilder = 
ImmutableList.builder();
-            if (session.getConfiguration().isPlainAuthEnabled()) {
-                authTypesBuilder.add(AUTH_TYPE_LOGIN, AUTH_TYPE_PLAIN);
-            }
-            if (session.getConfiguration().saslConfiguration().isPresent()) {
-                authTypesBuilder.add(AUTH_TYPE_OAUTHBEARER);
-                authTypesBuilder.add(AUTH_TYPE_XOAUTH2);
-            }
-            ImmutableList<String> authTypes = authTypesBuilder.build();
+            ImmutableList<String> authTypes = saslAuthTypes(session);
             if (authTypes.isEmpty()) {
                 return Collections.emptyList();
             }
@@ -553,37 +464,36 @@ public class AuthCmdHandler
         return Collections.emptyList();
     }
 
+    private ImmutableList<String> saslAuthTypes(SMTPSession session) {
+        return effectiveSaslMechanisms
+            .stream()
+            .filter(mechanism -> 
mechanism.isAvailableOnTransport(session.isTLSStarted()))
+            .flatMap(mechanism -> {
+                if 
(mechanism.name().equalsIgnoreCase(SaslMechanismNames.PLAIN)) {
+                    return Stream.of(AUTH_TYPE_LOGIN, AUTH_TYPE_PLAIN);
+                }
+                return Stream.of(mechanism.name());
+            })
+            .distinct()
+            .collect(ImmutableList.toImmutableList());
+    }
+
     @Override
     public List<Class<?>> getMarkerInterfaces() {
-        List<Class<?>> classes = new ArrayList<>(2);
-        classes.add(AuthHook.class);
-        classes.add(HookResultHook.class);
-        return classes;
+        return ImmutableList.of(AuthHook.class, HookResultHook.class, 
SaslAuthResultHook.class);
     }
 
-
     @Override
     @SuppressWarnings("unchecked")
     public void wireExtensions(Class<?> interfaceName, List<?> extension) 
throws WiringException {
         if (AuthHook.class.equals(interfaceName)) {
-            this.hooks = (List<AuthHook>) extension;
-            // If no AuthHook is configured then we revert to the default 
LocalUsersRespository check
-            if (hooks == null || hooks.isEmpty()) {
-                throw new WiringException("AuthCmdHandler used without 
AuthHooks");
-            }
+            this.authHooks = ImmutableList.copyOf((List<AuthHook>) extension);
         } else if (HookResultHook.class.equals(interfaceName)) {
-            this.rHooks = (List<HookResultHook>) extension;
+            this.hookResultHooks = ImmutableList.copyOf((List<HookResultHook>) 
extension);
+        } else if (SaslAuthResultHook.class.equals(interfaceName)) {
+            this.saslAuthResultHooks = 
ImmutableList.copyOf((List<SaslAuthResultHook>) extension);
         }
-    }
-
-
-    /**
-     * Return a list which holds all hooks for the cmdHandler
-     *
-     * @return list containing all hooks for the cmd handler
-     */
-    protected List<AuthHook> getHooks() {
-        return hooks;
+        updateEffectiveSaslMechanisms();
     }
 
     @Override
diff --git 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java
 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java
deleted file mode 100644
index edf407331d..0000000000
--- 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/****************************************************************
- * Licensed to the Apache Software Foundation (ASF) under one   *
- * or more contributor license agreements.  See the NOTICE file *
- * distributed with this work for additional information        *
- * regarding copyright ownership.  The ASF licenses this file   *
- * to you 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.apache.james.protocols.smtp.core.esmtp;
-
-import java.util.Optional;
-
-import org.apache.james.core.Username;
-import org.assertj.core.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-class AuthCmdHandlerTest {
-
-    @Test
-    void shouldReturnEmptyWhenEmptyInput() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues("")).isEmpty();
-    }
-
-    @Test
-    void shouldReturnEmptyWhenBlankInput() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues(" 
\t\n")).isEmpty();
-    }
-
-    @Test
-    void shouldReturnEmptyWhenTwoBlankParts() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues(" 
\0\t\n")).isEmpty();
-    }
-
-    @Test
-    void shouldReturnEmptyWhenThreeBlankParts() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues(" \0\0 
")).isEmpty();
-    }
-
-    @Test
-    void shouldReturnUsernameWhenSinglePart() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues("bob"))
-                .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), 
Optional.empty()));
-    }
-
-    @Test
-    void shouldReturnUsernameAndPassWhenTwoParts() {
-        Assertions.assertThat(AuthCmdHandler.parseAuthValues("bob\0pass"))
-                .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), 
Optional.of("pass")));
-    }
-
-    @Test
-    void shouldReturnUsernameAndPassWhenThreeParts() {
-        
Assertions.assertThat(AuthCmdHandler.parseAuthValues("something\0bob\0pass"))
-                .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), 
Optional.of("pass")));
-    }
-
-
-}
\ No newline at end of file
diff --git 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java
 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java
index bff98d4ce0..8bc8abe5f8 100644
--- 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java
+++ 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java
@@ -69,11 +69,6 @@ public class BaseFakeSMTPSession implements SMTPSession {
         throw new UnsupportedOperationException("Unimplemented Stub Method");
     }
 
-    @Override
-    public boolean supportsOAuth() {
-        return false;
-    }
-
     @Override
     public String getSessionID() {
         throw new UnsupportedOperationException("Unimplemented Stub Method");
diff --git 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java
 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java
index 7049978ed7..24464bc596 100644
--- 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java
+++ 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java
@@ -23,6 +23,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import java.net.InetSocketAddress;
 
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
 import org.apache.commons.net.smtp.SMTPClient;
 import org.apache.james.server.core.configuration.FileConfigurationProvider;
 import org.assertj.core.api.SoftAssertions;
@@ -45,9 +47,7 @@ class AuthAnnounceTest {
 
     @Test
     void authAnnounceAlwaysShouldAnnounceAuth() throws Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            
ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceAlways.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-authAnnounceAlways.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -63,9 +63,7 @@ class AuthAnnounceTest {
 
     @Test
     void authAnnounceSometimeShouldNotAnnounceAuthWhenMatching() throws 
Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            
ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceSometimeMatching.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-authAnnounceSometimeMatching.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -81,9 +79,7 @@ class AuthAnnounceTest {
 
     @Test
     void plainAuthShouldNotBeAnnouncedWhenDisabled() throws Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            ClassLoader.getSystemResourceAsStream("smtpserver-no-plain.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-no-plain.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -99,9 +95,7 @@ class AuthAnnounceTest {
 
     @Test
     void plainAuthShouldFailWhenDisabled() throws Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            ClassLoader.getSystemResourceAsStream("smtpserver-no-plain.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-no-plain.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -114,9 +108,7 @@ class AuthAnnounceTest {
 
     @Test
     void authAnnounceSometimeShouldAnnounceAuthWhenNotMatching() throws 
Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            
ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceSometimeNotMatching.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-authAnnounceSometimeNotMatching.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -132,9 +124,7 @@ class AuthAnnounceTest {
 
     @Test
     void authAnnounceNeverShouldNotAnnounceAuth() throws Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            
ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceNever.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-authAnnounceNever.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -150,9 +140,7 @@ class AuthAnnounceTest {
 
     @Test
     void authShouldNotBeAnnouncedOnPlainChannelsWhenRequireSSL() throws 
Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            
ClassLoader.getSystemResourceAsStream("smtpserver-requireSSL.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-requireSSL.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -168,9 +156,7 @@ class AuthAnnounceTest {
 
     @Test
     void shouldStartWithPreviousConfiguration() throws Exception {
-        testSystem.smtpServer.configure(FileConfigurationProvider.getConfig(
-            ClassLoader.getSystemResourceAsStream("smtpserver-noauth.xml")));
-        testSystem.smtpServer.init();
+        configureAndInit("smtpserver-noauth.xml");
 
         SMTPClient smtpProtocol = new SMTPClient();
         InetSocketAddress bindedAddress = testSystem.getBindedAddress();
@@ -184,4 +170,12 @@ class AuthAnnounceTest {
                 .doesNotContain("250-AUTH LOGIN PLAIN");
         });
     }
+
+    private void configureAndInit(String configurationName) throws Exception {
+        HierarchicalConfiguration<ImmutableNode> configuration = 
FileConfigurationProvider.getConfig(
+            ClassLoader.getSystemResourceAsStream(configurationName));
+        testSystem.configureSaslMechanisms(configuration);
+        testSystem.smtpServer.configure(configuration);
+        testSystem.smtpServer.init();
+    }
 }
diff --git 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
index 45bac66783..8ad3db7278 100644
--- 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
+++ 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
@@ -27,6 +27,7 @@ import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
+import java.util.Optional;
 
 import org.apache.commons.configuration2.HierarchicalConfiguration;
 import org.apache.commons.configuration2.tree.ImmutableNode;
@@ -37,9 +38,19 @@ import org.apache.james.core.Username;
 import org.apache.james.jwt.OidcTokenFixture;
 import org.apache.james.mailbox.Authorizator;
 import org.apache.james.protocols.api.OIDCSASLHelper;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslStep;
 import org.apache.james.protocols.api.utils.BogusSslContextFactory;
 import org.apache.james.protocols.api.utils.BogusTrustManagerFactory;
 import org.apache.james.protocols.lib.mock.ConfigLoader;
+import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory;
+import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
 import org.apache.james.util.ClassLoaderUtils;
 import org.assertj.core.api.SoftAssertions;
 import org.junit.jupiter.api.AfterEach;
@@ -49,6 +60,8 @@ import org.mockserver.integration.ClientAndServer;
 import org.mockserver.model.HttpRequest;
 import org.mockserver.model.HttpResponse;
 
+import com.google.common.collect.ImmutableList;
+
 class SMTPSaslTest {
     public static final String LOCAL_DOMAIN = "domain.org";
     public static final Username USER = Username.of("[email protected]");
@@ -63,6 +76,8 @@ class SMTPSaslTest {
         
String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}",
 SCOPE, OIDC_URL).getBytes(UTF_8));
     public static final String VALID_OAUTHBEARER_TOKEN = 
OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), 
OidcTokenFixture.VALID_TOKEN);
     public static final String INVALID_OAUTHBEARER_TOKEN = 
OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), 
OidcTokenFixture.INVALID_TOKEN);
+    private static final SaslMechanism ADVERTISED_OAUTHBEARER = new 
AdvertisedOnlySaslMechanism("OAUTHBEARER");
+    private static final SaslMechanism ADVERTISED_XOAUTH2 = new 
AdvertisedOnlySaslMechanism("XOAUTH2");
 
 
     private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem();
@@ -83,17 +98,29 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
 
-        Authorizator authorizator = (userId, otherUserId) -> {
+        setUpServerWithOidcMechanisms(config);
+    }
+
+    private void 
setUpServerWithOidcMechanisms(HierarchicalConfiguration<ImmutableNode> config) 
throws Exception {
+        testSystem.setUpWithSaslMechanisms(config, authorizator(), 
ImmutableList.of(
+            new OauthBearerSaslMechanismFactory().create(config),
+            new XOauth2SaslMechanismFactory().create(config)));
+        addUsers();
+    }
+
+    private void addUsers() throws Exception {
+        testSystem.domainList.addDomain(Domain.of(LOCAL_DOMAIN));
+        testSystem.usersRepository.addUser(USER, PASSWORD);
+        testSystem.usersRepository.addUser(USER2, PASSWORD);
+    }
+
+    private Authorizator authorizator() {
+        return (userId, otherUserId) -> {
             if (userId.equals(USER) && otherUserId.equals(USER2)) {
                 return Authorizator.AuthorizationState.ALLOWED;
             }
             return Authorizator.AuthorizationState.FORBIDDEN;
         };
-
-        testSystem.setUp(config, authorizator);
-        testSystem.domainList.addDomain(Domain.of(LOCAL_DOMAIN));
-        testSystem.usersRepository.addUser(USER, PASSWORD);
-        testSystem.usersRepository.addUser(USER2, PASSWORD);
     }
 
     private SMTPSClient initSMTPSClient() throws IOException {
@@ -117,7 +144,7 @@ class SMTPSaslTest {
 
         client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN);
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
 
         client.sendCommand("NOOP");
         assertThat(client.getReplyString()).contains("250 2.0.0 OK");
@@ -131,7 +158,7 @@ class SMTPSaslTest {
         assertThat(client.getReplyString()).contains("334");
         client.sendCommand(VALID_OAUTHBEARER_TOKEN);
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
 
         client.sendCommand("NOOP");
         assertThat(client.getReplyString()).contains("250 2.0.0 OK");
@@ -145,7 +172,7 @@ class SMTPSaslTest {
         assertThat(client.getReplyString()).contains("334");
         
client.sendCommand(OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(),
 OidcTokenFixture.VALID_TOKEN));
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
 
         client.sendCommand("NOOP");
         assertThat(client.getReplyString()).contains("250 2.0.0 OK");
@@ -157,7 +184,7 @@ class SMTPSaslTest {
 
         client.sendCommand("AUTH XOAUTH2 " + 
OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), 
OidcTokenFixture.VALID_TOKEN));
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
     }
 
     @Test
@@ -234,8 +261,8 @@ class SMTPSaslTest {
     void oauthShouldFailWhenConfigIsNotProvided() throws Exception {
         testSystem.smtpServer.destroy();
         HierarchicalConfiguration<ImmutableNode> config = 
ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        testSystem.setUp(config, authorizator());
+        addUsers();
 
         SMTPSClient client = initSMTPSClient();
 
@@ -273,8 +300,8 @@ class SMTPSaslTest {
     void ehloShouldNotAdvertiseOAUTHBEARERWhenConfigIsNotProvided() throws 
Exception {
         testSystem.smtpServer.destroy();
         HierarchicalConfiguration<ImmutableNode> config = 
ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        testSystem.setUp(config, authorizator());
+        addUsers();
 
         SMTPSClient client = initSMTPSClient();
         client.sendCommand("EHLO localhost");
@@ -290,8 +317,8 @@ class SMTPSaslTest {
     void ehloShouldNotAdvertiseXOAUTH2WhenConfigIsNotProvided() throws 
Exception {
         testSystem.smtpServer.destroy();
         HierarchicalConfiguration<ImmutableNode> config = 
ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        testSystem.setUp(config, authorizator());
+        addUsers();
 
         SMTPSClient client = initSMTPSClient();
         client.sendCommand("EHLO localhost");
@@ -318,8 +345,7 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
         config.addProperty("auth.oidc.introspection.url", 
String.format("http://127.0.0.1:%s%s";, authServer.getLocalPort(), 
INTROSPECT_TOKEN_URI_PATH));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        setUpServerWithOidcMechanisms(config);
 
         SMTPSClient client = initSMTPSClient();
 
@@ -347,14 +373,13 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
         config.addProperty("auth.oidc.introspection.url", 
String.format("http://127.0.0.1:%s%s";, authServer.getLocalPort(), 
INTROSPECT_TOKEN_URI_PATH));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        setUpServerWithOidcMechanisms(config);
 
         SMTPSClient client = initSMTPSClient();
 
         client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN);
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
     }
 
     @Test
@@ -372,8 +397,7 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
         config.addProperty("auth.oidc.introspection.url", 
String.format("http://127.0.0.1:%s%s";, authServer.getLocalPort(), invalidURI));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        setUpServerWithOidcMechanisms(config);
 
         SMTPSClient client = initSMTPSClient();
 
@@ -397,14 +421,13 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
         config.addProperty("auth.oidc.userinfo.url", 
String.format("http://127.0.0.1:%s%s";, authServer.getLocalPort(), 
USERINFO_URI_PATH));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        setUpServerWithOidcMechanisms(config);
 
         SMTPSClient client = initSMTPSClient();
 
         client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN);
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
     }
 
     @Test
@@ -421,8 +444,7 @@ class SMTPSaslTest {
         config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
         config.addProperty("auth.oidc.scope", SCOPE);
         config.addProperty("auth.oidc.userinfo.url", 
String.format("http://127.0.0.1:%s%s";, authServer.getLocalPort(), 
USERINFO_URI_PATH));
-        testSystem.smtpServer.configure(config);
-        testSystem.smtpServer.init();
+        setUpServerWithOidcMechanisms(config);
 
         SMTPSClient client = initSMTPSClient();
 
@@ -437,9 +459,6 @@ class SMTPSaslTest {
         String tokenWithImpersonation = 
OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse("[email protected]",
 OidcTokenFixture.VALID_TOKEN);
         client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation);
 
-        assertThat(client.getReplyString()).contains("334 ");
-
-        client.sendCommand("AQ==");
         assertThat(client.getReplyString()).contains("535 Authentication 
Failed");
     }
 
@@ -449,7 +468,7 @@ class SMTPSaslTest {
         String tokenWithImpersonation = 
OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(),
 OidcTokenFixture.VALID_TOKEN);
         client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation);
 
-        assertThat(client.getReplyString()).contains("235 Authentication 
successful.");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
     }
 
     @Test
@@ -469,4 +488,208 @@ class SMTPSaslTest {
             .as("mail received by mail server")
             .isNotNull();
     }
+
+    @Test
+    void ehloShouldAdvertisePlainAndLoginWhenPlainMechanismIsConfigured() 
throws Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("EHLO localhost");
+
+        assertThat(client.getReplyString()).contains("250-AUTH LOGIN PLAIN");
+    }
+
+    @Test
+    void 
ehloShouldPreserveConfiguredMechanismOrderAndDeduplicateAdvertisement() throws 
Exception {
+        resetWithMechanisms(ImmutableList.of(
+            ADVERTISED_OAUTHBEARER,
+            new PlainSaslMechanism(true, false),
+            ADVERTISED_XOAUTH2,
+            new PlainSaslMechanism(true, false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("EHLO localhost");
+
+        assertThat(client.getReplyString())
+            .contains("250-AUTH OAUTHBEARER LOGIN PLAIN XOAUTH2")
+            .doesNotContain("LOGIN PLAIN XOAUTH2 LOGIN PLAIN");
+    }
+
+    @Test
+    void ehloShouldAdvertiseOnlyConfiguredSaslMechanisms() throws Exception {
+        resetWithMechanisms(ImmutableList.of(ADVERTISED_XOAUTH2));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("EHLO localhost");
+
+        assertThat(client.getReplyString())
+            .contains("250-AUTH XOAUTH2")
+            .doesNotContain("LOGIN")
+            .doesNotContain("PLAIN")
+            .doesNotContain("OAUTHBEARER");
+    }
+
+    @Test
+    void authPlainShouldBeRejectedWhenPlainMechanismIsNotConfigured() throws 
Exception {
+        resetWithMechanisms(ImmutableList.of(ADVERTISED_XOAUTH2));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH PLAIN " + 
plainInitialResponse(SMTPServerTestSystem.BOB.asString(), 
SMTPServerTestSystem.PASSWORD));
+
+        assertThat(client.getReplyString()).contains("504 Unrecognized 
Authentication Type");
+    }
+
+    @Test
+    void authPlainWithInitialResponseShouldSucceedWhenCredentialsAreValid() 
throws Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH PLAIN " + 
plainInitialResponse(SMTPServerTestSystem.BOB.asString(), 
SMTPServerTestSystem.PASSWORD));
+
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
+    }
+
+    @Test
+    void authPlainContinuationShouldSucceedWhenCredentialsAreValid() throws 
Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH PLAIN");
+        assertThat(client.getReplyString()).contains("334 ");
+        
client.sendCommand(plainInitialResponse(SMTPServerTestSystem.BOB.asString(), 
SMTPServerTestSystem.PASSWORD));
+
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
+    }
+
+    @Test
+    void authLoginShouldSucceedWhenCredentialsAreValid() throws Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH LOGIN");
+        assertThat(client.getReplyString()).contains("334 VXNlcm5hbWU6");
+        client.sendCommand(base64(SMTPServerTestSystem.BOB.asString()));
+        assertThat(client.getReplyString()).contains("334 UGFzc3dvcmQ6");
+        client.sendCommand(base64(SMTPServerTestSystem.PASSWORD));
+
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
+    }
+
+    @Test
+    void authPlainShouldFailWhenCredentialsAreInvalid() throws Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
false)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH PLAIN " + 
plainInitialResponse(SMTPServerTestSystem.BOB.asString(), "bad-password"));
+
+        assertThat(client.getReplyString()).contains("535 Authentication 
Failed");
+    }
+
+    @Test
+    void authShouldSendFinalServerDataBeforeSuccess() throws Exception {
+        resetWithMechanisms(ImmutableList.of(new ServerDataSaslMechanism()));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("AUTH SERVER-DATA");
+        assertThat(client.getReplyString()).contains("334 " + 
base64("server-data"));
+
+        client.sendCommand("");
+        assertThat(client.getReplyString()).contains("235 Authentication 
Successful");
+    }
+
+    @Test
+    void 
mechanismUnavailableOnClearTransportShouldNotBeAdvertisedAndShouldBeRejected() 
throws Exception {
+        resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, 
true)));
+        SMTPClient client = connectedClient();
+
+        client.sendCommand("EHLO localhost");
+        assertThat(client.getReplyString()).doesNotContain("250-AUTH LOGIN 
PLAIN");
+
+        client.sendCommand("AUTH PLAIN " + 
plainInitialResponse(SMTPServerTestSystem.BOB.asString(), 
SMTPServerTestSystem.PASSWORD));
+        assertThat(client.getReplyString()).contains("504 Unrecognized 
Authentication Type");
+    }
+
+    private void resetWithMechanisms(ImmutableList<SaslMechanism> 
saslMechanisms) throws Exception {
+        testSystem.smtpServer.destroy();
+        HierarchicalConfiguration<ImmutableNode> configuration = 
ConfigLoader.getConfig(
+            
ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-authAnnounceAlways.xml"));
+        testSystem.setUpWithSaslMechanisms(configuration, authorizator(), 
saslMechanisms);
+    }
+
+    private SMTPClient connectedClient() throws IOException {
+        SMTPClient client = new SMTPClient();
+        InetSocketAddress bindedAddress = testSystem.getBindedAddress();
+        client.connect(bindedAddress.getAddress().getHostAddress(), 
bindedAddress.getPort());
+        return client;
+    }
+
+    private String plainInitialResponse(String username, String password) {
+        return base64("\0" + username + "\0" + password);
+    }
+
+    private String base64(String value) {
+        return Base64.getEncoder().encodeToString(value.getBytes(UTF_8));
+    }
+
+    private static class AdvertisedOnlySaslMechanism implements SaslMechanism {
+        private final String name;
+
+        private AdvertisedOnlySaslMechanism(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public String name() {
+            return name;
+        }
+
+        @Override
+        public SaslExchange start(SaslInitialRequest request, 
SaslAuthenticator authenticator) {
+            return new SaslExchange() {
+                @Override
+                public SaslStep firstStep() {
+                    return new 
SaslStep.Failure(SaslFailure.authenticationFailed(
+                        Optional.empty(), Optional.empty(), "Test-only 
mechanism"));
+                }
+
+                @Override
+                public SaslStep onResponse(byte[] clientResponse) {
+                    return new 
SaslStep.Failure(SaslFailure.authenticationFailed(
+                        Optional.empty(), Optional.empty(), "Test-only 
mechanism"));
+                }
+
+                @Override
+                public void close() {
+                }
+            };
+        }
+    }
+
+    private static class ServerDataSaslMechanism implements SaslMechanism {
+        @Override
+        public String name() {
+            return "SERVER-DATA";
+        }
+
+        @Override
+        public SaslExchange start(SaslInitialRequest request, 
SaslAuthenticator authenticator) {
+            return new SaslExchange() {
+                @Override
+                public SaslStep firstStep() {
+                    return new SaslStep.Success(
+                        new SaslIdentity(USER, USER),
+                        Optional.of("server-data".getBytes(UTF_8)));
+                }
+
+                @Override
+                public SaslStep onResponse(byte[] clientResponse) {
+                    return firstStep();
+                }
+
+                @Override
+                public void close() {
+                }
+            };
+        }
+    }
 }
diff --git 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java
 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java
index c7b82cb41c..71391cf7db 100644
--- 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java
+++ 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java
@@ -1363,8 +1363,10 @@ public class SMTPServerTest {
 
         smtpProtocol.sendCommand("AUTH PLAIN");
         smtpProtocol.sendCommand("canNotDecode");
+        // RFC 4954 distinguishes malformed AUTH exchange data from invalid 
credentials:
+        // this payload has no valid PLAIN separators, so 501 is more accurate 
than 535.
         assertThat(smtpProtocol.getReplyString())
-            .contains("535 Authentication Failed");
+            .contains("501 Could not decode parameters for AUTH PLAIN");
     }
 
     @Test
diff --git 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java
 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java
index d276912fe2..4b9ee5a181 100644
--- 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java
+++ 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java
@@ -25,6 +25,7 @@ import java.net.InetSocketAddress;
 import java.time.Clock;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Optional;
 
 import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
 import org.apache.commons.configuration2.HierarchicalConfiguration;
@@ -39,6 +40,7 @@ import 
org.apache.james.domainlist.lib.DomainListConfiguration;
 import org.apache.james.domainlist.memory.MemoryDomainList;
 import org.apache.james.filesystem.api.FileSystem;
 import org.apache.james.mailbox.Authorizator;
+import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailrepository.api.MailRepositoryStore;
 import org.apache.james.mailrepository.api.Protocol;
 import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration;
@@ -49,9 +51,15 @@ import 
org.apache.james.mailrepository.memory.SimpleMailRepositoryLoader;
 import org.apache.james.metrics.api.Metric;
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.metrics.tests.RecordingMetricFactory;
+import org.apache.james.protocols.api.sasl.SaslAuthenticationResult;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
 import org.apache.james.protocols.api.utils.ProtocolServerUtils;
 import org.apache.james.protocols.lib.LegacyJavaEncryptionFactory;
 import org.apache.james.protocols.lib.mock.MockProtocolHandlerLoader;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
 import org.apache.james.queue.api.MailQueueFactory;
 import org.apache.james.queue.api.RawMailQueueItemDecoratorFactory;
 import org.apache.james.queue.memory.MemoryMailQueueFactory;
@@ -67,6 +75,7 @@ import org.apache.james.server.core.filesystem.FileSystemImpl;
 import org.apache.james.smtpserver.netty.SMTPServer;
 import org.apache.james.smtpserver.netty.SmtpMetricsImpl;
 import org.apache.james.user.api.UsersRepository;
+import org.apache.james.user.api.UsersRepositoryException;
 import org.apache.james.user.memory.MemoryUsersRepository;
 
 import com.google.common.collect.ImmutableList;
@@ -100,6 +109,23 @@ class SMTPServerTestSystem {
     void setUp(HierarchicalConfiguration<ImmutableNode> configuration, 
Authorizator authorizator) throws Exception {
         preSetUp(authorizator);
 
+        configureSaslMechanisms(configuration);
+        smtpServer.configure(configuration);
+        smtpServer.init();
+    }
+
+    void configureSaslMechanisms(HierarchicalConfiguration<ImmutableNode> 
configuration) {
+        smtpServer.setSaslMechanisms(ImmutableList.of(new PlainSaslMechanism(
+            configuration.getBoolean("auth.plainAuthEnabled", true),
+            configuration.getBoolean("auth.requireSSL", false))));
+    }
+
+    void setUpWithSaslMechanisms(HierarchicalConfiguration<ImmutableNode> 
configuration, Authorizator authorizator,
+                                  ImmutableList<SaslMechanism> saslMechanisms) 
throws Exception {
+        preSetUp(authorizator);
+
+        smtpServer.setSaslMechanisms(saslMechanisms);
+        
smtpServer.setSaslAuthenticator(Optional.of(testSaslAuthenticator(authorizator)));
         smtpServer.configure(configuration);
         smtpServer.init();
     }
@@ -116,7 +142,7 @@ class SMTPServerTestSystem {
         createMailRepositoryStore();
 
         setUpFakeLoader(authorizator);
-        setUpSMTPServer();
+        setUpSMTPServer(authorizator);
     }
 
     void preSetUp() throws Exception {
@@ -141,7 +167,7 @@ class SMTPServerTestSystem {
         return new SMTPServer(smtpMetrics);
     }
 
-    protected void setUpSMTPServer() {
+    protected void setUpSMTPServer(Authorizator authorizator) {
         SmtpMetricsImpl smtpMetrics = mock(SmtpMetricsImpl.class);
         when(smtpMetrics.getCommandsMetric()).thenReturn(mock(Metric.class));
         when(smtpMetrics.getConnectionMetric()).thenReturn(mock(Metric.class));
@@ -150,6 +176,8 @@ class SMTPServerTestSystem {
         smtpServer.setFileSystem(fileSystem);
         smtpServer.setEncryptionFactory(new 
LegacyJavaEncryptionFactory(fileSystem));
         smtpServer.setProtocolHandlerLoader(chain);
+        smtpServer.setSaslMechanisms(ImmutableList.of(new 
PlainSaslMechanism(true, false)));
+        
smtpServer.setSaslAuthenticator(Optional.of(testSaslAuthenticator(authorizator)));
     }
 
     protected void setUpFakeLoader(Authorizator authorizator) {
@@ -178,6 +206,34 @@ class SMTPServerTestSystem {
             .build();
     }
 
+    private SaslAuthenticator testSaslAuthenticator(Authorizator authorizator) 
{
+        return new SaslAuthenticator() {
+            @Override
+            public SaslAuthenticationResult authenticatePassword(Username 
authenticationId, Optional<Username> authorizationId, String password) {
+                try {
+                    return usersRepository.test(authenticationId, password)
+                        .<SaslAuthenticationResult>map(authenticatedUser -> 
authorize(new SaslIdentity(authenticatedUser, 
authorizationId.orElse(authenticatedUser))))
+                        .orElseGet(() -> new 
SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId,
 authorizationId, "Invalid credentials")));
+                } catch (UsersRepositoryException e) {
+                    return new 
SaslAuthenticationResult.Failure(SaslFailure.serverError(Optional.of(authenticationId),
 authorizationId, "Authentication failed", e));
+                }
+            }
+
+            @Override
+            public SaslAuthenticationResult authorize(SaslIdentity identity) {
+                try {
+                    if 
(identity.authenticationId().equals(identity.authorizationId())
+                        || 
authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId())
 == Authorizator.AuthorizationState.ALLOWED) {
+                        return new SaslAuthenticationResult.Success(identity);
+                    }
+                    return new 
SaslAuthenticationResult.Failure(SaslFailure.delegationForbidden(identity.authenticationId(),
 identity.authorizationId(), "Delegation forbidden"));
+                } catch (MailboxException e) {
+                    return new 
SaslAuthenticationResult.Failure(SaslFailure.serverError(Optional.of(identity.authenticationId()),
 Optional.of(identity.authorizationId()), "Authentication failed", e));
+                }
+            }
+        };
+    }
+
     InetSocketAddress getBindedAddress() {
         return new ProtocolServerUtils(smtpServer).retrieveBindedAddress();
     }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to