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


The following commit(s) were added to refs/heads/master by this push:
     new 6ca83f999d JAMES-4210 Reduce boilerplate: Prefer SaslCodec over 
SaslBridges (#3093)
6ca83f999d is described below

commit 6ca83f999d7a104e9e984fa60586cc7088381fc9
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Jul 20 12:02:19 2026 +0200

    JAMES-4210 Reduce boilerplate: Prefer SaslCodec over SaslBridges (#3093)
---
 .../apache/james/protocols/api/sasl/SaslCodec.java |  91 ++++++++
 .../james/protocols/api/sasl/SaslCodecTest.java    | 133 +++++++++++
 .../james/protocols/api/sasl/SaslExchangeTest.java |  98 ++++++++
 .../imap/processor/AuthenticateProcessor.java      |  32 ++-
 .../james/imap/processor/sasl/ImapSaslBridge.java  | 104 ---------
 .../imap/processor/sasl/ImapSaslBridgeTest.java    | 240 --------------------
 .../protocols/smtp/core/esmtp/AuthCmdHandler.java  |  42 ++--
 .../protocols/smtp/core/esmtp/SmtpSaslBridge.java  | 116 ----------
 .../smtp/core/esmtp/SmtpSaslBridgeTest.java        | 247 ---------------------
 9 files changed, 361 insertions(+), 742 deletions(-)

diff --git 
a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCodec.java
 
b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCodec.java
new file mode 100644
index 0000000000..6f1fdf2b3d
--- /dev/null
+++ 
b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCodec.java
@@ -0,0 +1,91 @@
+/****************************************************************
+ * 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.api.sasl;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Wire encoding shared by the line based protocols carrying SASL exchanges 
(IMAP, SMTP).
+ *
+ * Those protocols agree on the encoding rules defined by RFC 4422: payloads 
travel base64
+ * encoded on a single line, "*" cancels the exchange, and an empty line 
acknowledges final
+ * server data. Only the framing of the encoded payload is protocol specific 
and thus stays
+ * in the respective command handlers.
+ */
+public class SaslCodec {
+    private static final String ABORT = "*";
+    private static final String EMPTY_INITIAL_CLIENT_RESPONSE = "=";
+
+    /**
+     * Converts a protocol authentication command into a protocol-neutral SASL 
initial request.
+     */
+    public static SaslInitialRequest initialRequest(String mechanismName, 
Optional<String> initialClientResponse) {
+        return new SaslInitialRequest(mechanismName, 
initialClientResponse.map(SaslCodec::decodeInitialClientResponse));
+    }
+
+    /**
+     * Decodes a client continuation line into the bytes to feed to the SASL 
exchange.
+     */
+    public static byte[] decodeClientResponse(byte[] line) {
+        return decodeBase64(stripTrailingCrlf(line));
+    }
+
+    /**
+     * Encodes a SASL payload (challenge or final server data) for the wire.
+     */
+    public static String encode(Optional<byte[]> payload) {
+        return payload
+            .map(Base64.getEncoder()::encodeToString)
+            .orElse("");
+    }
+
+    /**
+     * Detects SASL client cancellation.
+     */
+    public static boolean isAbort(byte[] line) {
+        return ABORT.equals(stripTrailingCrlf(line));
+    }
+
+    /**
+     * Detects the empty client response used to acknowledge final SASL server 
data.
+     */
+    public static boolean isEmptyClientResponse(byte[] line) {
+        return stripTrailingCrlf(line).isEmpty();
+    }
+
+    private static byte[] decodeBase64(String value) {
+        return Base64.getDecoder().decode(value);
+    }
+
+    private static byte[] decodeInitialClientResponse(String value) {
+        if (value.equals(EMPTY_INITIAL_CLIENT_RESPONSE)) {
+            return new byte[0];
+        }
+        return decodeBase64(value);
+    }
+
+    private static String stripTrailingCrlf(byte[] line) {
+        return StringUtils.stripEnd(new String(line, 
StandardCharsets.US_ASCII), "\r\n");
+    }
+}
diff --git 
a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslCodecTest.java
 
b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslCodecTest.java
new file mode 100644
index 0000000000..c30007c0ee
--- /dev/null
+++ 
b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslCodecTest.java
@@ -0,0 +1,133 @@
+/****************************************************************
+ * 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.api.sasl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+class SaslCodecTest {
+    @Test
+    void initialRequestShouldDecodeInitialClientResponse() {
+        String encodedInitialResponse = 
Base64.getEncoder().encodeToString(bytes("initial"));
+
+        SaslInitialRequest request = SaslCodec.initialRequest("PLAIN", 
Optional.of(encodedInitialResponse));
+
+        assertThat(request.mechanismName()).isEqualTo("PLAIN");
+        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).containsExactly(bytes("initial")));
+    }
+
+    @Test
+    void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() {
+        SaslInitialRequest request = SaslCodec.initialRequest("PLAIN", 
Optional.of("="));
+
+        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).isEmpty());
+    }
+
+    @Test
+    void initialRequestShouldPreserveAbsentInitialClientResponse() {
+        SaslInitialRequest request = SaslCodec.initialRequest("PLAIN", 
Optional.empty());
+
+        assertThat(request.initialResponse()).isEmpty();
+    }
+
+    @Test
+    void initialRequestShouldRejectMalformedInitialClientResponse() {
+        assertThatThrownBy(() -> SaslCodec.initialRequest("PLAIN", 
Optional.of("not-base64!")))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void decodeClientResponseShouldDecodeLineTerminatedByCrlf() {
+        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
+
+        
assertThat(SaslCodec.decodeClientResponse(line)).containsExactly(bytes("response"));
+    }
+
+    @Test
+    void decodeClientResponseShouldDecodeLineTerminatedByLfOnly() {
+        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\n").getBytes(StandardCharsets.US_ASCII);
+
+        
assertThat(SaslCodec.decodeClientResponse(line)).containsExactly(bytes("response"));
+    }
+
+    @Test
+    void decodeClientResponseShouldRejectMalformedBase64() {
+        assertThatThrownBy(() -> 
SaslCodec.decodeClientResponse("not-base64!\r\n".getBytes(StandardCharsets.US_ASCII)))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void encodeShouldBase64EncodePayload() {
+        assertThat(SaslCodec.encode(Optional.of(bytes("challenge"))))
+            .isEqualTo(Base64.getEncoder().encodeToString(bytes("challenge")));
+    }
+
+    @Test
+    void encodeShouldReturnEmptyStringWhenPayloadIsAbsent() {
+        assertThat(SaslCodec.encode(Optional.empty())).isEmpty();
+    }
+
+    @Test
+    void encodeShouldEncodeAuthLoginUsernamePrompt() {
+        
assertThat(SaslCodec.encode(Optional.of(bytes("Username:")))).isEqualTo("VXNlcm5hbWU6");
+    }
+
+    @Test
+    void encodeShouldEncodeAuthLoginPasswordPrompt() {
+        
assertThat(SaslCodec.encode(Optional.of(bytes("Password:")))).isEqualTo("UGFzc3dvcmQ6");
+    }
+
+    @Test
+    void isAbortShouldDetectAbortLine() {
+        
assertThat(SaslCodec.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
+    }
+
+    @Test
+    void isAbortShouldDetectAbortLineWithLfOnly() {
+        
assertThat(SaslCodec.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
+    }
+
+    @Test
+    void isAbortShouldRejectRegularClientResponse() {
+        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
+
+        assertThat(SaslCodec.isAbort(line)).isFalse();
+    }
+
+    @Test
+    void isEmptyClientResponseShouldDetectEmptyLine() {
+        
assertThat(SaslCodec.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
+    }
+
+    @Test
+    void isEmptyClientResponseShouldRejectNonEmptyLine() {
+        
assertThat(SaslCodec.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse();
+    }
+
+    private static byte[] bytes(String value) {
+        return value.getBytes(StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslExchangeTest.java
 
b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslExchangeTest.java
new file mode 100644
index 0000000000..ebc2773fe3
--- /dev/null
+++ 
b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslExchangeTest.java
@@ -0,0 +1,98 @@
+/****************************************************************
+ * 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.api.sasl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.junit.jupiter.api.Test;
+
+class SaslExchangeTest {
+    private static final Username USER = Username.of("[email protected]");
+    private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
+
+    private static class RecordingExchange implements SaslExchange {
+        protected final List<String> lifecycleEvents = new ArrayList<>();
+
+        @Override
+        public SaslStep firstStep() {
+            return new SaslStep.Challenge(Optional.empty());
+        }
+
+        @Override
+        public SaslStep onResponse(byte[] clientResponse) {
+            return new SaslStep.Success(IDENTITY, Optional.empty());
+        }
+
+        @Override
+        public void close() {
+            lifecycleEvents.add("close");
+        }
+    }
+
+    private static class OverridingAbortExchange extends RecordingExchange {
+        @Override
+        public void abort() {
+            lifecycleEvents.add("abort");
+            close();
+        }
+    }
+
+    private static class ThrowingAbortExchange extends OverridingAbortExchange 
{
+        @Override
+        public void abort() {
+            super.abort();
+            throw new IllegalStateException("boom");
+        }
+    }
+
+    @Test
+    void abortShouldCloseExchangeByDefault() {
+        RecordingExchange exchange = new RecordingExchange();
+
+        exchange.abort();
+
+        assertThat(exchange.lifecycleEvents).containsExactly("close");
+    }
+
+    @Test
+    void abortShouldUseExchangeSpecificAbortWhenOverridden() {
+        OverridingAbortExchange exchange = new OverridingAbortExchange();
+
+        exchange.abort();
+
+        assertThat(exchange.lifecycleEvents).containsExactly("abort", "close");
+    }
+
+    @Test
+    void abortShouldPropagateExchangeSpecificAbortFailure() {
+        ThrowingAbortExchange exchange = new ThrowingAbortExchange();
+
+        assertThatThrownBy(exchange::abort)
+            .isInstanceOf(IllegalStateException.class);
+
+        assertThat(exchange.lifecycleEvents).containsExactly("abort", "close");
+    }
+}
diff --git 
a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
 
b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
index 8a9d87fbd3..833fa95247 100644
--- 
a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
+++ 
b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
@@ -34,10 +34,10 @@ import org.apache.james.imap.main.PathConverter;
 import org.apache.james.imap.message.request.AuthenticateRequest;
 import org.apache.james.imap.message.request.IRAuthenticateRequest;
 import org.apache.james.imap.message.response.AuthenticateResponse;
-import org.apache.james.imap.processor.sasl.ImapSaslBridge;
 import org.apache.james.mailbox.MailboxManager;
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslCodec;
 import org.apache.james.protocols.api.sasl.SaslExchange;
 import org.apache.james.protocols.api.sasl.SaslInitialRequest;
 import org.apache.james.protocols.api.sasl.SaslMechanism;
@@ -62,7 +62,6 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
     public static final Capability SASL_CAPABILITY = Capability.of("SASL-IR");
     private static final Logger LOGGER = 
LoggerFactory.getLogger(AuthenticateProcessor.class);
 
-    private final ImapSaslBridge saslBridge;
     private final JamesSaslAuthenticator jamesSaslAuthenticator;
     private ImmutableList<SaslMechanism> saslMechanisms;
 
@@ -71,7 +70,6 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
                                  MetricFactory metricFactory, 
PathConverter.Factory pathConverterFactory,
                                  JamesSaslAuthenticator 
jamesSaslAuthenticator) {
         super(AuthenticateRequest.class, mailboxManager, factory, 
metricFactory, pathConverterFactory);
-        this.saslBridge = new ImapSaslBridge();
         this.jamesSaslAuthenticator = jamesSaslAuthenticator;
         this.saslMechanisms = ImmutableList.of();
     }
@@ -97,7 +95,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         }
 
         try {
-            SaslInitialRequest initialRequest = 
saslBridge.initialRequest(request.getAuthType(), 
initialClientResponse(request));
+            SaslInitialRequest initialRequest = 
SaslCodec.initialRequest(request.getAuthType(), initialClientResponse(request));
             SaslAuthenticator authenticator = 
jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers());
             SaslExchange exchange = mechanism.get().start(initialRequest, 
authenticator);
             handleFirstStep(exchange, firstStep(exchange), session, request, 
responder);
@@ -142,7 +140,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         try {
             return exchange.firstStep();
         } catch (RuntimeException e) {
-            saslBridge.close(exchange);
+            exchange.close();
             throw e;
         }
     }
@@ -188,7 +186,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
                                                    ImapSession session, 
AuthenticateRequest request, Responder responder) {
         pushContinuationHandler(exchange, session, request, responder);
         respondActiveContinuation(exchange, session, () ->
-            responder.respond(new 
AuthenticateResponse(saslBridge.continuation(challenge))));
+            responder.respond(new 
AuthenticateResponse(SaslCodec.encode(challenge.payload()))));
     }
 
     private void pushContinuationHandler(SaslExchange exchange, ImapSession 
session, AuthenticateRequest request, Responder responder) {
@@ -197,7 +195,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
                 .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
                 .then());
         } catch (RuntimeException e) {
-            saslBridge.close(exchange);
+            exchange.close();
             throw e;
         }
     }
@@ -216,7 +214,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
 
     private Optional<SaslStep> nextStep(SaslExchange exchange, ImapSession 
session, AuthenticateRequest request, Responder responder, byte[] data) {
         try {
-            return Optional.of(saslBridge.onClientResponse(exchange, data));
+            return 
Optional.of(exchange.onResponse(SaslCodec.decodeClientResponse(data)));
         } catch (IllegalArgumentException e) {
             LOGGER.info("Invalid syntax in AUTHENTICATE client response", e);
             closeActiveContinuation(exchange, session);
@@ -233,7 +231,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
     private void handleContinuationStep(SaslExchange exchange, SaslStep step, 
ImapSession session, AuthenticateRequest request, Responder responder) {
         if (step instanceof SaslStep.Challenge challenge) {
             try {
-                responder.respond(new 
AuthenticateResponse(saslBridge.continuation(challenge)));
+                responder.respond(new 
AuthenticateResponse(SaslCodec.encode(challenge.payload())));
                 responder.flush();
             } catch (RuntimeException e) {
                 closeActiveContinuation(exchange, session);
@@ -263,7 +261,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
 
     private boolean isAbort(SaslExchange exchange, ImapSession session, byte[] 
data) {
         try {
-            return saslBridge.isAbort(data);
+            return SaslCodec.isAbort(data);
         } catch (RuntimeException e) {
             closeActiveContinuation(exchange, session);
             throw e;
@@ -272,7 +270,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
 
     private boolean isEmptyClientResponse(SaslExchange exchange, ImapSession 
session, byte[] data) {
         try {
-            return saslBridge.isEmptyClientResponse(data);
+            return SaslCodec.isEmptyClientResponse(data);
         } catch (RuntimeException e) {
             closeActiveContinuation(exchange, session);
             throw e;
@@ -283,7 +281,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         try {
             session.popLineHandler();
         } finally {
-            saslBridge.close(exchange);
+            exchange.close();
         }
     }
 
@@ -291,7 +289,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         try {
             session.popLineHandler();
         } finally {
-            saslBridge.abort(exchange);
+            exchange.abort();
         }
     }
 
@@ -299,7 +297,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         try {
             session.popLineHandler();
         } catch (RuntimeException e) {
-            saslBridge.close(exchange);
+            exchange.close();
             throw e;
         }
     }
@@ -308,7 +306,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
                                              AuthenticateRequest request, 
Responder responder) {
         pushSuccessDataAcknowledgementHandler(exchange, success, session, 
request, responder);
         respondActiveContinuation(exchange, session, () -> {
-            responder.respond(new 
AuthenticateResponse(saslBridge.successData(success)));
+            responder.respond(new 
AuthenticateResponse(SaslCodec.encode(success.serverData())));
             responder.flush();
         });
     }
@@ -320,7 +318,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
                 .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
                 .then());
         } catch (RuntimeException e) {
-            saslBridge.close(exchange);
+            exchange.close();
             throw e;
         }
     }
@@ -350,7 +348,7 @@ public class AuthenticateProcessor extends 
AbstractAuthProcessor<AuthenticateReq
         try {
             handleSaslStep(step, session, request, responder, 
successLog(request));
         } finally {
-            saslBridge.close(exchange);
+            exchange.close();
         }
     }
 
diff --git 
a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java
 
b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java
deleted file mode 100644
index 7e5a8217fe..0000000000
--- 
a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java
+++ /dev/null
@@ -1,104 +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.imap.processor.sasl;
-
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-import java.util.Optional;
-
-import org.apache.commons.lang3.StringUtils;
-import org.apache.james.protocols.api.sasl.SaslExchange;
-import org.apache.james.protocols.api.sasl.SaslInitialRequest;
-import org.apache.james.protocols.api.sasl.SaslStep;
-
-public class ImapSaslBridge {
-    /**
-     * Converts an IMAP AUTHENTICATE request into a protocol-neutral SASL 
initial request.
-     */
-    public SaslInitialRequest initialRequest(String mechanismName, 
Optional<String> initialClientResponse) {
-        return new SaslInitialRequest(mechanismName, 
initialClientResponse.map(this::decodeInitialClientResponse));
-    }
-
-    /**
-     * Encodes a SASL challenge payload as an IMAP continuation payload.
-     */
-    public String continuation(SaslStep.Challenge challenge) {
-        return challenge.payload()
-            .map(Base64.getEncoder()::encodeToString)
-            .orElse("");
-    }
-
-    /**
-     * Encodes final SASL server data as an IMAP continuation payload.
-     */
-    public String successData(SaslStep.Success success) {
-        return success.serverData()
-            .map(Base64.getEncoder()::encodeToString)
-            .orElse("");
-    }
-
-    /**
-     * Decodes an IMAP client continuation line and forwards it to the SASL 
exchange.
-     */
-    public SaslStep onClientResponse(SaslExchange exchange, byte[] line) {
-        return exchange.onResponse(decodeBase64(stripTrailingCrlf(line)));
-    }
-
-    public boolean isAbort(byte[] line) {
-        return "*".equals(stripTrailingCrlf(line));
-    }
-
-    /**
-     * Detects the empty IMAP client response used to acknowledge final SASL 
server data.
-     */
-    public boolean isEmptyClientResponse(byte[] line) {
-        return stripTrailingCrlf(line).isEmpty();
-    }
-
-    /**
-     * Aborts an active SASL exchange.
-     */
-    public void abort(SaslExchange exchange) {
-        exchange.abort();
-    }
-
-    /**
-     * Closes an active SASL exchange.
-     */
-    public void close(SaslExchange exchange) {
-        exchange.close();
-    }
-
-    private byte[] decodeBase64(String value) {
-        return Base64.getDecoder().decode(value);
-    }
-
-    private byte[] decodeInitialClientResponse(String value) {
-        if (value.equals("=")) {
-            return new byte[0];
-        }
-        return decodeBase64(value);
-    }
-
-    private String stripTrailingCrlf(byte[] line) {
-        String value = new String(line, StandardCharsets.US_ASCII);
-        return StringUtils.stripEnd(value, "\r\n");
-    }
-}
diff --git 
a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java
 
b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java
deleted file mode 100644
index 63053ee11c..0000000000
--- 
a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java
+++ /dev/null
@@ -1,240 +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.imap.processor.sasl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.List;
-import java.util.Optional;
-
-import org.apache.james.core.Username;
-import org.apache.james.protocols.api.sasl.SaslExchange;
-import org.apache.james.protocols.api.sasl.SaslIdentity;
-import org.apache.james.protocols.api.sasl.SaslInitialRequest;
-import org.apache.james.protocols.api.sasl.SaslStep;
-import org.junit.jupiter.api.Test;
-
-class ImapSaslBridgeTest {
-    private static final Username USER = Username.of("[email protected]");
-    private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
-
-    private final ImapSaslBridge testee = new ImapSaslBridge();
-
-    private static class RecordingExchange implements SaslExchange {
-        protected final List<String> lifecycleEvents;
-        private byte[] lastClientResponse;
-
-        private RecordingExchange() {
-            this.lifecycleEvents = new ArrayList<>();
-        }
-
-        @Override
-        public SaslStep firstStep() {
-            return new SaslStep.Challenge(Optional.of(bytes("challenge")));
-        }
-
-        @Override
-        public SaslStep onResponse(byte[] clientResponse) {
-            lastClientResponse = clientResponse.clone();
-            return new SaslStep.Success(IDENTITY, Optional.empty());
-        }
-
-        @Override
-        public void close() {
-            lifecycleEvents.add("close");
-        }
-    }
-
-    private static class RecordingAbortExchange extends RecordingExchange {
-        @Override
-        public void abort() {
-            lifecycleEvents.add("abort");
-            close();
-        }
-    }
-
-    private static class ThrowingAbortExchange implements SaslExchange {
-        private final List<String> lifecycleEvents;
-
-        private ThrowingAbortExchange() {
-            this.lifecycleEvents = new ArrayList<>();
-        }
-
-        @Override
-        public SaslStep firstStep() {
-            return new SaslStep.Challenge(Optional.of(bytes("challenge")));
-        }
-
-        @Override
-        public SaslStep onResponse(byte[] clientResponse) {
-            return new SaslStep.Success(IDENTITY, Optional.empty());
-        }
-
-        @Override
-        public void abort() {
-            lifecycleEvents.add("abort");
-            close();
-            throw new IllegalStateException("boom");
-        }
-
-        @Override
-        public void close() {
-            lifecycleEvents.add("close");
-        }
-    }
-
-    @Test
-    void initialRequestShouldDecodeInitialClientResponse() {
-        String encodedInitialResponse = 
Base64.getEncoder().encodeToString(bytes("initial"));
-
-        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of(encodedInitialResponse));
-
-        assertThat(request.mechanismName()).isEqualTo("PLAIN");
-        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).containsExactly(bytes("initial")));
-    }
-
-    @Test
-    void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() {
-        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of("="));
-
-        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).isEmpty());
-    }
-
-    @Test
-    void continuationShouldBase64EncodeChallengePayload() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.of(bytes("challenge")));
-
-        String continuation = testee.continuation(challenge);
-
-        
assertThat(continuation).isEqualTo(Base64.getEncoder().encodeToString(bytes("challenge")));
-    }
-
-    @Test
-    void continuationShouldReturnEmptyStringWhenChallengeHasNoPayload() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.empty());
-
-        String continuation = testee.continuation(challenge);
-
-        assertThat(continuation).isEmpty();
-    }
-
-    @Test
-    void successDataShouldBase64EncodePayload() {
-        SaslStep.Success success = new SaslStep.Success(IDENTITY, 
Optional.of(bytes("server-data")));
-
-        String successData = testee.successData(success);
-
-        
assertThat(successData).isEqualTo(Base64.getEncoder().encodeToString(bytes("server-data")));
-    }
-
-    @Test
-    void isEmptyClientResponseShouldDetectEmptyLine() {
-        
assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isEmptyClientResponseShouldRejectNonEmptyLine() {
-        
assertThat(testee.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse();
-    }
-
-    @Test
-    void onClientResponseShouldDecodeLineAndContinueExchange() {
-        RecordingExchange exchange = new RecordingExchange();
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
-
-        SaslStep step = testee.onClientResponse(exchange, line);
-
-        assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY);
-        
assertThat(exchange.lastClientResponse).containsExactly(bytes("response"));
-    }
-
-    @Test
-    void onClientResponseShouldDecodeLineWithLfOnly() {
-        RecordingExchange exchange = new RecordingExchange();
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\n").getBytes(StandardCharsets.US_ASCII);
-
-        SaslStep step = testee.onClientResponse(exchange, line);
-
-        assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY);
-        
assertThat(exchange.lastClientResponse).containsExactly(bytes("response"));
-    }
-
-    @Test
-    void isAbortShouldDetectImapSaslAbortLine() {
-        
assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isAbortShouldDetectImapSaslAbortLineWithLfOnly() {
-        
assertThat(testee.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isAbortShouldRejectRegularClientResponse() {
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
-
-        assertThat(testee.isAbort(line)).isFalse();
-    }
-
-    @Test
-    void abortShouldCloseExchangeByDefault() {
-        RecordingExchange exchange = new RecordingExchange();
-
-        testee.abort(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("close");
-    }
-
-    @Test
-    void abortShouldUseExchangeSpecificAbortWhenOverridden() {
-        RecordingAbortExchange exchange = new RecordingAbortExchange();
-
-        testee.abort(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("abort", "close");
-    }
-
-    @Test
-    void abortShouldPropagateExchangeSpecificAbortFailure() {
-        ThrowingAbortExchange exchange = new ThrowingAbortExchange();
-
-        assertThatThrownBy(() -> testee.abort(exchange))
-            .isInstanceOf(IllegalStateException.class);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("abort", "close");
-    }
-
-    @Test
-    void closeShouldCloseExchange() {
-        RecordingExchange exchange = new RecordingExchange();
-
-        testee.close(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("close");
-    }
-
-    private static byte[] bytes(String value) {
-        return value.getBytes(StandardCharsets.UTF_8);
-    }
-}
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 fcb6331468..eb649ae12f 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
@@ -36,6 +36,7 @@ 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.SaslCodec;
 import org.apache.james.protocols.api.sasl.SaslExchange;
 import org.apache.james.protocols.api.sasl.SaslFailure;
 import org.apache.james.protocols.api.sasl.SaslInitialRequest;
@@ -82,8 +83,6 @@ public class AuthCmdHandler
     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 ImmutableList<SaslMechanism> saslMechanisms = ImmutableList.of();
     private Optional<SaslAuthenticator> saslAuthenticator = Optional.empty();
     private ImmutableList<AuthHook> authHooks = ImmutableList.of();
@@ -136,7 +135,7 @@ public class AuthCmdHandler
         }
 
         try {
-            SaslExchange exchange = startExchange(maybeMechanism.get(), 
SASL_BRIDGE.initialRequest(authType, initialResponse));
+            SaslExchange exchange = startExchange(maybeMechanism.get(), 
SaslCodec.initialRequest(authType, initialResponse));
             return handleFirstSaslStep(session, authType, exchange);
         } catch (IllegalArgumentException e) {
             LOGGER.info("Could not decode parameters for AUTH {}", authType, 
e);
@@ -149,12 +148,12 @@ public class AuthCmdHandler
             SaslStep step = exchange.firstStep();
             if (step instanceof SaslStep.Challenge challenge) {
                 session.pushLineHandler(saslLineHandler(authType, exchange));
-                return SASL_BRIDGE.challenge(challenge);
+                return challengeResponse(challenge.payload());
             }
             return handleTerminalSaslStep(session, authType, exchange, step, 
() -> {
             });
         } catch (RuntimeException e) {
-            SASL_BRIDGE.close(exchange);
+            exchange.close();
             throw e;
         }
     }
@@ -175,28 +174,28 @@ public class AuthCmdHandler
 
     private Response handleSaslContinuation(SMTPSession session, String 
authType, SaslExchange exchange, String line) {
         try {
-            SaslStep step = SASL_BRIDGE.onClientResponse(exchange, 
line.getBytes(session.getCharset()));
+            SaslStep step = 
exchange.onResponse(SaslCodec.decodeClientResponse(line.getBytes(session.getCharset())));
             if (step instanceof SaslStep.Challenge challenge) {
-                return SASL_BRIDGE.challenge(challenge);
+                return challengeResponse(challenge.payload());
             }
             return handleTerminalSaslStep(session, authType, exchange, step, 
session::popLineHandler);
         } catch (IllegalArgumentException e) {
             LOGGER.info("Could not decode parameters for AUTH {}", authType, 
e);
             session.popLineHandler();
-            SASL_BRIDGE.close(exchange);
+            exchange.close();
             return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could 
not decode parameters for AUTH " + authType);
         } catch (RuntimeException e) {
             session.popLineHandler();
-            SASL_BRIDGE.close(exchange);
+            exchange.close();
             throw e;
         }
     }
 
     private LineHandler<SMTPSession> saslLineHandler(String authType, 
SaslExchange exchange) {
         return (session, line) -> {
-            if (SASL_BRIDGE.isAbort(line)) {
+            if (SaslCodec.isAbort(line)) {
                 session.popLineHandler();
-                SASL_BRIDGE.abort(exchange);
+                exchange.abort();
                 return AUTH_ABORTED;
             }
             return handleSaslContinuation(session, authType, exchange, new 
String(line, session.getCharset()));
@@ -205,13 +204,16 @@ public class AuthCmdHandler
 
     private Response handleSaslSuccess(SMTPSession session, String authType, 
SaslExchange exchange, SaslStep.Success success) {
         if (success.serverData().isPresent()) {
+            // Per RFC 4422, when a mechanism has additional success data and 
the protocol
+            // outcome has no dedicated field for it, the server sends it as a 
challenge,
+            // waits for an empty client response, then returns the successful 
outcome.
             
session.pushLineHandler(successDataAcknowledgementLineHandler(authType, 
exchange, success));
-            return SASL_BRIDGE.successData(success);
+            return challengeResponse(success.serverData());
         }
         try {
             return applySaslSuccess(session, authType, exchange, success);
         } finally {
-            SASL_BRIDGE.close(exchange);
+            exchange.close();
         }
     }
 
@@ -225,18 +227,18 @@ public class AuthCmdHandler
         boolean aborted = false;
         try {
             byte[] bytes = line.getBytes(session.getCharset());
-            if (SASL_BRIDGE.isAbort(bytes)) {
+            if (SaslCodec.isAbort(bytes)) {
                 aborted = true;
-                SASL_BRIDGE.abort(exchange);
+                exchange.abort();
                 return AUTH_ABORTED;
             }
-            if (!SASL_BRIDGE.isEmptyClientResponse(bytes)) {
+            if (!SaslCodec.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);
+                exchange.close();
             }
         }
     }
@@ -281,10 +283,14 @@ public class AuthCmdHandler
                     case INVALID_CREDENTIALS, AUTHENTICATION_FAILED, 
USER_DOES_NOT_EXIST, DELEGATION_FORBIDDEN -> AUTH_FAILED;
                 });
         } finally {
-            SASL_BRIDGE.close(exchange);
+            exchange.close();
         }
     }
 
+    private Response challengeResponse(Optional<byte[]> payload) {
+        return new SMTPResponse(SMTPRetCode.AUTH_READY, 
SaslCodec.encode(payload)).immutable();
+    }
+
     private Optional<SaslMechanism> findAvailableMechanism(SMTPSession 
session, String authType) {
         return effectiveSaslMechanisms(session)
             .stream()
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java
deleted file mode 100644
index 67a4682767..0000000000
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java
+++ /dev/null
@@ -1,116 +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.nio.charset.StandardCharsets;
-import java.util.Base64;
-import java.util.Optional;
-
-import org.apache.commons.lang3.StringUtils;
-import org.apache.james.protocols.api.Response;
-import org.apache.james.protocols.api.sasl.SaslExchange;
-import org.apache.james.protocols.api.sasl.SaslInitialRequest;
-import org.apache.james.protocols.api.sasl.SaslStep;
-import org.apache.james.protocols.smtp.SMTPResponse;
-import org.apache.james.protocols.smtp.SMTPRetCode;
-
-public class SmtpSaslBridge {
-    /**
-     * Converts an SMTP AUTH command into a protocol-neutral SASL initial 
request.
-     */
-    public SaslInitialRequest initialRequest(String mechanismName, 
Optional<String> initialClientResponse) {
-        return new SaslInitialRequest(mechanismName, 
initialClientResponse.map(this::decodeInitialClientResponse));
-    }
-
-    /**
-     * Encodes a SASL challenge as an SMTP 334 response.
-     */
-    public Response challenge(SaslStep.Challenge challenge) {
-        return new SMTPResponse(SMTPRetCode.AUTH_READY, 
encode(challenge.payload())).immutable();
-    }
-
-    /**
-     * Encodes final SASL server data as an SMTP 334 response.
-     *
-     * Per RFC 4422, when a mechanism has additional success data and the 
protocol
-     * outcome has no dedicated field for it, the server sends it as a 
challenge,
-     * waits for an empty client response, then returns the successful outcome.
-     */
-    public Response successData(SaslStep.Success success) {
-        return new SMTPResponse(SMTPRetCode.AUTH_READY, 
encode(success.serverData())).immutable();
-    }
-
-    /**
-     * Decodes an SMTP client continuation line and forwards it to the SASL 
exchange.
-     */
-    public SaslStep onClientResponse(SaslExchange exchange, byte[] line) {
-        return exchange.onResponse(decodeBase64(stripTrailingCrlf(line)));
-    }
-
-    /**
-     * Detects SMTP SASL client cancellation.
-     */
-    public boolean isAbort(byte[] line) {
-        return "*".equals(stripTrailingCrlf(line));
-    }
-
-    /**
-     * Detects the empty SMTP client response used to acknowledge final SASL 
server data.
-     */
-    public boolean isEmptyClientResponse(byte[] line) {
-        return stripTrailingCrlf(line).isEmpty();
-    }
-
-    /**
-     * Aborts an active SASL exchange.
-     */
-    public void abort(SaslExchange exchange) {
-        exchange.abort();
-    }
-
-    /**
-     * Closes an active SASL exchange.
-     */
-    public void close(SaslExchange exchange) {
-        exchange.close();
-    }
-
-    private byte[] decodeBase64(String value) {
-        return Base64.getDecoder().decode(value);
-    }
-
-    private byte[] decodeInitialClientResponse(String value) {
-        if (value.equals("=")) {
-            return new byte[0];
-        }
-        return decodeBase64(value);
-    }
-
-    private String encode(Optional<byte[]> payload) {
-        return payload
-            .map(Base64.getEncoder()::encodeToString)
-            .orElse("");
-    }
-
-    private String stripTrailingCrlf(byte[] line) {
-        String value = new String(line, StandardCharsets.US_ASCII);
-        return StringUtils.stripEnd(value, "\r\n");
-    }
-}
diff --git 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java
 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java
deleted file mode 100644
index 0db637f68a..0000000000
--- 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java
+++ /dev/null
@@ -1,247 +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 static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.List;
-import java.util.Optional;
-
-import org.apache.james.core.Username;
-import org.apache.james.protocols.api.Response;
-import org.apache.james.protocols.api.sasl.SaslExchange;
-import org.apache.james.protocols.api.sasl.SaslIdentity;
-import org.apache.james.protocols.api.sasl.SaslInitialRequest;
-import org.apache.james.protocols.api.sasl.SaslStep;
-import org.apache.james.protocols.smtp.SMTPRetCode;
-import org.junit.jupiter.api.Test;
-
-class SmtpSaslBridgeTest {
-    private static final Username USER = Username.of("[email protected]");
-    private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
-
-    private final SmtpSaslBridge testee = new SmtpSaslBridge();
-
-    private static class RecordingExchange implements SaslExchange {
-        protected final List<String> lifecycleEvents;
-        private byte[] lastClientResponse;
-
-        private RecordingExchange() {
-            this.lifecycleEvents = new ArrayList<>();
-        }
-
-        @Override
-        public SaslStep firstStep() {
-            return new SaslStep.Challenge(Optional.of(bytes("challenge")));
-        }
-
-        @Override
-        public SaslStep onResponse(byte[] clientResponse) {
-            lastClientResponse = clientResponse.clone();
-            return new SaslStep.Success(IDENTITY, Optional.empty());
-        }
-
-        @Override
-        public void close() {
-            lifecycleEvents.add("close");
-        }
-    }
-
-    private static class RecordingAbortExchange extends RecordingExchange {
-        @Override
-        public void abort() {
-            lifecycleEvents.add("abort");
-            close();
-        }
-    }
-
-    @Test
-    void initialRequestShouldDecodeInitialClientResponse() {
-        String encodedInitialResponse = 
Base64.getEncoder().encodeToString(bytes("initial"));
-
-        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of(encodedInitialResponse));
-
-        assertThat(request.mechanismName()).isEqualTo("PLAIN");
-        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).containsExactly(bytes("initial")));
-    }
-
-    @Test
-    void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() {
-        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of("="));
-
-        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).isEmpty());
-    }
-
-    @Test
-    void initialRequestShouldRejectMalformedInitialClientResponse() {
-        assertThatThrownBy(() -> testee.initialRequest("PLAIN", 
Optional.of("not-base64!")))
-            .isInstanceOf(IllegalArgumentException.class);
-    }
-
-    @Test
-    void challengeShouldBase64EncodePayloadAsSmtp334Response() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.of(bytes("challenge")));
-
-        Response response = testee.challenge(challenge);
-
-        assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY);
-        assertThat(response.getLines()).containsExactly("334 " + 
Base64.getEncoder().encodeToString(bytes("challenge")));
-    }
-
-    @Test
-    void challengeShouldReturnEmptySmtp334ResponseWhenChallengeHasNoPayload() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.empty());
-
-        Response response = testee.challenge(challenge);
-
-        assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY);
-        assertThat(response.getLines()).containsExactly("334 ");
-    }
-
-    @Test
-    void challengeShouldEncodeAuthLoginUsernamePrompt() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.of(bytes("Username:")));
-
-        Response response = testee.challenge(challenge);
-
-        assertThat(response.getLines()).containsExactly("334 VXNlcm5hbWU6");
-    }
-
-    @Test
-    void challengeShouldEncodeAuthLoginPasswordPrompt() {
-        SaslStep.Challenge challenge = new 
SaslStep.Challenge(Optional.of(bytes("Password:")));
-
-        Response response = testee.challenge(challenge);
-
-        assertThat(response.getLines()).containsExactly("334 UGFzc3dvcmQ6");
-    }
-
-    @Test
-    void onClientResponseShouldDecodeLineAndContinueExchange() {
-        RecordingExchange exchange = new RecordingExchange();
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
-
-        SaslStep step = testee.onClientResponse(exchange, line);
-
-        assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY);
-        
assertThat(exchange.lastClientResponse).containsExactly(bytes("response"));
-    }
-
-    @Test
-    void onClientResponseShouldDecodeLineWithLfOnly() {
-        RecordingExchange exchange = new RecordingExchange();
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\n").getBytes(StandardCharsets.US_ASCII);
-
-        SaslStep step = testee.onClientResponse(exchange, line);
-
-        assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY);
-        
assertThat(exchange.lastClientResponse).containsExactly(bytes("response"));
-    }
-
-    @Test
-    void onClientResponseShouldRejectMalformedBase64() {
-        RecordingExchange exchange = new RecordingExchange();
-
-        assertThatThrownBy(() -> testee.onClientResponse(exchange, 
"not-base64!\r\n".getBytes(StandardCharsets.US_ASCII)))
-            .isInstanceOf(IllegalArgumentException.class);
-    }
-
-    @Test
-    void isAbortShouldDetectSmtpSaslAbortLine() {
-        
assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isAbortShouldDetectSmtpSaslAbortLineWithLfOnly() {
-        
assertThat(testee.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isAbortShouldRejectRegularClientResponse() {
-        byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + 
"\r\n").getBytes(StandardCharsets.US_ASCII);
-
-        assertThat(testee.isAbort(line)).isFalse();
-    }
-
-    @Test
-    void successDataShouldBase64EncodePayloadAsSmtp334Response() {
-        SaslStep.Success success = new SaslStep.Success(IDENTITY, 
Optional.of(bytes("server-data")));
-
-        Response response = testee.successData(success);
-
-        assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY);
-        assertThat(response.getLines()).containsExactly("334 " + 
Base64.getEncoder().encodeToString(bytes("server-data")));
-    }
-
-    @Test
-    void successDataShouldReturnEmptySmtp334ResponseWhenPayloadIsEmpty() {
-        SaslStep.Success success = new SaslStep.Success(IDENTITY, 
Optional.empty());
-
-        Response response = testee.successData(success);
-
-        assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY);
-        assertThat(response.getLines()).containsExactly("334 ");
-    }
-
-    @Test
-    void isEmptyClientResponseShouldDetectEmptyLine() {
-        
assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
-    }
-
-    @Test
-    void isEmptyClientResponseShouldRejectNonEmptyLine() {
-        
assertThat(testee.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse();
-    }
-
-    @Test
-    void abortShouldCloseExchangeByDefault() {
-        RecordingExchange exchange = new RecordingExchange();
-
-        testee.abort(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("close");
-    }
-
-    @Test
-    void abortShouldUseExchangeSpecificAbortWhenOverridden() {
-        RecordingAbortExchange exchange = new RecordingAbortExchange();
-
-        testee.abort(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("abort", "close");
-    }
-
-    @Test
-    void closeShouldCloseExchange() {
-        RecordingExchange exchange = new RecordingExchange();
-
-        testee.close(exchange);
-
-        assertThat(exchange.lifecycleEvents).containsExactly("close");
-    }
-
-    private static byte[] bytes(String value) {
-        return value.getBytes(StandardCharsets.UTF_8);
-    }
-}


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

Reply via email to