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]