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 3d4cec1c2c8a62f56b4d125de263b173f7474b55 Author: Quan Tran <[email protected]> AuthorDate: Wed Jun 17 15:27:17 2026 +0700 JAMES-4210 Add SMTP SASL bridge Introduce an SMTP SASL bridge for decoding initial responses and continuation lines, encoding challenges as SMTP 334 responses, detecting aborts, and supporting final SASL server data through the RFC 4422 additional-challenge flow. --- .../protocols/smtp/core/esmtp/SmtpSaslBridge.java | 116 ++++++++++ .../smtp/core/esmtp/SmtpSaslBridgeTest.java | 247 +++++++++++++++++++++ 2 files changed, 363 insertions(+) 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 new file mode 100644 index 0000000000..67a4682767 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java @@ -0,0 +1,116 @@ +/**************************************************************** + * 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 new file mode 100644 index 0000000000..0db637f68a --- /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("[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]
