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/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
index 3a7234356c4..af5d5b01d07 100644
--- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
+++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
@@ -23,8 +23,12 @@
import org.apache.james.protocols.smtp.SMTPSession;
/**
- * Implement this interfaces to hook in the AUTH Command
+ * Legacy SMTP authentication hook.
+ *
+ * @deprecated Implement {@code SaslMechanismFactory} for authentication, or {@link SaslAuthResultHook}
+ * for post-authentication side effects.
*/
+@Deprecated
public interface AuthHook extends Hook {
/**
@@ -37,7 +41,9 @@ public interface AuthHook extends Hook {
*/
HookResult doAuth(SMTPSession session, Username username, String password);
- HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse);
+ default HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse) {
+ return HookResult.DECLINED;
+ }
default HookResult doDelegation(SMTPSession session, Username target) {
return HookResult.DECLINED;
diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java
new file mode 100644
index 00000000000..caaf729433d
--- /dev/null
+++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java
@@ -0,0 +1,37 @@
+/****************************************************************
+ * 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.hook;
+
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.smtp.SMTPSession;
+
+/**
+ * Decorates terminal SMTP SASL authentication results without participating in credential validation.
+ *
+ * Use this hook for legacy {@link AuthHook} use cases that only need authentication result side effects,
+ * such as audit events, notifications or metrics. Credential validation should be implemented by a SASL
+ * mechanism instead.
+ */
+public interface SaslAuthResultHook extends Hook {
+ void onSuccess(SMTPSession session, String mechanismName, SaslIdentity identity);
+
+ void onFailure(SMTPSession session, String mechanismName, SaslFailure failure);
+}
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 edf407331d7..00000000000
--- 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/core/esmtp/SmtpSaslBridgeTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java
new file mode 100644
index 00000000000..0db637f68ad
--- /dev/null
+++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java
@@ -0,0 +1,247 @@
+/****************************************************************
+ * 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("user@example.com");
+ private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
+
+ private final SmtpSaslBridge testee = new SmtpSaslBridge();
+
+ private static class RecordingExchange implements SaslExchange {
+ protected final List 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);
+ }
+}
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 bff98d4ce01..8bc8abe5f8f 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 int getRcptCount() {
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/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java
index 7fc3a55792e..8c08ae690a6 100644
--- a/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java
+++ b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java
@@ -25,6 +25,7 @@
import org.apache.james.blob.api.MetricableBlobStore;
import org.apache.james.blob.objectstorage.aws.S3BlobStoreDAO;
import org.apache.james.blob.objectstorage.aws.S3RequestOption;
+import org.apache.james.mailbox.Authenticator;
import org.apache.james.mailrepository.api.MailRepositoryFactory;
import org.apache.james.mailrepository.api.MailRepositoryUrlStore;
import org.apache.james.mailrepository.postgres.PostgresMailRepositoryFactory;
@@ -109,6 +110,7 @@ public class Main implements JamesServerMain {
Multibinder.newSetBinder(binder, PostgresDataDefinition.class)
.addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryDataDefinition.MODULE);
binder.bind(MailRepositoryUrlStore.class).to(PostgresMailRepositoryUrlStore.class).in(Scopes.SINGLETON);
+ binder.bind(Authenticator.class).to(UsersRepositoryBackedAuthenticator.class).in(Scopes.SINGLETON);
},
new CoreDataModule(),
new MemoryDelegationStoreModule(),
diff --git a/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java
new file mode 100644
index 00000000000..f980601e082
--- /dev/null
+++ b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java
@@ -0,0 +1,48 @@
+/****************************************************************
+ * 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;
+
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+
+import org.apache.james.core.Username;
+import org.apache.james.mailbox.Authenticator;
+import org.apache.james.mailbox.exception.MailboxException;
+import org.apache.james.user.api.UsersRepository;
+import org.apache.james.user.api.UsersRepositoryException;
+
+class UsersRepositoryBackedAuthenticator implements Authenticator {
+ private final UsersRepository usersRepository;
+
+ @Inject
+ UsersRepositoryBackedAuthenticator(UsersRepository usersRepository) {
+ this.usersRepository = usersRepository;
+ }
+
+ @Override
+ public Optional isAuthentic(Username userid, CharSequence passwd) throws MailboxException {
+ try {
+ return usersRepository.test(userid, passwd.toString());
+ } catch (UsersRepositoryException e) {
+ throw new MailboxException("Unable to access UsersRepository", e);
+ }
+ }
+}
diff --git a/server/container/guice/common/pom.xml b/server/container/guice/common/pom.xml
index c6a56f70283..f8001ee29ee 100644
--- a/server/container/guice/common/pom.xml
+++ b/server/container/guice/common/pom.xml
@@ -74,6 +74,10 @@
${james.groupId}
james-server-guice-netty