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 c849032d66f465bb7e17c4520d93a69529705e98
Author: Quan Tran <[email protected]>
AuthorDate: Tue Jun 30 09:39:31 2026 +0700

    JAMES-4210 Add SMTP LOGIN SASL mechanism
    
    Introduce an SMTP-only LOGIN SASL mechanism and factory. The mechanism owns 
the username/password challenge flow and delegates final credential validation 
to the configured PLAIN mechanism.
---
 .../protocols/api/sasl/SaslMechanismNames.java     |   1 +
 .../smtp/core/esmtp/LoginSaslMechanism.java        | 128 ++++++++++++++++
 .../core/esmtp/LoginSaslMechanismFactory.java}     |  25 +++-
 .../smtp/core/esmtp/LoginSaslMechanismTest.java    | 164 +++++++++++++++++++++
 4 files changed, 312 insertions(+), 6 deletions(-)

diff --git 
a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
 
b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
index 70e76d0503..20aad26648 100644
--- 
a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
+++ 
b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
@@ -20,6 +20,7 @@
 package org.apache.james.protocols.api.sasl;
 
 public final class SaslMechanismNames {
+    public static final String LOGIN = "LOGIN";
     public static final String PLAIN = "PLAIN";
     public static final String OAUTHBEARER = "OAUTHBEARER";
     public static final String XOAUTH2 = "XOAUTH2";
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java
new file mode 100644
index 0000000000..7e91a08458
--- /dev/null
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java
@@ -0,0 +1,128 @@
+/****************************************************************
+ * 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.Optional;
+
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+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;
+
+/**
+ * SMTP-only AUTH LOGIN framing backed by the configured PLAIN mechanism.
+ */
+public class LoginSaslMechanism implements SaslMechanism {
+    private static final byte[] USERNAME_PROMPT = 
"Username:".getBytes(StandardCharsets.US_ASCII);
+    private static final byte[] PASSWORD_PROMPT = 
"Password:".getBytes(StandardCharsets.US_ASCII);
+
+    private final SaslMechanism plainMechanism;
+
+    LoginSaslMechanism(SaslMechanism plainMechanism) {
+        this.plainMechanism = plainMechanism;
+    }
+
+    static Optional<SaslExchange> delegatedExchange(SaslExchange exchange) {
+        if (exchange instanceof LoginSaslExchange loginSaslExchange) {
+            return loginSaslExchange.plainExchange;
+        }
+        return Optional.empty();
+    }
+
+    SaslMechanism plainMechanism() {
+        return plainMechanism;
+    }
+
+    @Override
+    public String name() {
+        return SaslMechanismNames.LOGIN;
+    }
+
+    @Override
+    public boolean isAvailableOnTransport(boolean channelEncrypted) {
+        return plainMechanism.isAvailableOnTransport(channelEncrypted);
+    }
+
+    @Override
+    public SaslExchange start(SaslInitialRequest request, SaslAuthenticator 
authenticator) {
+        return new LoginSaslExchange(request.initialResponse(), authenticator);
+    }
+
+    private class LoginSaslExchange implements SaslExchange {
+        private final Optional<byte[]> initialResponse;
+        private final SaslAuthenticator authenticator;
+        private Optional<String> username;
+        private Optional<SaslExchange> plainExchange;
+
+        private LoginSaslExchange(Optional<byte[]> initialResponse, 
SaslAuthenticator authenticator) {
+            this.initialResponse = initialResponse;
+            this.authenticator = authenticator;
+            this.username = Optional.empty();
+            this.plainExchange = Optional.empty();
+        }
+
+        @Override
+        public SaslStep firstStep() {
+            return initialResponse
+                .map(this::recordUsernameThenChallengePassword)
+                .orElseGet(() -> new 
SaslStep.Challenge(Optional.of(USERNAME_PROMPT)));
+        }
+
+        @Override
+        public SaslStep onResponse(byte[] clientResponse) {
+            return username
+                .map(username -> authenticate(username, clientResponse))
+                .orElseGet(() -> 
recordUsernameThenChallengePassword(clientResponse));
+        }
+
+        @Override
+        public void close() {
+            plainExchange.ifPresent(SaslExchange::close);
+        }
+
+        private SaslStep recordUsernameThenChallengePassword(byte[] 
clientResponse) {
+            username = Optional.of(new String(clientResponse, 
StandardCharsets.UTF_8));
+            return new SaslStep.Challenge(Optional.of(PASSWORD_PROMPT));
+        }
+
+        private SaslStep authenticate(String username, byte[] password) {
+            SaslInitialRequest plainRequest = new 
SaslInitialRequest(SaslMechanismNames.PLAIN,
+                Optional.of(plainInitialResponse(username, password)));
+            SaslExchange exchange = startPlainExchange(plainRequest);
+            plainExchange = Optional.of(exchange);
+            return exchange.firstStep();
+        }
+
+        private SaslExchange startPlainExchange(SaslInitialRequest 
plainRequest) {
+            return plainMechanism.start(plainRequest, authenticator);
+        }
+
+        private byte[] plainInitialResponse(String username, byte[] password) {
+            byte[] usernameBytes = username.getBytes(StandardCharsets.UTF_8);
+            byte[] response = new byte[usernameBytes.length + password.length 
+ 2];
+            System.arraycopy(usernameBytes, 0, response, 1, 
usernameBytes.length);
+            System.arraycopy(password, 0, response, usernameBytes.length + 2, 
password.length);
+            return response;
+        }
+    }
+}
diff --git 
a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java
similarity index 54%
copy from 
protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
copy to 
protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java
index 70e76d0503..17a140b912 100644
--- 
a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java
@@ -17,13 +17,26 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.protocols.api.sasl;
+package org.apache.james.protocols.smtp.core.esmtp;
 
-public final class SaslMechanismNames {
-    public static final String PLAIN = "PLAIN";
-    public static final String OAUTHBEARER = "OAUTHBEARER";
-    public static final String XOAUTH2 = "XOAUTH2";
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
 
-    private SaslMechanismNames() {
+/**
+ * SMTP AUTH LOGIN framing backed by a configured PLAIN mechanism factory.
+ */
+public class LoginSaslMechanismFactory implements SaslMechanismFactory {
+    private final SaslMechanismFactory plainSaslMechanismFactory;
+
+    public LoginSaslMechanismFactory(SaslMechanismFactory 
plainSaslMechanismFactory) {
+        this.plainSaslMechanismFactory = plainSaslMechanismFactory;
+    }
+
+    @Override
+    public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> 
serverConfiguration) throws ConfigurationException {
+        return new 
LoginSaslMechanism(plainSaslMechanismFactory.create(serverConfiguration));
     }
 }
diff --git 
a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java
 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java
new file mode 100644
index 0000000000..0aaef7a211
--- /dev/null
+++ 
b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java
@@ -0,0 +1,164 @@
+/****************************************************************
+ * 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 java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.apache.james.protocols.api.sasl.SaslAuthenticationResult;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+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.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismNames;
+import org.apache.james.protocols.api.sasl.SaslStep;
+import org.junit.jupiter.api.Test;
+
+class LoginSaslMechanismTest {
+    private static final Username USERNAME = Username.of("[email protected]");
+    private static final SaslIdentity IDENTITY = new SaslIdentity(USERNAME, 
USERNAME);
+    private static final SaslAuthenticator UNUSED_AUTHENTICATOR = new 
SaslAuthenticator() {
+        @Override
+        public SaslAuthenticationResult authenticatePassword(Username 
authenticationId, Optional<Username> authorizationId, String password) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public SaslAuthenticationResult authorize(SaslIdentity identity) {
+            throw new UnsupportedOperationException();
+        }
+    };
+
+    private final RecordingPlainMechanism plainMechanism = new 
RecordingPlainMechanism();
+    private final LoginSaslMechanism testee = new 
LoginSaslMechanism(plainMechanism);
+
+    @Test
+    void nameShouldBeLogin() {
+        assertThat(testee.name()).isEqualTo(SaslMechanismNames.LOGIN);
+    }
+
+    @Test
+    void isAvailableOnTransportShouldDelegateToPlainMechanism() {
+        plainMechanism.availableOnEncryptedTransportOnly = true;
+
+        assertThat(testee.isAvailableOnTransport(false)).isFalse();
+        assertThat(testee.isAvailableOnTransport(true)).isTrue();
+    }
+
+    @Test
+    void firstStepShouldAskForUsernameWhenInitialResponseIsAbsent() {
+        SaslExchange exchange = testee.start(new 
SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), 
UNUSED_AUTHENTICATOR);
+
+        SaslStep step = exchange.firstStep();
+
+        assertThat(((SaslStep.Challenge) step).payload())
+            .hasValueSatisfying(payload -> 
assertThat(payload).containsExactly(bytes("Username:")));
+    }
+
+    @Test
+    void shouldAskForPasswordAfterUsernameResponse() {
+        SaslExchange exchange = testee.start(new 
SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), 
UNUSED_AUTHENTICATOR);
+        exchange.firstStep();
+
+        SaslStep step = exchange.onResponse(bytes("[email protected]"));
+
+        assertThat(((SaslStep.Challenge) step).payload())
+            .hasValueSatisfying(payload -> 
assertThat(payload).containsExactly(bytes("Password:")));
+    }
+
+    @Test
+    void firstStepShouldAskForPasswordWhenInitialResponseContainsUsername() {
+        SaslExchange exchange = testee.start(new 
SaslInitialRequest(SaslMechanismNames.LOGIN, 
Optional.of(bytes("[email protected]"))), UNUSED_AUTHENTICATOR);
+
+        SaslStep step = exchange.firstStep();
+
+        assertThat(((SaslStep.Challenge) step).payload())
+            .hasValueSatisfying(payload -> 
assertThat(payload).containsExactly(bytes("Password:")));
+    }
+
+    @Test
+    void shouldDelegateUsernameAndPasswordToPlainMechanism() {
+        SaslExchange exchange = testee.start(new 
SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), 
UNUSED_AUTHENTICATOR);
+        exchange.firstStep();
+        exchange.onResponse(bytes("[email protected]"));
+
+        SaslStep step = exchange.onResponse(bytes("secret"));
+
+        assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY);
+        assertThat(plainMechanism.initialResponse)
+            .containsExactly(bytes("\[email protected]\0secret"));
+    }
+
+    @Test
+    void closeShouldCloseDelegatedPlainExchange() {
+        SaslExchange exchange = testee.start(new 
SaslInitialRequest(SaslMechanismNames.LOGIN, 
Optional.of(bytes("[email protected]"))), UNUSED_AUTHENTICATOR);
+        exchange.firstStep();
+        exchange.onResponse(bytes("secret"));
+
+        exchange.close();
+
+        assertThat(plainMechanism.exchangeClosed).isTrue();
+    }
+
+    private static byte[] bytes(String value) {
+        return value.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static class RecordingPlainMechanism implements SaslMechanism {
+        private boolean availableOnEncryptedTransportOnly;
+        private boolean exchangeClosed;
+        private byte[] initialResponse;
+
+        @Override
+        public String name() {
+            return SaslMechanismNames.PLAIN;
+        }
+
+        @Override
+        public boolean isAvailableOnTransport(boolean channelEncrypted) {
+            return !availableOnEncryptedTransportOnly || channelEncrypted;
+        }
+
+        @Override
+        public SaslExchange start(SaslInitialRequest request, 
SaslAuthenticator authenticator) {
+            initialResponse = request.initialResponse().orElseThrow().clone();
+            return new SaslExchange() {
+                @Override
+                public SaslStep firstStep() {
+                    return new SaslStep.Success(IDENTITY, Optional.empty());
+                }
+
+                @Override
+                public SaslStep onResponse(byte[] clientResponse) {
+                    throw new UnsupportedOperationException();
+                }
+
+                @Override
+                public void close() {
+                    exchangeClosed = true;
+                }
+            };
+        }
+    }
+}


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

Reply via email to