diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md
index 7d0eee03256..750ce1b7bee 100644
--- a/examples/custom-imap/README.md
+++ b/examples/custom-imap/README.md
@@ -14,6 +14,26 @@ Sample configure file: [imapserver.xml](./sample-configuration/imapserver.xml)
Note that when `imapPackages` is not provided, James will implicit use
`org.apache.James.modules.protocols.DefaultImapPackage`
+# Creating your own IMAP SASL mechanisms
+
+This example also demonstrates how to add a custom IMAP SASL mechanism.
+The `EXAMPLE-TOKEN` mechanism is declared through `auth.saslMechanisms`,
+while `auth.exampleToken` is a custom configuration block owned by the extension:
+
+```xml
+
+ PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory
+
+ secret-token
+ bob@domain.tld
+
+
+```
+
+`auth.saslMechanisms` lists SASL mechanism factory classes. Built-in factories
+can use simple names, while custom factories use their fully qualified class name.
+The factory reads that server's `auth.exampleToken` block.
+
## Running the example
Build the project:
@@ -56,4 +76,59 @@ a02 OK LOGIN completed.
A03 PING
* PONG
A03 OK PING completed.
+A04 LOGOUT
+```
+
+Test the custom SASL mechanism:
+
+```bash
+telnet localhost 143
+Trying 127.0.0.1...
+Connected to localhost.
+Escape character is '^]'.
+* OK JAMES IMAP4rev1 Server james.local is ready.
+A01 CAPABILITY
+* CAPABILITY IMAP4rev1 AUTH=PLAIN SASL-IR AUTH=EXAMPLE-TOKEN PING
+A01 OK CAPABILITY completed.
+A02 AUTHENTICATE EXAMPLE-TOKEN c2VjcmV0LXRva2Vu
+A02 OK AUTHENTICATE completed.
+A03 PING
+* PONG
+A03 OK PING completed.
+```
+
+The custom SASL mechanism also supports a continuation when the client does not send
+the initial response in the `AUTHENTICATE` command. The continuation payload is
+base64-encoded by IMAP, so `R28gYWhlYWQ` decodes to `Go ahead`:
+
+```bash
+telnet localhost 143
+Trying 127.0.0.1...
+Connected to localhost.
+Escape character is '^]'.
+* OK JAMES IMAP4rev1 Server james.local is ready.
+A01 AUTHENTICATE EXAMPLE-TOKEN
++ R28gYWhlYWQ
+c2VjcmV0LXRva2Vu
+A01 OK AUTHENTICATE completed.
+A02 PING
+* PONG
+A02 OK PING completed.
+```
+
+The mechanism can also return final server data on success. The client acknowledges
+that final data with an empty line before James sends the tagged `OK`:
+
+```bash
+telnet localhost 143
+Trying 127.0.0.1...
+Connected to localhost.
+Escape character is '^]'.
+* OK JAMES IMAP4rev1 Server james.local is ready.
+A01 AUTHENTICATE EXAMPLE-TOKEN
++ R28gYWhlYWQ
+c2VjcmV0LXRva2VuOnNlcnZlci1kYXRh
++ VG9rZW4gYWNjZXB0ZWQ=
+
+A01 OK AUTHENTICATE completed.
```
diff --git a/examples/custom-imap/pom.xml b/examples/custom-imap/pom.xml
index bbdfc87b089..a4665f87dac 100644
--- a/examples/custom-imap/pom.xml
+++ b/examples/custom-imap/pom.xml
@@ -67,6 +67,12 @@
${james.baseVersion}
provided
+
+ ${james.protocols.groupId}
+ protocols-api
+ ${james.baseVersion}
+ provided
+
com.google.inject
guice
diff --git a/examples/custom-imap/sample-configuration/imapserver.xml b/examples/custom-imap/sample-configuration/imapserver.xml
index 3333910586a..f1df338c0df 100644
--- a/examples/custom-imap/sample-configuration/imapserver.xml
+++ b/examples/custom-imap/sample-configuration/imapserver.xml
@@ -33,6 +33,13 @@ under the License.
0
false
false
+
+ PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory
+
+ secret-token
+ bob@domain.tld
+
+
org.apache.james.modules.protocols.DefaultImapPackage
org.apache.james.examples.imap.PingImapPackages
diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java
new file mode 100644
index 00000000000..6bf0c219cc6
--- /dev/null
+++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java
@@ -0,0 +1,43 @@
+/****************************************************************
+ * 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.examples.imap.sasl;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.core.Username;
+
+public record ExampleTokenSaslConfiguration(String expectedToken, Username authorizedUser) {
+ private static final String EXPECTED_TOKEN_PROPERTY = "auth.exampleToken.expectedToken";
+ private static final String AUTHORIZED_USER_PROPERTY = "auth.exampleToken.authorizedUser";
+
+ public static ExampleTokenSaslConfiguration from(HierarchicalConfiguration configuration) throws ConfigurationException {
+ if (!configuration.containsKey(EXPECTED_TOKEN_PROPERTY)) {
+ throw new ConfigurationException(EXPECTED_TOKEN_PROPERTY + " is mandatory");
+ }
+ if (!configuration.containsKey(AUTHORIZED_USER_PROPERTY)) {
+ throw new ConfigurationException(AUTHORIZED_USER_PROPERTY + " is mandatory");
+ }
+
+ return new ExampleTokenSaslConfiguration(
+ configuration.getString(EXPECTED_TOKEN_PROPERTY),
+ Username.of(configuration.getString(AUTHORIZED_USER_PROPERTY)));
+ }
+}
diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java
new file mode 100644
index 00000000000..6a8943a01b5
--- /dev/null
+++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java
@@ -0,0 +1,99 @@
+/****************************************************************
+ * 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.examples.imap.sasl;
+
+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.SaslFailure;
+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.SaslStep;
+
+public class ExampleTokenSaslMechanism implements SaslMechanism {
+ public static final String NAME = "EXAMPLE-TOKEN";
+ public static final String CONTINUATION_PROMPT = "Go ahead";
+ public static final String SUCCESS_DATA_TOKEN_SUFFIX = ":server-data";
+ public static final String SUCCESS_DATA = "Token accepted";
+
+ private final ExampleTokenSaslConfiguration configuration;
+
+ public ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ Optional initialResponse = request.initialResponse();
+ return new ExampleTokenSaslExchange(initialResponse, configuration);
+ }
+
+ private static class ExampleTokenSaslExchange implements SaslExchange {
+ private final Optional initialResponse;
+ private final ExampleTokenSaslConfiguration configuration;
+
+ private ExampleTokenSaslExchange(Optional initialResponse, ExampleTokenSaslConfiguration configuration) {
+ this.initialResponse = initialResponse;
+ this.configuration = configuration;
+ }
+
+ @Override
+ public SaslStep firstStep() {
+ return initialResponse
+ .map(this::authenticate)
+ .orElseGet(() -> new SaslStep.Challenge(Optional.of(CONTINUATION_PROMPT
+ .getBytes(StandardCharsets.UTF_8))));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ return authenticate(clientResponse);
+ }
+
+ @Override
+ public void close() {
+ }
+
+ private SaslStep authenticate(byte[] clientResponse) {
+ String token = new String(clientResponse, StandardCharsets.UTF_8);
+ if (configuration.expectedToken().equals(token)) {
+ return success(Optional.empty());
+ }
+ // allow client to request server to return data on success message, which may be used by Kerberos auth
+ if ((configuration.expectedToken() + SUCCESS_DATA_TOKEN_SUFFIX).equals(token)) {
+ return success(Optional.of(SUCCESS_DATA.getBytes(StandardCharsets.UTF_8)));
+ }
+ return new SaslStep.Failure(SaslFailure.authenticationFailed(Optional.empty(), Optional.of(configuration.authorizedUser()),
+ "EXAMPLE-TOKEN authentication failed."));
+ }
+
+ private SaslStep success(Optional serverData) {
+ return new SaslStep.Success(new SaslIdentity(configuration.authorizedUser(), configuration.authorizedUser()), serverData);
+ }
+ }
+}
diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java
new file mode 100644
index 00000000000..57895b789de
--- /dev/null
+++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.examples.imap.sasl;
+
+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;
+
+public class ExampleTokenSaslMechanismFactory implements SaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ return new ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration.from(serverConfiguration));
+ }
+}
diff --git a/examples/custom-imap/src/main/resources/imapserver.xml b/examples/custom-imap/src/main/resources/imapserver.xml
index 9f5ec0e98b9..3ffc60b05a6 100644
--- a/examples/custom-imap/src/main/resources/imapserver.xml
+++ b/examples/custom-imap/src/main/resources/imapserver.xml
@@ -36,6 +36,13 @@ under the License.
pong.response=customImapParameter
prop.b=anotherValue
false
+
+ PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory
+
+ secret-token
+ bob@domain.tld
+
+
org.apache.james.modules.protocols.DefaultImapPackage
org.apache.james.examples.imap.PingImapPackages
@@ -55,7 +62,14 @@ under the License.
pong.response=bad
prop.b=baad
false
+
+ PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory
+
+ secret-token
+ bob@domain.tld
+
+
org.apache.james.modules.protocols.DefaultImapPackage
org.apache.james.examples.imap.PingImapPackages
-
\ No newline at end of file
+
diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java
new file mode 100644
index 00000000000..41fb770b2ca
--- /dev/null
+++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java
@@ -0,0 +1,209 @@
+/****************************************************************
+ * 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.examples.imap;
+
+import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT;
+import static org.apache.james.jmap.JMAPTestingConstants.BOB;
+import static org.apache.james.jmap.JMAPTestingConstants.BOB_PASSWORD;
+import static org.apache.james.jmap.JMAPTestingConstants.DOMAIN;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.function.Predicate;
+
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.MemoryJamesConfiguration;
+import org.apache.james.MemoryJamesServerMain;
+import org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism;
+import org.apache.james.modules.protocols.ImapGuiceProbe;
+import org.apache.james.utils.DataProbeImpl;
+import org.apache.james.utils.TestIMAPClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class CustomSaslMechanismTest {
+ private static class ClientConnection implements AutoCloseable {
+ private final Socket socket;
+ private final BufferedReader reader;
+ private final BufferedWriter writer;
+
+ private ClientConnection(String host, int port) throws IOException {
+ socket = new Socket();
+ socket.connect(new InetSocketAddress(host, port));
+ socket.setSoTimeout(5_000);
+ reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
+ writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII));
+ }
+
+ private void writeLine(String line) throws IOException {
+ writer.write(line);
+ writer.write("\r\n");
+ writer.flush();
+ }
+
+ private String readUntil(Predicate condition) throws IOException {
+ StringBuilder response = new StringBuilder();
+ while (true) {
+ String line = reader.readLine();
+ if (line == null) {
+ throw new EOFException("Connection closed while waiting for IMAP response");
+ }
+ response.append(line).append("\n");
+ if (condition.test(line)) {
+ return response.toString();
+ }
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ socket.close();
+ }
+ }
+
+ private static final String EXPECTED_TOKEN = "secret-token";
+ private static final String LOCALHOST_IP = "127.0.0.1";
+
+ @RegisterExtension
+ static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir ->
+ MemoryJamesConfiguration.builder()
+ .workingDirectory(tmpDir)
+ .configurationFromClasspath()
+ .usersRepository(DEFAULT)
+ .build())
+ .server(MemoryJamesServerMain::createServer)
+ .build();
+
+ @BeforeEach
+ void setup(GuiceJamesServer server) throws Exception {
+ server.getProbe(DataProbeImpl.class).fluent()
+ .addDomain(DOMAIN)
+ .addUser(BOB.asString(), BOB_PASSWORD);
+ }
+
+ @Test
+ void imapServerShouldAdvertiseCustomSaslMechanism(GuiceJamesServer server) throws IOException {
+ assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server))
+ .sendCommand("CAPABILITY"))
+ .contains("AUTH=PLAIN", "AUTH=EXAMPLE-TOKEN");
+ }
+
+ @Test
+ void imapServerShouldAuthenticateCustomSaslMechanismUsingOwnConfiguration(GuiceJamesServer server) throws IOException {
+ TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server));
+
+ assertThat(client.sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode(EXPECTED_TOKEN)))
+ .contains("OK AUTHENTICATE completed.");
+ assertThat(client.sendCommand("PING"))
+ .contains("PONG");
+ }
+
+ @Test
+ void imapServerShouldAuthenticateCustomSaslMechanismUsingContinuation(GuiceJamesServer server) throws IOException {
+ try (ClientConnection client = clientConnection(server)) {
+ client.readUntil(line -> line.startsWith("* OK"));
+
+ client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN");
+ assertThat(client.readUntil(line -> line.startsWith("+")))
+ .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT));
+
+ client.writeLine(encode(EXPECTED_TOKEN));
+ String authenticationResponse = client.readUntil(line -> line.startsWith("A01"));
+ assertThat(authenticationResponse)
+ .contains("OK AUTHENTICATE completed.")
+ .doesNotContain("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT));
+
+ client.writeLine("A02 PING");
+ assertThat(client.readUntil(line -> line.startsWith("A02")))
+ .contains("PONG");
+ }
+ }
+
+ @Test
+ void imapServerShouldAuthenticateCustomSaslMechanismReturningServerDataOnSuccess(GuiceJamesServer server) throws IOException {
+ try (ClientConnection client = clientConnection(server)) {
+ // GIVEN a custom SASL exchange started without SASL-IR
+ client.readUntil(line -> line.startsWith("* OK"));
+ client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN");
+ assertThat(client.readUntil(line -> line.startsWith("+")))
+ .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT));
+
+ // WHEN the mechanism succeeds with final server data, as GSSAPI/Kerberos-like SASL mechanisms may require
+ client.writeLine(encode(EXPECTED_TOKEN + ExampleTokenSaslMechanism.SUCCESS_DATA_TOKEN_SUFFIX));
+ assertThat(client.readUntil(line -> line.startsWith("+") || line.startsWith("A01")))
+ .contains("+ " + encode(ExampleTokenSaslMechanism.SUCCESS_DATA));
+
+ // THEN the client acknowledges the final server data before IMAP completes authentication
+ client.writeLine("");
+ assertThat(client.readUntil(line -> line.startsWith("A01")))
+ .contains("OK AUTHENTICATE completed.");
+
+ // THEN the authenticated IMAP session remains usable
+ client.writeLine("A02 PING");
+ assertThat(client.readUntil(line -> line.startsWith("A02")))
+ .contains("PONG");
+ }
+ }
+
+ @Test
+ void plainSaslAuthenticationShouldStillWork(GuiceJamesServer server) throws IOException {
+ TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server));
+
+ assertThat(client.sendCommand("AUTHENTICATE PLAIN " + encodePlainInitialResponse()))
+ .contains("OK AUTHENTICATE completed.");
+ assertThat(client.sendCommand("PING"))
+ .contains("PONG");
+ }
+
+ @Test
+ void imapServerShouldRejectInvalidCustomSaslToken(GuiceJamesServer server) throws IOException {
+ assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server))
+ .sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode("invalid-token")))
+ .contains("NO AUTHENTICATE failed.");
+ }
+
+ private int imapPort(GuiceJamesServer server) {
+ return server.getProbe(ImapGuiceProbe.class).getImapPort();
+ }
+
+ private ClientConnection clientConnection(GuiceJamesServer server) throws IOException {
+ return new ClientConnection(LOCALHOST_IP, imapPort(server));
+ }
+
+ private String encode(String token) {
+ return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String encodePlainInitialResponse() {
+ return encode(BOB.asString() + "\0" + BOB.asString() + "\0" + BOB_PASSWORD);
+ }
+}
diff --git a/pom.xml b/pom.xml
index a8abd2fef04..f033892b0d6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2151,6 +2151,11 @@
protocols-pop3
${project.version}
+
+ ${james.protocols.groupId}
+ protocols-sasl
+ ${project.version}
+
${james.protocols.groupId}
protocols-smtp
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java
index 1623998c954..be557f1cac5 100644
--- a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java
@@ -58,41 +58,43 @@ public String getAssociatedUser() {
}
public static Optional parse(String initialResponse) {
- Optional decodeResult = decodeBase64(initialResponse);
+ return decodeBase64(initialResponse)
+ .flatMap(OIDCSASLParser::parseDecoded);
+ }
- if (decodeResult.isPresent()) {
- // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4.
- String decodeValueWithoutDanglingPart = decodeResult.filter(value -> value.startsWith("n,"))
- .map(value -> value.substring(2))
- .orElse(decodeResult.get());
+ public static Optional parseDecoded(String decodedInitialResponse) {
+ // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4.
+ String decodeValueWithoutDanglingPart = Optional.of(decodedInitialResponse)
+ .filter(value -> value.startsWith("n,"))
+ .map(value -> value.substring(2))
+ .orElse(decodedInitialResponse);
- StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR));
- String tokenPart = null;
- String userPart = null;
- int tokenPartCounter = 0;
- int userPartCounter = 0;
+ StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR));
+ String tokenPart = null;
+ String userPart = null;
+ int tokenPartCounter = 0;
+ int userPartCounter = 0;
- while (stringTokenizer.hasMoreTokens()) {
- String stringToken = stringTokenizer.nextToken();
- if (stringToken.startsWith(TOKEN_PART_PREFIX)) {
- tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, "");
- tokenPartCounter++;
- } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) {
- userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX);
- userPartCounter++;
- } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) {
- userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX);
- // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4.
- if (userPart.endsWith(",")) {
- userPart = userPart.substring(0, userPart.length() - 1);
- }
- userPartCounter++;
+ while (stringTokenizer.hasMoreTokens()) {
+ String stringToken = stringTokenizer.nextToken();
+ if (stringToken.startsWith(TOKEN_PART_PREFIX)) {
+ tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, "");
+ tokenPartCounter++;
+ } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) {
+ userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX);
+ userPartCounter++;
+ } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) {
+ userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX);
+ // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4.
+ if (userPart.endsWith(",")) {
+ userPart = userPart.substring(0, userPart.length() - 1);
}
+ userPartCounter++;
}
+ }
- if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) {
- return Optional.of(new OIDCInitialResponse(userPart, tokenPart));
- }
+ if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) {
+ return Optional.of(new OIDCInitialResponse(userPart, tokenPart));
}
return Optional.empty();
}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java
new file mode 100644
index 00000000000..f3c2c10a690
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java
@@ -0,0 +1,32 @@
+/****************************************************************
+ * 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;
+
+/**
+ * Result of protocol-neutral James authentication or authorization performed for a SASL mechanism.
+ */
+public sealed interface SaslAuthenticationResult permits SaslAuthenticationResult.Success, SaslAuthenticationResult.Failure {
+
+ record Success(SaslIdentity identity) implements SaslAuthenticationResult {
+ }
+
+ record Failure(SaslFailure failure) implements SaslAuthenticationResult {
+ }
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java
new file mode 100644
index 00000000000..18b21d78108
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java
@@ -0,0 +1,43 @@
+/****************************************************************
+ * 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.util.Optional;
+
+import org.apache.james.core.Username;
+
+/**
+ * Protocol-neutral authentication service available to SASL mechanisms.
+ */
+public interface SaslAuthenticator {
+ /**
+ * Verifies password credentials and, when present, validates that the authenticated user may act as the
+ * requested authorization identity.
+ */
+ SaslAuthenticationResult authenticatePassword(Username authenticationId,
+ Optional authorizationId,
+ String password);
+
+ /**
+ * Validates an already-authenticated identity, typically for token or Kerberos mechanisms that verified
+ * credentials inside the SASL exchange.
+ */
+ SaslAuthenticationResult authorize(SaslIdentity identity);
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java
new file mode 100644
index 00000000000..49921941d21
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java
@@ -0,0 +1,45 @@
+/****************************************************************
+ * 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;
+
+/**
+ * Stateful SASL authentication exchange.
+ */
+public interface SaslExchange extends AutoCloseable {
+ /**
+ * Starts the exchange and returns the first server step.
+ */
+ SaslStep firstStep();
+
+ /**
+ * Continues the exchange with a decoded client response.
+ */
+ SaslStep onResponse(byte[] clientResponse);
+
+ /**
+ * Aborts the exchange after a client cancellation or protocol-level failure, and releases associated resources.
+ */
+ default void abort() {
+ close();
+ }
+
+ @Override
+ void close();
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java
new file mode 100644
index 00000000000..385ef2140b6
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java
@@ -0,0 +1,66 @@
+/****************************************************************
+ * 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.util.Optional;
+
+import org.apache.james.core.Username;
+
+/**
+ * Protocol-neutral SASL failure with enough metadata for protocol-specific response mapping and audit logging.
+ */
+public record SaslFailure(Type type,
+ Optional authenticationId,
+ Optional authorizationId,
+ String reason,
+ Optional cause) {
+ public enum Type {
+ MALFORMED,
+ INVALID_CREDENTIALS,
+ AUTHENTICATION_FAILED,
+ USER_DOES_NOT_EXIST,
+ DELEGATION_FORBIDDEN,
+ SERVER_ERROR
+ }
+
+ public static SaslFailure malformed(String reason) {
+ return new SaslFailure(Type.MALFORMED, Optional.empty(), Optional.empty(), reason, Optional.empty());
+ }
+
+ public static SaslFailure invalidCredentials(Username authenticationId, Optional authorizationId, String reason) {
+ return new SaslFailure(Type.INVALID_CREDENTIALS, Optional.of(authenticationId), authorizationId, reason, Optional.empty());
+ }
+
+ public static SaslFailure authenticationFailed(Optional authenticationId, Optional authorizationId, String reason) {
+ return new SaslFailure(Type.AUTHENTICATION_FAILED, authenticationId, authorizationId, reason, Optional.empty());
+ }
+
+ public static SaslFailure userDoesNotExist(Username authenticationId, Username authorizationId, String reason) {
+ return new SaslFailure(Type.USER_DOES_NOT_EXIST, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty());
+ }
+
+ public static SaslFailure delegationForbidden(Username authenticationId, Username authorizationId, String reason) {
+ return new SaslFailure(Type.DELEGATION_FORBIDDEN, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty());
+ }
+
+ public static SaslFailure serverError(Optional authenticationId, Optional authorizationId, String reason, Throwable cause) {
+ return new SaslFailure(Type.SERVER_ERROR, authenticationId, authorizationId, reason, Optional.ofNullable(cause));
+ }
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java
new file mode 100644
index 00000000000..7038651fcd5
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java
@@ -0,0 +1,31 @@
+/****************************************************************
+ * 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 org.apache.james.core.Username;
+
+/**
+ * SASL authentication and authorization identities.
+ *
+ * @param authenticationId identity proven by the SASL mechanism
+ * @param authorizationId identity requested for protocol access
+ */
+public record SaslIdentity(Username authenticationId, Username authorizationId) {
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java
new file mode 100644
index 00000000000..4e245eebd18
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java
@@ -0,0 +1,42 @@
+/****************************************************************
+ * 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.util.Optional;
+
+/**
+ * Protocol-neutral initial SASL request.
+ *
+ * @param mechanismName requested SASL mechanism name
+ * @param initialResponse decoded initial client response, when supplied by the client
+ */
+public record SaslInitialRequest(String mechanismName, Optional initialResponse) {
+ public SaslInitialRequest(String mechanismName, Optional initialResponse) {
+ this.mechanismName = mechanismName;
+ this.initialResponse = initialResponse.map(byte[]::clone);
+ }
+
+ /**
+ * Returns a defensive copy of the decoded initial client response.
+ */
+ public Optional initialResponse() {
+ return initialResponse.map(byte[]::clone);
+ }
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java
new file mode 100644
index 00000000000..3be6de623bd
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java
@@ -0,0 +1,44 @@
+/****************************************************************
+ * 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;
+
+/**
+ * Protocol-neutral SASL mechanism.
+ */
+public interface SaslMechanism {
+ /**
+ * Returns the SASL mechanism name advertised to clients.
+ */
+ String name();
+
+ /**
+ * Starts a new SASL exchange for one client authentication attempt.
+ */
+ SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator);
+
+ /**
+ * Whether this mechanism may be used over the current transport.
+ *
+ * @param channelEncrypted whether the underlying transport is encrypted, for example with TLS.
+ */
+ default boolean isAvailableOnTransport(boolean channelEncrypted) {
+ return true;
+ }
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java
new file mode 100644
index 00000000000..77fb9e5e709
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java
@@ -0,0 +1,31 @@
+/****************************************************************
+ * 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 org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+
+/**
+ * Creates a SASL mechanism for one server configuration block.
+ */
+public interface SaslMechanismFactory {
+ SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException;
+}
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
new file mode 100644
index 00000000000..70e76d05039
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java
@@ -0,0 +1,29 @@
+/****************************************************************
+ * 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;
+
+public final class SaslMechanismNames {
+ public static final String PLAIN = "PLAIN";
+ public static final String OAUTHBEARER = "OAUTHBEARER";
+ public static final String XOAUTH2 = "XOAUTH2";
+
+ private SaslMechanismNames() {
+ }
+}
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java
new file mode 100644
index 00000000000..37608f30d5a
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java
@@ -0,0 +1,66 @@
+/****************************************************************
+ * 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.util.Optional;
+
+/**
+ * Server step produced by a SASL exchange.
+ */
+public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Success, SaslStep.Failure {
+ /**
+ * Server challenge to send back to the client.
+ */
+ record Challenge(Optional payload) implements SaslStep {
+ public Challenge(Optional payload) {
+ this.payload = payload.map(byte[]::clone);
+ }
+
+ /**
+ * Returns a defensive copy of the decoded challenge payload.
+ */
+ public Optional payload() {
+ return payload.map(byte[]::clone);
+ }
+ }
+
+ /**
+ * Successful SASL exchange result.
+ */
+ record Success(SaslIdentity identity, Optional serverData) implements SaslStep {
+ public Success(SaslIdentity identity, Optional serverData) {
+ this.identity = identity;
+ this.serverData = serverData.map(byte[]::clone);
+ }
+
+ /**
+ * Returns a defensive copy of the decoded final server data.
+ */
+ public Optional serverData() {
+ return serverData.map(byte[]::clone);
+ }
+ }
+
+ /**
+ * Failed SASL exchange result.
+ */
+ record Failure(SaslFailure failure) implements SaslStep {
+ }
+}
diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java
new file mode 100644
index 00000000000..2b4054284a2
--- /dev/null
+++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java
@@ -0,0 +1,249 @@
+/****************************************************************
+ * 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 java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Validates the shared SASL SPI shape with fake mechanisms before wiring real protocol mechanisms to it.
+ */
+class SaslMechanismContractTest {
+ private static final Username AUTHENTICATION_ID = Username.of("authentication@example.com");
+ private static final Username AUTHORIZATION_ID = Username.of("authorization@example.com");
+ private static final SaslIdentity SAME_USER_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID);
+ private static final SaslIdentity DELEGATED_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID);
+ private static final SaslAuthenticator NOOP_AUTHENTICATOR = new SaslAuthenticator() {
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) {
+ return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused"));
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Success(identity);
+ }
+ };
+
+ /**
+ * Models one-step mechanisms that can immediately succeed or fail on the first server step.
+ */
+ private static class FixedStepMechanism implements SaslMechanism {
+ private final SaslStep firstStep;
+
+ private FixedStepMechanism(SaslStep firstStep) {
+ this.firstStep = firstStep;
+ }
+
+ @Override
+ public String name() {
+ return "FIXED";
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return new FixedStepExchange(firstStep);
+ }
+ }
+
+ private static class FixedStepExchange implements SaslExchange {
+ private final SaslStep firstStep;
+ private boolean aborted;
+ private boolean closed;
+
+ private FixedStepExchange(SaslStep firstStep) {
+ this.firstStep = firstStep;
+ }
+
+ @Override
+ public SaslStep firstStep() {
+ return firstStep;
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ return firstStep;
+ }
+
+ @Override
+ public void abort() {
+ aborted = true;
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+
+ /**
+ * Models challenge/response mechanisms where state must survive between client lines.
+ */
+ private static class TwoStepMechanism implements SaslMechanism {
+ @Override
+ public String name() {
+ return "TWO_STEP";
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return new TwoStepExchange();
+ }
+ }
+
+ private static class TwoStepExchange implements SaslExchange {
+ private boolean challenged;
+
+ @Override
+ public SaslStep firstStep() {
+ challenged = true;
+ return new SaslStep.Challenge(Optional.of(bytes("continue")));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ if (!challenged) {
+ return new SaslStep.Failure(SaslFailure.malformed("response received before challenge"));
+ }
+ if (new String(clientResponse, StandardCharsets.UTF_8).equals("accepted")) {
+ return new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty());
+ }
+ return new SaslStep.Failure(SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected"));
+ }
+
+ @Override
+ public void abort() {
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ @Test
+ void oneStepMechanismShouldReturnSuccess() {
+ // GIVEN a one-step mechanism configured to immediately succeed
+ SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty());
+ SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR);
+
+ // WHEN the exchange starts
+ SaslStep firstStep = exchange.firstStep();
+
+ // THEN the mechanism can complete without a client continuation
+ assertThat(firstStep).isEqualTo(success);
+ }
+
+ @Test
+ void oneStepMechanismShouldReturnFailure() {
+ // GIVEN a one-step mechanism configured to immediately fail
+ SaslStep.Failure failure = new SaslStep.Failure(SaslFailure.malformed("failure"));
+ SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR);
+
+ // WHEN the exchange starts
+ SaslStep firstStep = exchange.firstStep();
+
+ // THEN the mechanism can fail without a client continuation
+ assertThat(firstStep).isEqualTo(failure);
+ }
+
+ @Test
+ void multiStepMechanismShouldKeepStateAcrossResponses() {
+ // GIVEN a mechanism that requires one challenge before accepting a response
+ SaslExchange exchange = new TwoStepMechanism().start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR);
+
+ // WHEN the server sends a challenge and later receives the expected client response
+ SaslStep firstStep = exchange.firstStep();
+ SaslStep success = exchange.onResponse(bytes("accepted"));
+
+ // THEN the exchange keeps enough state to complete after the continuation
+ assertThat(firstStep).isInstanceOf(SaslStep.Challenge.class);
+ assertThat(((SaslStep.Success) success).identity()).isEqualTo(SAME_USER_IDENTITY);
+ }
+
+ @Test
+ void successStepShouldPreserveDelegatedIdentity() {
+ // GIVEN a self-authenticating mechanism returning a delegated identity
+ SaslExchange exchange = new FixedStepMechanism(new SaslStep.Success(DELEGATED_IDENTITY, Optional.empty()))
+ .start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR);
+
+ // WHEN the exchange starts
+ SaslStep firstStep = exchange.firstStep();
+
+ // THEN the identity keeps both authentication and authorization users
+ assertThat(((SaslStep.Success) firstStep).identity()).isEqualTo(DELEGATED_IDENTITY);
+ }
+
+ @Test
+ void initialRequestShouldDefensivelyCopyInitialResponse() {
+ // GIVEN an initial response backed by a mutable byte array
+ byte[] initialResponse = bytes("initial");
+ SaslInitialRequest request = initialRequest(Optional.of(initialResponse));
+
+ // WHEN the caller mutates the original array
+ initialResponse[0] = 'I';
+
+ // THEN the request keeps the original payload
+ assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial")));
+ }
+
+ @Test
+ void saslStepsShouldDefensivelyCopyPayloads() {
+ // GIVEN challenge and success steps backed by mutable byte arrays
+ byte[] challengePayload = bytes("challenge");
+ byte[] serverData = bytes("server");
+ SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(challengePayload));
+ SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.of(serverData));
+
+ // WHEN the caller mutates the original arrays
+ challengePayload[0] = 'C';
+ serverData[0] = 'S';
+
+ // THEN the SASL steps keep their original payloads
+ assertThat(challenge.payload()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("challenge")));
+ assertThat(success.serverData()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("server")));
+ }
+
+ @Test
+ void exchangeShouldExposeAbortAndCloseLifecycle() {
+ // GIVEN an active exchange
+ FixedStepExchange exchange = new FixedStepExchange(new SaslStep.Failure(SaslFailure.malformed("failure")));
+
+ // WHEN the protocol aborts and then closes it
+ exchange.abort();
+ exchange.close();
+
+ // THEN mechanisms can observe both lifecycle events
+ assertThat(exchange.aborted).isTrue();
+ assertThat(exchange.closed).isTrue();
+ }
+
+ private static SaslInitialRequest initialRequest(Optional initialResponse) {
+ return new SaslInitialRequest("TEST", initialResponse);
+ }
+
+ private static byte[] bytes(String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+}
diff --git a/protocols/imap/pom.xml b/protocols/imap/pom.xml
index 1d4d6642d07..e1ec4819fb4 100644
--- a/protocols/imap/pom.xml
+++ b/protocols/imap/pom.xml
@@ -91,6 +91,10 @@
${james.protocols.groupId}
protocols-api
+
+ ${james.protocols.groupId}
+ protocols-sasl
+
com.beetstra.jutf7
jutf7
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
index ebdce2fdbaf..791e8ce69a5 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
@@ -31,7 +31,6 @@
import org.apache.commons.text.RandomStringGenerator;
import org.apache.james.core.Username;
import org.apache.james.imap.api.ImapSessionState;
-import org.apache.james.jwt.OidcSASLConfiguration;
import org.apache.james.mailbox.MailboxSession;
import org.apache.james.protocols.api.CommandDetectionSession;
import org.apache.james.util.MDCBuilder;
@@ -247,19 +246,6 @@ default boolean backpressureNeeded(Runnable restoreBackpressure) {
*/
void popLineHandler();
- /**
- * Return true if SSL is required when Authenticating
- */
- boolean isSSLRequired();
-
- /**
- * Return true if the login / authentication via plain username / password is
- * enabled
- */
- boolean isPlainAuthEnabled();
-
- boolean supportsOAuth();
-
default void withMDC(Runnable runnable) {
try (Closeable c = mdc().build()) {
runnable.run();
@@ -280,8 +266,6 @@ default MDCBuilder mdc() {
*/
InetSocketAddress getRemoteAddress();
- Optional oidcSaslConfiguration();
-
default void setMailboxSession(MailboxSession mailboxSession) {
setAttribute(MAILBOX_SESSION_ATTRIBUTE_SESSION_KEY, mailboxSession);
}
@@ -296,14 +280,6 @@ default Username getUserName() {
.orElse(null);
}
- default boolean isPlainAuthDisallowed() {
- return !isPlainAuthEnabled() || isAuthenticatingNonEncryptedWhenRequiredSSL();
- }
-
- default boolean isAuthenticatingNonEncryptedWhenRequiredSSL() {
- return isSSLRequired() && !isTLSActive();
- }
-
void schedule(Runnable runnable, Duration waitDelay);
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java
index a4dab679c8b..3e77db72e68 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java
@@ -30,6 +30,10 @@ public Class acceptableMessages() {
@Override
public void encode(AuthenticateResponse message, ImapResponseComposer composer) throws IOException {
- composer.continuationResponse();
+ if (message.getResponse().filter(response -> !response.isEmpty()).isPresent()) {
+ composer.continuationResponse(message.getResponse().get());
+ } else {
+ composer.continuationResponse();
+ }
}
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
index d73af074e1b..cd7ad586589 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
@@ -34,7 +34,6 @@
import org.apache.james.imap.api.process.ImapLineHandler;
import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.api.process.SelectedMailbox;
-import org.apache.james.jwt.OidcSASLConfiguration;
import org.apache.james.util.concurrent.NamedThreadFactory;
import reactor.core.publisher.Mono;
@@ -166,31 +165,11 @@ public void popLineHandler() {
}
- @Override
- public boolean isSSLRequired() {
- return false;
- }
-
- @Override
- public boolean isPlainAuthEnabled() {
- return true;
- }
-
- @Override
- public boolean supportsOAuth() {
- return false;
- }
-
@Override
public InetSocketAddress getRemoteAddress() {
return null;
}
- @Override
- public Optional oidcSaslConfiguration() {
- return Optional.empty();
- }
-
@Override
public boolean isTLSActive() {
return false;
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java
index 345fc9e5f4d..dcf4355e345 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java
@@ -19,8 +19,22 @@
package org.apache.james.imap.message.response;
+import java.util.Optional;
+
import org.apache.james.imap.api.message.response.ImapResponseMessage;
-public class AuthenticateResponse implements ImapResponseMessage{
+public class AuthenticateResponse implements ImapResponseMessage {
+ private final Optional response;
+
+ public AuthenticateResponse() {
+ this.response = Optional.empty();
+ }
+
+ public AuthenticateResponse(String response) {
+ this.response = Optional.of(response);
+ }
+ public Optional getResponse() {
+ return response;
+ }
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
index 8786a6ec941..cad4359952f 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
@@ -27,8 +27,6 @@
import org.apache.james.imap.api.message.response.StatusResponseFactory;
import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.main.PathConverter;
-import org.apache.james.jwt.OidcJwtTokenVerifier;
-import org.apache.james.jwt.OidcSASLConfiguration;
import org.apache.james.mailbox.Authorizator;
import org.apache.james.mailbox.DefaultMailboxes;
import org.apache.james.mailbox.MailboxManager;
@@ -41,12 +39,13 @@
import org.apache.james.mailbox.model.MailboxConstants;
import org.apache.james.mailbox.model.MailboxPath;
import org.apache.james.metrics.api.MetricFactory;
-import org.apache.james.protocols.api.OIDCSASLParser;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.api.sasl.SaslStep;
import org.apache.james.util.AuditTrail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import reactor.core.publisher.Mono;
@@ -80,54 +79,6 @@ public void configure(ImapConfiguration imapConfiguration) {
this.imapConfiguration = imapConfiguration;
}
- protected void doPasswordAuth(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) {
- Preconditions.checkArgument(!authenticationAttempt.isDelegation());
-
- if (authenticationAttempt.getAuthenticationId() == null || authenticationAttempt.getPassword() == null) {
- authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(),
- "Malformed authentication command."
- );
- } else {
- try {
- final MailboxSession mailboxSession = getMailboxManager().authenticate(
- authenticationAttempt.getAuthenticationId(),
- authenticationAttempt.getPassword()
- ).withoutDelegation();
- authSuccess(session, mailboxSession, request, responder, "Password authentication succeeded.");
- } catch (BadCredentialsException e) {
- authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS,
- Optional.of(authenticationAttempt.getAuthenticationId()),
- Optional.empty(),
- "Password authentication failed because of bad credentials."
- );
- } catch (MailboxException e) {
- // This is probably not a user error, so we do not increase the failure count or add the
- // event to the audit log.
- LOGGER.error("Authentication failed", e);
- no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING);
- }
- }
- }
-
- protected void doPasswordAuthWithDelegation(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) {
- Preconditions.checkArgument(authenticationAttempt.isDelegation());
- Username otherUser = authenticationAttempt.getDelegateUserName().orElseThrow();
-
- Username givenUser = authenticationAttempt.getAuthenticationId();
- if (givenUser == null) {
- authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED,
- Optional.empty(), Optional.of(otherUser), "Malformed authentication command.");
- } else {
- doAuthWithDelegation(() -> getMailboxManager()
- .withExtraAuthorizator(withAdminUsers())
- .authenticate(givenUser, authenticationAttempt.getPassword())
- .as(otherUser),
- session,
- request, responder,
- givenUser, otherUser);
- }
- }
-
protected Authorizator withAdminUsers() {
return (userId, otherUserId) -> {
if (imapConfiguration.getAdminUsers().contains(userId.asString())) {
@@ -140,8 +91,14 @@ protected Authorizator withAdminUsers() {
protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier,
ImapSession session, ImapRequest request, Responder responder,
Username authenticateUser, Username delegatorUser) {
+ doAuth(mailboxSessionSupplier, session, request, responder, authenticateUser, delegatorUser, "Authentication with delegation succeeded.");
+ }
+
+ protected void doAuth(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier,
+ ImapSession session, ImapRequest request, Responder responder,
+ Username authenticateUser, Username delegatorUser, String successLog) {
try {
- authSuccess(session, mailboxSessionSupplier.get(), request, responder, "Authentication with delegation succeeded.");
+ authSuccess(session, mailboxSessionSupplier.get(), request, responder, successLog);
} catch (BadCredentialsException e) {
authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, Optional.of(authenticateUser),
Optional.of(delegatorUser), "Password authentication with delegation failed because of bad credentials.");
@@ -150,37 +107,58 @@ protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mai
Optional.of(delegatorUser), "Delegation target user does not exist.");
} catch (ForbiddenDelegationException e) {
authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN, Optional.of(authenticateUser),
- Optional.of(delegatorUser), "Requested delegation is forbidden.");
+ Optional.of(delegatorUser), "Requested delegation is forbidden.");
} catch (MailboxException e) {
- // This is probably not a user error, so we do not increase the failure count or add the
- // event to the audit log.
LOGGER.info("Authentication failed", e);
no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING);
}
}
- protected void doOAuth(OIDCSASLParser.OIDCInitialResponse oidcInitialResponse, OidcSASLConfiguration oidcSASLConfiguration,
- ImapSession session, ImapRequest request, Responder responder) {
- new OidcJwtTokenVerifier(oidcSASLConfiguration).validateToken(oidcInitialResponse.getToken())
- .ifPresentOrElse(authenticatedUser -> {
- Username associatedUser = Username.of(oidcInitialResponse.getAssociatedUser());
- if (!associatedUser.equals(authenticatedUser)) {
- doAuthWithDelegation(() -> getMailboxManager()
- .withExtraAuthorizator(withAdminUsers())
- .authenticate(authenticatedUser)
- .as(associatedUser),
- session, request, responder, authenticatedUser, associatedUser);
- } else {
- authSuccess(session, getMailboxManager().createSystemSession(authenticatedUser), request, responder,
- "OAuth authentication succeeded."
- );
- }
- }, () -> {
- authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(),
- Optional.of(Username.of(oidcInitialResponse.getAssociatedUser())),
- "OAuth authentication failed."
- );
- });
+ protected void handleSaslStep(SaslStep step, ImapSession session, ImapRequest request, Responder responder, String successLog) {
+ switch (step) {
+ case SaslStep.Success success -> handleSaslSuccess(success, session, request, responder, successLog);
+ case SaslStep.Failure failure -> handleSaslFailure(failure.failure(), session, request, responder);
+ case SaslStep.Challenge ignored -> throw new IllegalStateException("Challenge SASL step cannot be applied as authentication result");
+ }
+ }
+
+ protected void handleSaslSuccess(SaslStep.Success success, ImapSession session, ImapRequest request, Responder responder, String successLog) {
+ SaslIdentity identity = success.identity();
+ if (!identity.authenticationId().equals(identity.authorizationId())) {
+ doAuthWithDelegation(() -> getMailboxManager()
+ .withExtraAuthorizator(withAdminUsers())
+ .authenticate(identity.authenticationId())
+ .as(identity.authorizationId()),
+ session, request, responder, identity.authenticationId(), identity.authorizationId());
+ return;
+ }
+
+ doAuth(() -> getMailboxManager()
+ .authenticate(identity.authenticationId())
+ .withoutDelegation(),
+ session, request, responder, identity.authenticationId(), identity.authorizationId(), successLog);
+ }
+
+ protected void handleSaslFailure(SaslFailure failure, ImapSession session, ImapRequest request, Responder responder) {
+ switch (failure.type()) {
+ case MALFORMED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED,
+ failure.authenticationId(), failure.authorizationId(), failure.reason());
+ case AUTHENTICATION_FAILED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED,
+ failure.authenticationId(), failure.authorizationId(), failure.reason());
+ case INVALID_CREDENTIALS -> authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS,
+ failure.authenticationId(), failure.authorizationId(), failure.reason());
+ case USER_DOES_NOT_EXIST -> authFailure(session, request, responder, HumanReadableText.USER_DOES_NOT_EXIST,
+ failure.authenticationId(), failure.authorizationId(), failure.reason());
+ case DELEGATION_FORBIDDEN -> authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN,
+ failure.authenticationId(), failure.authorizationId(), failure.reason());
+ case SERVER_ERROR -> {
+ failure.cause()
+ .ifPresentOrElse(
+ cause -> LOGGER.error("Authentication failed: {}", failure.reason(), cause),
+ () -> LOGGER.error("Authentication failed: {}", failure.reason()));
+ no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING);
+ }
+ }
}
protected void provisionInbox(ImapSession session, MailboxManager mailboxManager, MailboxSession mailboxSession) throws MailboxException {
@@ -233,15 +211,7 @@ protected void manageFailureCount(ImapSession session, ImapRequest request, Resp
}
}
- protected static AuthenticationAttempt delegation(Username authorizeId, Username authenticationId, String password) {
- return new AuthenticationAttempt(Optional.of(authorizeId), authenticationId, password);
- }
-
- protected static AuthenticationAttempt noDelegation(Username authenticationId, String password) {
- return new AuthenticationAttempt(Optional.empty(), authenticationId, password);
- }
-
- protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String log) {
+ protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String successLog) {
session.authenticated();
session.setMailboxSession(mailboxSession);
try {
@@ -260,12 +230,13 @@ protected void authSuccess(ImapSession session, MailboxSession mailboxSession, I
if (assumedUser.isPresent()) {
entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString()));
}
- entry.log(log);
+ entry.log(successLog);
okComplete(request, responder);
session.stopDetectingCommandInjection();
}
- protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username, Optional assumedUser, String log) {
+ protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username,
+ Optional assumedUser, String failureReason) {
AuditTrail.Entry entry = AuditTrail.entry()
.username(() -> username.map(name -> name.asString()).orElse(null))
.sessionId(() -> session.sessionId().asString())
@@ -275,35 +246,7 @@ protected void authFailure(ImapSession session, ImapRequest request, Responder r
if (assumedUser.isPresent()) {
entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString()));
}
- entry.log(log);
+ entry.log(failureReason);
manageFailureCount(session, request, responder, failed);
}
-
- protected static class AuthenticationAttempt {
- private final Optional delegateUserName;
- private final Username authenticationId;
- private final String password;
-
- public AuthenticationAttempt(Optional delegateUserName, Username authenticationId, String password) {
- this.delegateUserName = delegateUserName;
- this.authenticationId = authenticationId;
- this.password = password;
- }
-
- public boolean isDelegation() {
- return delegateUserName.isPresent() && !delegateUserName.get().equals(authenticationId);
- }
-
- public Optional getDelegateUserName() {
- return delegateUserName;
- }
-
- public Username getAuthenticationId() {
- return authenticationId;
- }
-
- public String getPassword() {
- return password;
- }
- }
}
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 cfae43c6f37..8a9d87fbd30 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
@@ -19,36 +19,36 @@
package org.apache.james.imap.processor;
-import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Base64;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
-import java.util.stream.Collectors;
import jakarta.inject.Inject;
-import org.apache.commons.lang3.tuple.Pair;
-import org.apache.james.core.Username;
import org.apache.james.imap.api.display.HumanReadableText;
import org.apache.james.imap.api.message.Capability;
-import org.apache.james.imap.api.message.request.ImapRequest;
import org.apache.james.imap.api.message.response.StatusResponseFactory;
import org.apache.james.imap.api.process.ImapSession;
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.OIDCSASLParser;
+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;
+import org.apache.james.protocols.sasl.JamesSaslAuthenticator;
import org.apache.james.util.MDCBuilder;
import org.apache.james.util.ReactorUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import reactor.core.publisher.Mono;
@@ -59,17 +59,21 @@
public class AuthenticateProcessor extends AbstractAuthProcessor implements CapabilityImplementingProcessor {
public static final String AUTH_PLAIN = "AUTH=PLAIN";
public static final Capability AUTH_PLAIN_CAPABILITY = Capability.of(AUTH_PLAIN);
- private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class);
- private static final String AUTH_TYPE_PLAIN = "PLAIN";
- private static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER";
- private static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2";
- private static final List OAUTH_CAPABILITIES = ImmutableList.of(Capability.of("AUTH=" + AUTH_TYPE_OAUTHBEARER), Capability.of("AUTH=" + AUTH_TYPE_XOAUTH2));
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 saslMechanisms;
@Inject
public AuthenticateProcessor(MailboxManager mailboxManager, StatusResponseFactory factory,
- MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) {
+ 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();
}
@Override
@@ -79,127 +83,40 @@ public List> acceptableClasses() {
@Override
protected void processRequest(AuthenticateRequest request, ImapSession session, final Responder responder) {
- final String authType = request.getAuthType();
-
- if (authType.equalsIgnoreCase(AUTH_TYPE_PLAIN)) {
- // See if AUTH=PLAIN is allowed. See IMAP-304
- if (session.isPlainAuthDisallowed()) {
- LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel");
- no(request, responder, HumanReadableText.DISABLED_LOGIN);
- } else {
- if (request instanceof IRAuthenticateRequest) {
- IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request;
- parseAndDoPlainAuth(irRequest.getInitialClientResponse(), session, request, responder);
- } else {
- session.executeSafely(() -> {
- responder.respond(new AuthenticateResponse());
- responder.flush();
- session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> {
- parseAndDoPlainAuth(extractInitialClientResponse(data), requestSession, request, responder);
- // remove the handler now
- requestSession.popLineHandler();
- responder.flush();
- }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then());
- });
- }
- }
- } else if (authType.equalsIgnoreCase(AUTH_TYPE_OAUTHBEARER) || authType.equalsIgnoreCase(AUTH_TYPE_XOAUTH2)) {
- if (!session.supportsOAuth()) {
- LOGGER.warn("OAuth authentication rejected because it is disabled");
- no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM);
- } else {
- if (request instanceof IRAuthenticateRequest) {
- IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request;
- parseAndDoOAuth(irRequest.getInitialClientResponse(), session, request, responder);
- } else {
- session.executeSafely(() -> {
- responder.respond(new AuthenticateResponse());
- responder.flush();
- session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> {
- parseAndDoOAuth(extractInitialClientResponse(data), requestSession, request, responder);
- requestSession.popLineHandler();
- responder.flush();
- }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then());
- });
- }
- }
- } else {
- LOGGER.debug("Unsupported authentication mechanism '{}'", authType);
+ Optional mechanism = findMechanism(request.getAuthType());
+
+ if (mechanism.isEmpty()) {
+ LOGGER.debug("Unsupported authentication mechanism '{}'", request.getAuthType());
no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM);
+ return;
}
- }
- /**
- * Parse the initial client response and start plain authentication.
- */
- protected void parseAndDoPlainAuth(String initialClientResponse, ImapSession session, ImapRequest request, Responder responder) {
- AuthenticationAttempt authenticationAttempt = parseDelegationAttempt(initialClientResponse);
- if (authenticationAttempt.isDelegation()) {
- doPasswordAuthWithDelegation(authenticationAttempt, session, request, responder);
- } else {
- doPasswordAuth(authenticationAttempt, session, request, responder);
+ if (!isAvailable(mechanism.get(), session)) {
+ rejectUnavailable(request, responder, mechanism.get());
+ return;
}
- }
-
- /**
- * Parse the initial client response and start oauth authentication.
- */
- protected void parseAndDoOAuth(String initialResponse, ImapSession session, ImapRequest request, Responder responder) {
- OIDCSASLParser.parse(initialResponse)
- .flatMap(oidcInitialResponseValue -> session.oidcSaslConfiguration().map(configure -> Pair.of(oidcInitialResponseValue, configure)))
- .ifPresentOrElse(pair -> doOAuth(pair.getLeft(), pair.getRight(), session, request, responder),
- () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(),
- Optional.empty(), "Malformed authentication command."));
- }
- private AuthenticationAttempt parseDelegationAttempt(String initialClientResponse) {
try {
- String userpass = new String(Base64.getDecoder().decode(initialClientResponse));
- List tokens = Arrays.stream(userpass.split("\0"))
- .filter(token -> !token.isBlank())
- .collect(Collectors.toList());
- Preconditions.checkArgument(tokens.size() == 2 || tokens.size() == 3);
- if (tokens.size() == 2) {
- // If we got here, this is what happened. RFC 2595
- // says that "the client may leave the authorization
- // identity empty to indicate that it is the same as
- // the authentication identity." As noted above,
- // that would be represented as a decoded string of
- // the form: "\0authenticate-id\0password". The
- // first call to nextToken will skip the empty
- // authorize-id, and give us the authenticate-id,
- // which we would store as the authorize-id. The
- // second call will give us the password, which we
- // think is the authenticate-id (user). Then when
- // we ask for the password, there are no more
- // elements, leading to the exception we just
- // caught. So we need to move the user to the
- // password, and the authorize_id to the user.
- return noDelegation(Username.of(tokens.get(0)), tokens.get(1));
- } else {
- return delegation(Username.of(tokens.get(0)), Username.of(tokens.get(1)), tokens.get(2));
- }
- } catch (Exception e) {
- // Ignored - this exception in parsing will be dealt
- // with in the if clause below
+ SaslInitialRequest initialRequest = saslBridge.initialRequest(request.getAuthType(), initialClientResponse(request));
+ SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers());
+ SaslExchange exchange = mechanism.get().start(initialRequest, authenticator);
+ handleFirstStep(exchange, firstStep(exchange), session, request, responder);
+ } catch (IllegalArgumentException e) {
LOGGER.info("Invalid syntax in AUTHENTICATE initial client response", e);
- return noDelegation(null, null);
+ authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(),
+ Optional.empty(), "Malformed authentication command.");
}
}
@Override
public List getImplementedCapabilities(ImapSession session) {
- List caps = new ArrayList<>();
- // Only ounce AUTH=PLAIN if the session does allow plain auth or TLS is active.
- // See IMAP-304
- if (!session.isPlainAuthDisallowed()) {
- caps.add(AUTH_PLAIN_CAPABILITY);
- }
+ List caps = saslMechanisms.stream()
+ .filter(mechanism -> isAvailable(mechanism, session))
+ .map(mechanism -> Capability.of("AUTH=" + mechanism.name()))
+ .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
+
// Support for SASL-IR. See RFC4959
caps.add(SASL_CAPABILITY);
- if (session.supportsOAuth()) {
- caps.addAll(OAUTH_CAPABILITIES);
- }
return ImmutableList.copyOf(caps);
}
@@ -210,9 +127,235 @@ protected MDCBuilder mdc(AuthenticateRequest request) {
.addToContext("authType", request.getAuthType());
}
- private static String extractInitialClientResponse(byte[] data) {
- // cut of the CRLF
- return new String(data, 0, data.length - 2, StandardCharsets.US_ASCII);
+ public void configureSaslMechanisms(ImmutableList saslMechanisms) {
+ this.saslMechanisms = saslMechanisms;
+ }
+
+ private Optional initialClientResponse(AuthenticateRequest request) {
+ if (request instanceof IRAuthenticateRequest irAuthenticateRequest) {
+ return Optional.of(irAuthenticateRequest.getInitialClientResponse());
+ }
+ return Optional.empty();
+ }
+
+ private SaslStep firstStep(SaslExchange exchange) {
+ try {
+ return exchange.firstStep();
+ } catch (RuntimeException e) {
+ saslBridge.close(exchange);
+ throw e;
+ }
+ }
+
+ private Optional findMechanism(String mechanismName) {
+ String normalizedName = mechanismName.toUpperCase(Locale.US);
+ return saslMechanisms.stream()
+ .filter(mechanism -> mechanism.name().toUpperCase(Locale.US).equals(normalizedName))
+ .findFirst();
+ }
+
+ private boolean isAvailable(SaslMechanism mechanism, ImapSession session) {
+ return mechanism.isAvailableOnTransport(session.isTLSActive());
+ }
+
+ private void rejectUnavailable(AuthenticateRequest request, Responder responder, SaslMechanism mechanism) {
+ LOGGER.warn("{} authentication rejected because it is not allowed over current transport", mechanism.name());
+ if (SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) {
+ no(request, responder, HumanReadableText.DISABLED_LOGIN);
+ return;
+ }
+ no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM);
+ }
+
+ private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) {
+ if (step instanceof SaslStep.Challenge challenge) {
+ handleInitialChallenge(exchange, challenge, session, request, responder);
+ return;
+ }
+ if (step instanceof SaslStep.Success success && success.serverData().isPresent()) {
+ handleSuccessWithServerData(exchange, success, session, request, responder);
+ return;
+ }
+ handleTerminalStep(exchange, step, session, request, responder);
+ }
+
+ private void handleInitialChallenge(SaslExchange exchange, SaslStep.Challenge challenge,
+ ImapSession session, AuthenticateRequest request, Responder responder) {
+ pushContinuationHandlerAndRespond(exchange, challenge, session, request, responder);
+ }
+
+ private void pushContinuationHandlerAndRespond(SaslExchange exchange, SaslStep.Challenge challenge,
+ ImapSession session, AuthenticateRequest request, Responder responder) {
+ pushContinuationHandler(exchange, session, request, responder);
+ respondActiveContinuation(exchange, session, () ->
+ responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))));
+ }
+
+ private void pushContinuationHandler(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder) {
+ try {
+ session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleContinuationLine(exchange, requestSession, request, responder, data))
+ .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
+ .then());
+ } catch (RuntimeException e) {
+ saslBridge.close(exchange);
+ throw e;
+ }
+ }
+
+ private void handleContinuationLine(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) {
+ if (isAbort(exchange, session, data)) {
+ abortActiveContinuation(exchange, session);
+ no(request, responder, HumanReadableText.AUTHENTICATION_FAILED);
+ responder.flush();
+ return;
+ }
+
+ nextStep(exchange, session, request, responder, data)
+ .ifPresent(step -> handleContinuationStep(exchange, step, session, request, responder));
+ }
+
+ private Optional nextStep(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) {
+ try {
+ return Optional.of(saslBridge.onClientResponse(exchange, data));
+ } catch (IllegalArgumentException e) {
+ LOGGER.info("Invalid syntax in AUTHENTICATE client response", e);
+ closeActiveContinuation(exchange, session);
+ authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(),
+ Optional.empty(), "Malformed authentication command.");
+ responder.flush();
+ return Optional.empty();
+ } catch (RuntimeException e) {
+ closeActiveContinuation(exchange, session);
+ throw e;
+ }
}
+ 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.flush();
+ } catch (RuntimeException e) {
+ closeActiveContinuation(exchange, session);
+ throw e;
+ }
+ return;
+ }
+
+ popActiveContinuation(exchange, session);
+ if (step instanceof SaslStep.Success success && success.serverData().isPresent()) {
+ handleSuccessWithServerData(exchange, success, session, request, responder);
+ return;
+ }
+
+ handleTerminalStep(exchange, step, session, request, responder);
+ responder.flush();
+ }
+
+ private void respondActiveContinuation(SaslExchange exchange, ImapSession session, Runnable runnable) {
+ try {
+ runnable.run();
+ } catch (RuntimeException e) {
+ closeActiveContinuation(exchange, session);
+ throw e;
+ }
+ }
+
+ private boolean isAbort(SaslExchange exchange, ImapSession session, byte[] data) {
+ try {
+ return saslBridge.isAbort(data);
+ } catch (RuntimeException e) {
+ closeActiveContinuation(exchange, session);
+ throw e;
+ }
+ }
+
+ private boolean isEmptyClientResponse(SaslExchange exchange, ImapSession session, byte[] data) {
+ try {
+ return saslBridge.isEmptyClientResponse(data);
+ } catch (RuntimeException e) {
+ closeActiveContinuation(exchange, session);
+ throw e;
+ }
+ }
+
+ private void closeActiveContinuation(SaslExchange exchange, ImapSession session) {
+ try {
+ session.popLineHandler();
+ } finally {
+ saslBridge.close(exchange);
+ }
+ }
+
+ private void abortActiveContinuation(SaslExchange exchange, ImapSession session) {
+ try {
+ session.popLineHandler();
+ } finally {
+ saslBridge.abort(exchange);
+ }
+ }
+
+ private void popActiveContinuation(SaslExchange exchange, ImapSession session) {
+ try {
+ session.popLineHandler();
+ } catch (RuntimeException e) {
+ saslBridge.close(exchange);
+ throw e;
+ }
+ }
+
+ private void handleSuccessWithServerData(SaslExchange exchange, SaslStep.Success success, ImapSession session,
+ AuthenticateRequest request, Responder responder) {
+ pushSuccessDataAcknowledgementHandler(exchange, success, session, request, responder);
+ respondActiveContinuation(exchange, session, () -> {
+ responder.respond(new AuthenticateResponse(saslBridge.successData(success)));
+ responder.flush();
+ });
+ }
+
+ private void pushSuccessDataAcknowledgementHandler(SaslExchange exchange, SaslStep.Success success, ImapSession session,
+ AuthenticateRequest request, Responder responder) {
+ try {
+ session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleSuccessDataAcknowledgement(exchange, success, requestSession, request, responder, data))
+ .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
+ .then());
+ } catch (RuntimeException e) {
+ saslBridge.close(exchange);
+ throw e;
+ }
+ }
+
+ private void handleSuccessDataAcknowledgement(SaslExchange exchange, SaslStep.Success success, ImapSession session,
+ AuthenticateRequest request, Responder responder, byte[] data) {
+ if (isAbort(exchange, session, data)) {
+ abortActiveContinuation(exchange, session);
+ no(request, responder, HumanReadableText.AUTHENTICATION_FAILED);
+ responder.flush();
+ return;
+ }
+ if (!isEmptyClientResponse(exchange, session, data)) {
+ closeActiveContinuation(exchange, session);
+ authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(),
+ Optional.empty(), "Malformed authentication command.");
+ responder.flush();
+ return;
+ }
+
+ popActiveContinuation(exchange, session);
+ handleTerminalStep(exchange, success, session, request, responder);
+ responder.flush();
+ }
+
+ private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) {
+ try {
+ handleSaslStep(step, session, request, responder, successLog(request));
+ } finally {
+ saslBridge.close(exchange);
+ }
+ }
+
+ private String successLog(AuthenticateRequest request) {
+ String authType = request.getAuthType().toUpperCase(Locale.US);
+ return authType + " authentication succeeded.";
+ }
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java
index 794954c85d9..be56964b79c 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java
@@ -19,6 +19,8 @@
package org.apache.james.imap.processor;
+import static org.apache.james.protocols.sasl.JamesSaslAuthenticator.jamesSaslAuthenticator;
+
import java.util.Map;
import java.util.stream.Stream;
@@ -40,6 +42,9 @@
import org.apache.james.mailbox.quota.QuotaManager;
import org.apache.james.mailbox.quota.QuotaRootResolver;
import org.apache.james.metrics.api.MetricFactory;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.sasl.JamesSaslAuthenticator;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -59,16 +64,56 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess
MailboxCounterCorrector mailboxCounterCorrector,
MetricFactory metricFactory,
FetchProcessor.LocalCacheConfiguration localCacheConfiguration) {
+ return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager,
+ statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector,
+ metricFactory, localCacheConfiguration, ImmutableList.of(new PlainSaslMechanism()));
+ }
+
+ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor,
+ MailboxManager mailboxManager,
+ EventBus eventBus,
+ SubscriptionManager subscriptionManager,
+ StatusResponseFactory statusResponseFactory,
+ MailboxTyper mailboxTyper,
+ QuotaManager quotaManager,
+ QuotaRootResolver quotaRootResolver,
+ MailboxCounterCorrector mailboxCounterCorrector,
+ MetricFactory metricFactory,
+ FetchProcessor.LocalCacheConfiguration localCacheConfiguration,
+ ImmutableList defaultSaslMechanisms) {
+ return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager,
+ statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector,
+ metricFactory, localCacheConfiguration, defaultSaslMechanisms, jamesSaslAuthenticator(mailboxManager));
+ }
+
+ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor,
+ MailboxManager mailboxManager,
+ EventBus eventBus,
+ SubscriptionManager subscriptionManager,
+ StatusResponseFactory statusResponseFactory,
+ MailboxTyper mailboxTyper,
+ QuotaManager quotaManager,
+ QuotaRootResolver quotaRootResolver,
+ MailboxCounterCorrector mailboxCounterCorrector,
+ MetricFactory metricFactory,
+ FetchProcessor.LocalCacheConfiguration localCacheConfiguration,
+ ImmutableList defaultSaslMechanisms,
+ JamesSaslAuthenticator saslAuthenticator) {
PathConverter.Factory pathConverterFactory = PathConverter.Factory.DEFAULT;
ImmutableList.Builder builder = ImmutableList.builder();
CapabilityProcessor capabilityProcessor = new CapabilityProcessor(mailboxManager, statusResponseFactory, metricFactory);
+ LoginProcessor loginProcessor = new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator);
+ loginProcessor.configureSaslMechanisms(defaultSaslMechanisms);
+ AuthenticateProcessor authenticateProcessor = new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator);
+ authenticateProcessor.configureSaslMechanisms(defaultSaslMechanisms);
+
builder.add(new SystemMessageProcessor());
builder.add(new LogoutProcessor(mailboxManager, statusResponseFactory, metricFactory));
builder.add(capabilityProcessor);
builder.add(new IdProcessor(mailboxManager, statusResponseFactory, metricFactory));
builder.add(new CheckProcessor(mailboxManager, statusResponseFactory, metricFactory));
- builder.add(new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
+ builder.add(loginProcessor);
builder.add(new RenameProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
builder.add(new DeleteProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
builder.add(new CreateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
@@ -76,7 +121,7 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess
builder.add(new UnsubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory));
builder.add(new SubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory));
builder.add(new CopyProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
- builder.add(new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
+ builder.add(authenticateProcessor);
builder.add(new ExpungeProcessor(mailboxManager, statusResponseFactory, metricFactory));
builder.add(new ReplaceProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory));
builder.add(new ExamineProcessor(mailboxManager, eventBus, statusResponseFactory, metricFactory, pathConverterFactory, mailboxCounterCorrector));
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java
index 551bac70cf2..b3318e9b891 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java
@@ -52,15 +52,15 @@
public class EnableProcessor extends AbstractMailboxProcessor implements CapabilityImplementingProcessor {
private static final Logger LOGGER = LoggerFactory.getLogger(EnableProcessor.class);
- private static final List capabilities = new ArrayList<>();
public static final String ENABLED_CAPABILITIES = "ENABLED_CAPABILITIES";
private static final List CAPS = ImmutableList.of(SUPPORTS_ENABLE);
- private final CapabilityProcessor capabilityProcessor;
+ private final List capabilities = new ArrayList<>();
+ private CapabilityProcessor capabilityProcessor;
public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, List capabilities,
MetricFactory metricFactory, CapabilityProcessor capabilityProcessor) {
this(mailboxManager, factory, metricFactory, capabilityProcessor);
- EnableProcessor.capabilities.addAll(capabilities);
+ this.capabilities.addAll(capabilities);
}
@Inject
@@ -70,6 +70,12 @@ public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory fact
this.capabilityProcessor = capabilityProcessor;
}
+ /**
+ * Use the capability processor from the same IMAP suite.
+ */
+ public void configureCapabilityProcessor(CapabilityProcessor capabilityProcessor) {
+ this.capabilityProcessor = capabilityProcessor;
+ }
@Override
protected Mono processRequestReactive(EnableRequest request, ImapSession session, Responder responder) {
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java
index c7c2a845b33..2f2ba5d168d 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java
@@ -21,6 +21,7 @@
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import jakarta.inject.Inject;
@@ -32,6 +33,11 @@
import org.apache.james.imap.message.request.LoginRequest;
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.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismNames;
+import org.apache.james.protocols.sasl.JamesSaslAuthenticator;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
import org.apache.james.util.MDCBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -45,9 +51,15 @@ public class LoginProcessor extends AbstractAuthProcessor implemen
private static final List LOGINDISABLED_CAPS = ImmutableList.of(Capability.of("LOGINDISABLED"));
private static final Logger LOGGER = LoggerFactory.getLogger(LoginProcessor.class);
+ private final JamesSaslAuthenticator jamesSaslAuthenticator;
+ private Optional plainSaslMechanism;
+
@Inject
- public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) {
+ public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory,
+ JamesSaslAuthenticator jamesSaslAuthenticator) {
super(LoginRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory);
+ this.jamesSaslAuthenticator = jamesSaslAuthenticator;
+ this.plainSaslMechanism = Optional.empty();
}
/**
@@ -55,12 +67,16 @@ public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory facto
*/
@Override
protected void processRequest(LoginRequest request, ImapSession session, Responder responder) {
+ Optional plainSaslMechanism = availablePlainSaslMechanism(session);
+
// check if the login is allowed with LOGIN command. See IMAP-304
- if (session.isPlainAuthDisallowed()) {
- LOGGER.warn("Login rejected because it is disabled or not allowed over insecure channel");
+ if (plainSaslMechanism.isEmpty()) {
+ LOGGER.warn("Login rejected because PLAIN SASL mechanism is disabled");
no(request, responder, HumanReadableText.DISABLED_LOGIN);
} else {
- doPasswordAuth(noDelegation(request.getUserid(), request.getPassword()), session, request, responder);
+ SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers());
+ handleSaslStep(plainSaslMechanism.orElseThrow().authenticate(request.getUserid(), request.getPassword(), authenticator),
+ session, request, responder, "Password authentication succeeded.");
}
}
@@ -68,12 +84,25 @@ protected void processRequest(LoginRequest request, ImapSession session, Respond
public List getImplementedCapabilities(ImapSession session) {
// Announce LOGINDISABLED if plain auth / login is deactivated and the session is not using
// TLS. See IMAP-304
- if (session.isPlainAuthDisallowed()) {
+ if (availablePlainSaslMechanism(session).isEmpty()) {
return LOGINDISABLED_CAPS;
}
return Collections.emptyList();
}
+ public void configureSaslMechanisms(ImmutableList saslMechanisms) {
+ this.plainSaslMechanism = saslMechanisms.stream()
+ .filter(mechanism -> SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name()))
+ .filter(PlainSaslMechanism.class::isInstance)
+ .map(PlainSaslMechanism.class::cast)
+ .findFirst();
+ }
+
+ private Optional availablePlainSaslMechanism(ImapSession session) {
+ return plainSaslMechanism
+ .filter(mechanism -> mechanism.isAvailableOnTransport(session.isTLSActive()));
+ }
+
@Override
protected MDCBuilder mdc(LoginRequest request) {
return MDCBuilder.create()
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java
index 31415b99cf8..6edd5724db2 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java
@@ -19,6 +19,9 @@
package org.apache.james.imap.processor.main;
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
import org.apache.james.events.EventBus;
import org.apache.james.imap.api.message.response.StatusResponseFactory;
import org.apache.james.imap.api.process.DefaultMailboxTyper;
@@ -34,6 +37,17 @@
import org.apache.james.mailbox.quota.QuotaManager;
import org.apache.james.mailbox.quota.QuotaRootResolver;
import org.apache.james.metrics.api.MetricFactory;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories;
+import org.apache.james.protocols.sasl.JamesSaslAuthenticator;
+import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory;
+import org.apache.james.protocols.sasl.PlainSaslMechanismFactory;
+import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
+
+import com.github.fge.lambdas.Throwing;
+import com.google.common.collect.ImmutableList;
public class DefaultImapProcessorFactory {
@@ -59,6 +73,7 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo
}
public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager,
+ JamesSaslAuthenticator saslAuthenticator,
EventBus eventBus, SubscriptionManager subscriptionManager,
MailboxTyper mailboxTyper, QuotaManager quotaManager,
QuotaRootResolver quotaRootResolver, MetricFactory metricFactory) {
@@ -69,7 +84,60 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo
return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager,
eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver,
MailboxCounterCorrector.DEFAULT, metricFactory,
- FetchProcessor.LocalCacheConfiguration.DEFAULT);
+ FetchProcessor.LocalCacheConfiguration.DEFAULT, ImmutableList.of(new PlainSaslMechanism()), saslAuthenticator);
+ }
+
+ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager,
+ EventBus eventBus, SubscriptionManager subscriptionManager,
+ MailboxTyper mailboxTyper, QuotaManager quotaManager,
+ QuotaRootResolver quotaRootResolver, MetricFactory metricFactory,
+ FetchProcessor.LocalCacheConfiguration localCacheConfiguration,
+ HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+
+ StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory();
+ UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory);
+
+ return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager,
+ eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver,
+ MailboxCounterCorrector.DEFAULT, metricFactory,
+ localCacheConfiguration, defaultSaslMechanisms(serverConfiguration));
+ }
+
+ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager,
+ EventBus eventBus, SubscriptionManager subscriptionManager,
+ MailboxTyper mailboxTyper, QuotaManager quotaManager,
+ QuotaRootResolver quotaRootResolver, MetricFactory metricFactory,
+ FetchProcessor.LocalCacheConfiguration localCacheConfiguration,
+ HierarchicalConfiguration serverConfiguration,
+ JamesSaslAuthenticator saslAuthenticator) throws ConfigurationException {
+
+ StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory();
+ UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory);
+
+ return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager,
+ eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver,
+ MailboxCounterCorrector.DEFAULT, metricFactory,
+ localCacheConfiguration, defaultSaslMechanisms(serverConfiguration), saslAuthenticator);
+ }
+
+ private static ImmutableList defaultSaslMechanisms(HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ ImmutableList factories = BuiltInSaslMechanismFactories.enabledForServer(
+ ImmutableList.of(
+ new PlainSaslMechanismFactory(),
+ new OauthBearerSaslMechanismFactory(),
+ new XOauth2SaslMechanismFactory()),
+ serverConfiguration);
+
+ try {
+ return factories.stream()
+ .map(Throwing.function(factory -> factory.create(serverConfiguration)))
+ .collect(ImmutableList.toImmutableList());
+ } catch (RuntimeException e) {
+ if (e.getCause() instanceof ConfigurationException configurationException) {
+ throw configurationException;
+ }
+ throw e;
+ }
}
}
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
new file mode 100644
index 00000000000..7e5a8217fe9
--- /dev/null
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java
@@ -0,0 +1,104 @@
+/****************************************************************
+ * 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 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/AuthenticateProcessorTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java
new file mode 100644
index 00000000000..1d66db34375
--- /dev/null
+++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java
@@ -0,0 +1,277 @@
+/****************************************************************
+ * 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;
+
+import static org.apache.james.imap.ImapFixture.TAG;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.apache.james.imap.api.message.response.ImapResponseMessage;
+import org.apache.james.imap.api.process.ImapLineHandler;
+import org.apache.james.imap.api.process.ImapProcessor;
+import org.apache.james.imap.encode.FakeImapSession;
+import org.apache.james.imap.main.PathConverter;
+import org.apache.james.imap.message.request.AuthenticateRequest;
+import org.apache.james.imap.message.response.UnpooledStatusResponseFactory;
+import org.apache.james.mailbox.Authenticator;
+import org.apache.james.mailbox.Authorizator;
+import org.apache.james.mailbox.MailboxManager;
+import org.apache.james.metrics.tests.RecordingMetricFactory;
+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.SaslStep;
+import org.apache.james.protocols.sasl.JamesSaslAuthenticator;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+
+import reactor.core.publisher.Mono;
+
+class AuthenticateProcessorTest {
+ private static final String BROKEN_MECHANISM = "BROKEN";
+ private static final Username USER = Username.of("user@example.com");
+ private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
+
+ private static class TestSaslMechanism implements SaslMechanism {
+ private final SaslExchange exchange;
+
+ private TestSaslMechanism(SaslExchange exchange) {
+ this.exchange = exchange;
+ }
+
+ @Override
+ public String name() {
+ return BROKEN_MECHANISM;
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return exchange;
+ }
+ }
+
+ private static class RecordingLineHandlerImapSession extends FakeImapSession {
+ private ImapLineHandler lineHandler;
+ private boolean throwOnPop;
+ private int popCount;
+
+ @Override
+ public void pushLineHandler(ImapLineHandler lineHandler) {
+ this.lineHandler = lineHandler;
+ }
+
+ @Override
+ public void popLineHandler() {
+ popCount++;
+ if (throwOnPop) {
+ throw new IllegalStateException("boom");
+ }
+ lineHandler = null;
+ }
+ }
+
+ private static class ThrowingResponder implements ImapProcessor.Responder {
+ @Override
+ public void respond(ImapResponseMessage message) {
+ throw new IllegalStateException("boom");
+ }
+
+ @Override
+ public void flush() {
+ }
+ }
+
+ private static class ThrowingFirstStepExchange implements SaslExchange {
+ private boolean closed;
+
+ @Override
+ public SaslStep firstStep() {
+ throw new IllegalArgumentException("boom");
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void abort() {
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+
+ private static class ThrowingContinuationExchange implements SaslExchange {
+ private boolean closed;
+
+ @Override
+ public SaslStep firstStep() {
+ return new SaslStep.Challenge(Optional.of(bytes("challenge")));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ throw new IllegalStateException("boom");
+ }
+
+ @Override
+ public void abort() {
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+
+ private static class SuccessDataExchange implements SaslExchange {
+ private boolean closed;
+
+ @Override
+ public SaslStep firstStep() {
+ return new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data")));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void abort() {
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+
+ private final AuthenticateProcessor testee = new AuthenticateProcessor(
+ mock(MailboxManager.class),
+ new UnpooledStatusResponseFactory(),
+ new RecordingMetricFactory(),
+ PathConverter.Factory.DEFAULT,
+ new JamesSaslAuthenticator(mock(Authenticator.class), mock(Authorizator.class)));
+
+ @Test
+ void processRequestShouldCloseExchangeWhenFirstStepThrows() {
+ // GIVEN a mechanism whose exchange fails while computing the first SASL step
+ ThrowingFirstStepExchange exchange = new ThrowingFirstStepExchange();
+ testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange)));
+
+ // WHEN the processor handles the malformed exchange
+ testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), mock(ImapProcessor.Responder.class));
+
+ // THEN it closes the exchange even though the terminal step handling was not reached
+ assertThat(exchange.closed).isTrue();
+ }
+
+ @Test
+ void processRequestShouldCloseExchangeWhenInitialChallengeWriteThrows() {
+ // GIVEN a first challenge that fails while being written to the client
+ ThrowingContinuationExchange exchange = new ThrowingContinuationExchange();
+ testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange)));
+
+ // WHEN the processor handles the initial challenge
+ assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder()))
+ .isInstanceOf(IllegalStateException.class);
+
+ // THEN it closes the exchange even though no continuation handler was installed
+ assertThat(exchange.closed).isTrue();
+ }
+
+ @Test
+ void processRequestShouldCloseExchangeWhenInitialSuccessDataWriteThrows() {
+ // GIVEN a first successful SASL step with final server data that fails while being written to the client
+ SuccessDataExchange exchange = new SuccessDataExchange();
+ testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange)));
+
+ // WHEN the processor handles the initial success data
+ assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder()))
+ .isInstanceOf(IllegalStateException.class);
+
+ // THEN it closes the exchange because terminal handling did not own it yet
+ assertThat(exchange.closed).isTrue();
+ }
+
+ @Test
+ void continuationShouldCloseExchangeWhenOnResponseThrows() {
+ // GIVEN an active continuation whose mechanism fails unexpectedly while processing the client response
+ ThrowingContinuationExchange exchange = new ThrowingContinuationExchange();
+ RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession();
+ testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange)));
+ testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class));
+ ImapLineHandler lineHandler = session.lineHandler;
+ assertThat(lineHandler).isNotNull();
+
+ // WHEN the continuation line is processed
+ assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, imapLine("response"))).block())
+ .isInstanceOf(IllegalStateException.class);
+
+ // THEN the active line handler is removed and the exchange is closed before rethrowing
+ assertThat(session.popCount).isEqualTo(1);
+ assertThat(exchange.closed).isTrue();
+ }
+
+ @Test
+ void successDataAcknowledgementShouldCloseExchangeWhenPopLineHandlerThrows() {
+ // GIVEN an active final server-data acknowledgement handler
+ SuccessDataExchange exchange = new SuccessDataExchange();
+ RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession();
+ testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange)));
+ testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class));
+ ImapLineHandler lineHandler = session.lineHandler;
+ assertThat(lineHandler).isNotNull();
+ session.throwOnPop = true;
+
+ // WHEN the acknowledgement line reaches a failing line-handler cleanup
+ assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, emptyLine())).block())
+ .isInstanceOf(IllegalStateException.class);
+
+ // THEN the exchange is still closed before the failure is rethrown
+ assertThat(session.popCount).isEqualTo(1);
+ assertThat(exchange.closed).isTrue();
+ }
+
+ private static byte[] imapLine(String value) {
+ return (Base64.getEncoder().encodeToString(bytes(value)) + "\r\n").getBytes(StandardCharsets.US_ASCII);
+ }
+
+ private static byte[] emptyLine() {
+ return "\r\n".getBytes(StandardCharsets.US_ASCII);
+ }
+
+ private static byte[] bytes(String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+}
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
new file mode 100644
index 00000000000..63053ee11c3
--- /dev/null
+++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java
@@ -0,0 +1,240 @@
+/****************************************************************
+ * 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("user@example.com");
+ private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
+
+ private final ImapSaslBridge testee = new ImapSaslBridge();
+
+ 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();
+ }
+ }
+
+ private static class ThrowingAbortExchange implements SaslExchange {
+ private final List 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/pom.xml b/protocols/pom.xml
index e3745907832..a643b050a31 100644
--- a/protocols/pom.xml
+++ b/protocols/pom.xml
@@ -42,6 +42,7 @@
managesieve
netty
pop3
+ sasl
smtp
diff --git a/protocols/sasl/pom.xml b/protocols/sasl/pom.xml
new file mode 100644
index 00000000000..ddb94437af0
--- /dev/null
+++ b/protocols/sasl/pom.xml
@@ -0,0 +1,66 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.james.protocols
+ protocols
+ 3.10.0-SNAPSHOT
+ ../pom.xml
+
+
+ protocols-sasl
+ jar
+
+ Apache James :: Protocols :: SASL implementations
+
+
+
+ ${james.groupId}
+ apache-james-mailbox-api
+
+
+ ${james.groupId}
+ james-server-jwt
+
+
+ ${james.groupId}
+ testing-base
+ test
+
+
+ ${james.protocols.groupId}
+ protocols-api
+
+
+ com.google.guava
+ guava
+
+
+ jakarta.inject
+ jakarta.inject-api
+
+
+ org.apache.commons
+ commons-configuration2
+
+
+
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java
new file mode 100644
index 00000000000..859895f0304
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java
@@ -0,0 +1,46 @@
+/****************************************************************
+ * 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.sasl;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+
+import com.google.common.collect.ImmutableList;
+
+public final class BuiltInSaslMechanismFactories {
+ public static ImmutableList enabledForServer(ImmutableList defaultFactories,
+ HierarchicalConfiguration serverConfiguration) {
+ return defaultFactories.stream()
+ .filter(factory -> isEnabledByDefault(factory, serverConfiguration))
+ .collect(ImmutableList.toImmutableList());
+ }
+
+ private static boolean isEnabledByDefault(SaslMechanismFactory factory,
+ HierarchicalConfiguration serverConfiguration) {
+ if (factory instanceof OidcSaslMechanismFactory) {
+ return !serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty();
+ }
+ return true;
+ }
+
+ private BuiltInSaslMechanismFactories() {
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java
new file mode 100644
index 00000000000..b253153bde9
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java
@@ -0,0 +1,118 @@
+/****************************************************************
+ * 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.sasl;
+
+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.Authorizator;
+import org.apache.james.mailbox.MailboxManager;
+import org.apache.james.mailbox.exception.BadCredentialsException;
+import org.apache.james.mailbox.exception.ForbiddenDelegationException;
+import org.apache.james.mailbox.exception.MailboxException;
+import org.apache.james.mailbox.exception.UserDoesNotExistException;
+import org.apache.james.protocols.api.sasl.SaslAuthenticationResult;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+
+public class JamesSaslAuthenticator implements SaslAuthenticator {
+ public static JamesSaslAuthenticator jamesSaslAuthenticator(MailboxManager mailboxManager) {
+ Authenticator authenticator = (username, password) -> {
+ try {
+ return Optional.of(mailboxManager.authenticate(username, password.toString()).withoutDelegation().getUser());
+ } catch (BadCredentialsException e) {
+ return Optional.empty();
+ }
+ };
+ Authorizator authorizator = (username, otherUsername) -> {
+ try {
+ mailboxManager.authenticate(username).as(otherUsername);
+ return Authorizator.AuthorizationState.ALLOWED;
+ } catch (UserDoesNotExistException e) {
+ return Authorizator.AuthorizationState.UNKNOWN_USER;
+ } catch (ForbiddenDelegationException | BadCredentialsException e) {
+ return Authorizator.AuthorizationState.FORBIDDEN;
+ }
+ };
+ return new JamesSaslAuthenticator(authenticator, authorizator);
+ }
+
+ private final Authenticator authenticator;
+ private final Authorizator authorizator;
+
+ @Inject
+ public JamesSaslAuthenticator(Authenticator authenticator, Authorizator authorizator) {
+ this.authenticator = authenticator;
+ this.authorizator = authorizator;
+ }
+
+ public JamesSaslAuthenticator withExtraAuthorizator(Authorizator extraAuthorizator) {
+ return new JamesSaslAuthenticator(authenticator, Authorizator.combine(authorizator, extraAuthorizator));
+ }
+
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId,
+ Optional authorizationId,
+ String password) {
+ try {
+ Optional authenticatedUser = authenticator.isAuthentic(authenticationId, password);
+ if (authenticatedUser.isEmpty()) {
+ return failure(SaslFailure.invalidCredentials(authenticationId, authorizationId,
+ "Password authentication failed because of bad credentials."));
+ }
+ Username targetUser = authorizationId.orElse(authenticatedUser.get());
+ return authorize(new SaslIdentity(authenticatedUser.get(), targetUser));
+ } catch (MailboxException e) {
+ return failure(SaslFailure.serverError(Optional.of(authenticationId), authorizationId, "Authentication failed.", e));
+ }
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ if (identity.authenticationId().equals(identity.authorizationId())) {
+ return success(identity);
+ }
+
+ try {
+ return switch (authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId())) {
+ case ALLOWED -> success(identity);
+ case UNKNOWN_USER -> failure(SaslFailure.userDoesNotExist(identity.authenticationId(), identity.authorizationId(),
+ "Delegation target user does not exist."));
+ case FORBIDDEN -> failure(SaslFailure.delegationForbidden(identity.authenticationId(), identity.authorizationId(),
+ "Requested delegation is forbidden."));
+ };
+ } catch (MailboxException e) {
+ return failure(SaslFailure.serverError(Optional.of(identity.authenticationId()), Optional.of(identity.authorizationId()),
+ "Authentication failed.", e));
+ }
+ }
+
+ private SaslAuthenticationResult success(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Success(identity);
+ }
+
+ private SaslAuthenticationResult failure(SaslFailure failure) {
+ return new SaslAuthenticationResult.Failure(failure);
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java
new file mode 100644
index 00000000000..49de4f2e404
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java
@@ -0,0 +1,34 @@
+/****************************************************************
+ * 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.sasl;
+
+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.SaslMechanismNames;
+import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism;
+
+public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, parseVerifier(serverConfiguration));
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java
new file mode 100644
index 00000000000..3cf55ed170b
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java
@@ -0,0 +1,43 @@
+/****************************************************************
+ * 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.sasl;
+
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.jwt.OidcJwtTokenVerifier;
+import org.apache.james.jwt.OidcSASLConfiguration;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+
+abstract class OidcSaslMechanismFactory implements SaslMechanismFactory {
+ protected OidcJwtTokenVerifier parseVerifier(HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ if (serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty()) {
+ throw new ConfigurationException("OAuth SASL mechanisms require an auth.oidc configuration");
+ }
+ try {
+ return new OidcJwtTokenVerifier(OidcSASLConfiguration.parse(serverConfiguration.configurationAt("auth.oidc")));
+ } catch (MalformedURLException | URISyntaxException | NullPointerException e) {
+ throw new ConfigurationException("Failed to retrieve oauth component", e);
+ }
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java
new file mode 100644
index 00000000000..9a3537a772f
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java
@@ -0,0 +1,45 @@
+/****************************************************************
+ * 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.sasl;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+import org.apache.james.protocols.sasl.plain.PlainSaslMechanism;
+
+public class PlainSaslMechanismFactory implements SaslMechanismFactory {
+ private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true;
+ private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true;
+
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) {
+ return new PlainSaslMechanism(plainAuthEnabled(serverConfiguration), requiresSsl(serverConfiguration));
+ }
+
+ protected boolean plainAuthEnabled(HierarchicalConfiguration serverConfiguration) {
+ return serverConfiguration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT);
+ }
+
+ protected boolean requiresSsl(HierarchicalConfiguration serverConfiguration) {
+ return serverConfiguration.getBoolean("auth.requireSSL",
+ serverConfiguration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT));
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java
new file mode 100644
index 00000000000..9f9c895b7f3
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java
@@ -0,0 +1,34 @@
+/****************************************************************
+ * 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.sasl;
+
+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.SaslMechanismNames;
+import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism;
+
+public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, parseVerifier(serverConfiguration));
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java
new file mode 100644
index 00000000000..cd2106d1f01
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java
@@ -0,0 +1,105 @@
+/****************************************************************
+ * 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.sasl.oidc;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.apache.james.jwt.OidcJwtTokenVerifier;
+import org.apache.james.protocols.api.OIDCSASLParser;
+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.SaslFailure;
+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.SaslStep;
+
+/**
+ * OIDC bearer-token SASL mechanism. OAUTHBEARER and XOAUTH2 share the same exchange and only differ by
+ * their advertised name, so a single implementation is parameterized with the mechanism name.
+ */
+public class OAuthSaslMechanism implements SaslMechanism {
+ private final String name;
+ private final OidcJwtTokenVerifier verifier;
+
+ public OAuthSaslMechanism(String name, OidcJwtTokenVerifier verifier) {
+ this.name = name;
+ this.verifier = verifier;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return new OAuthSaslExchange(request.initialResponse(), authenticator);
+ }
+
+ private class OAuthSaslExchange implements SaslExchange {
+ private final Optional initialResponse;
+ private final SaslAuthenticator authenticator;
+
+ private OAuthSaslExchange(Optional initialResponse, SaslAuthenticator authenticator) {
+ this.initialResponse = initialResponse;
+ this.authenticator = authenticator;
+ }
+
+ @Override
+ public SaslStep firstStep() {
+ return initialResponse
+ .map(this::authenticate)
+ .orElseGet(() -> new SaslStep.Challenge(Optional.empty()));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ return authenticate(clientResponse);
+ }
+
+ @Override
+ public void close() {
+ }
+
+ private SaslStep authenticate(byte[] clientResponse) {
+ return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII))
+ .map(response -> {
+ Username authorizationId = Username.of(response.getAssociatedUser());
+ return verifier.validateToken(response.getToken())
+ .map(authenticationId -> authorize(authenticationId, authorizationId))
+ .orElseGet(() -> new SaslStep.Failure(SaslFailure.authenticationFailed(
+ Optional.empty(), Optional.of(authorizationId), "OAuth authentication failed.")));
+ })
+ .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command.")));
+ }
+
+ private SaslStep authorize(Username authenticationId, Username authorizationId) {
+ SaslAuthenticationResult result = authenticator.authorize(new SaslIdentity(authenticationId, authorizationId));
+ return switch (result) {
+ case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty());
+ case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure());
+ };
+ }
+ }
+}
diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java
new file mode 100644
index 00000000000..96aa8212c12
--- /dev/null
+++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java
@@ -0,0 +1,144 @@
+/****************************************************************
+ * 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.sasl.plain;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.function.Function;
+
+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.SaslFailure;
+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 com.google.common.collect.ImmutableList;
+
+public class PlainSaslMechanism implements SaslMechanism {
+ public static final String NAME = SaslMechanismNames.PLAIN;
+
+ protected record PlainCredentials(Optional authorizationId, Username authenticationId, String password) {
+ }
+
+ protected static PlainCredentials credentials(Optional authorizationId, Username authenticationId, String password) {
+ return new PlainCredentials(authorizationId, authenticationId, password);
+ }
+
+ private final boolean enabled;
+ private final boolean requiresSsl;
+
+ public PlainSaslMechanism() {
+ this(true, false);
+ }
+
+ public PlainSaslMechanism(boolean enabled, boolean requiresSsl) {
+ this.enabled = enabled;
+ this.requiresSsl = requiresSsl;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public boolean isAvailableOnTransport(boolean channelEncrypted) {
+ return enabled && (!requiresSsl || channelEncrypted);
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return new PlainSaslExchange(request.initialResponse(), this::parse, authenticator);
+ }
+
+ /**
+ * Verifies cleartext credentials directly for protocols whose command already exposes username/password,
+ * for example IMAP LOGIN.
+ */
+ public SaslStep authenticate(Username authenticationId, String password, SaslAuthenticator authenticator) {
+ return verify(credentials(Optional.empty(), authenticationId, password), authenticator);
+ }
+
+ private static class PlainSaslExchange implements SaslExchange {
+ private final Optional initialResponse;
+ private final Function> credentialsParser;
+ private final SaslAuthenticator authenticator;
+
+ private PlainSaslExchange(Optional initialResponse,
+ Function> credentialsParser,
+ SaslAuthenticator authenticator) {
+ this.initialResponse = initialResponse;
+ this.credentialsParser = credentialsParser;
+ this.authenticator = authenticator;
+ }
+
+ @Override
+ public SaslStep firstStep() {
+ return initialResponse
+ .map(this::authenticate)
+ .orElseGet(() -> new SaslStep.Challenge(Optional.empty()));
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ return authenticate(clientResponse);
+ }
+
+ @Override
+ public void close() {
+ }
+
+ private SaslStep authenticate(byte[] clientResponse) {
+ return credentialsParser.apply(clientResponse)
+ .map(credentials -> verify(credentials, authenticator))
+ .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command.")));
+ }
+ }
+
+ protected static SaslStep verify(PlainCredentials credentials, SaslAuthenticator authenticator) {
+ SaslAuthenticationResult result = authenticator.authenticatePassword(
+ credentials.authenticationId(), credentials.authorizationId(), credentials.password());
+ return switch (result) {
+ case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty());
+ case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure());
+ };
+ }
+
+ protected Optional parse(byte[] clientResponse) {
+ ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1))
+ .collect(ImmutableList.toImmutableList());
+
+ if (tokens.size() == 2) {
+ return Optional.of(credentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1)));
+ }
+ if (tokens.size() == 3) {
+ Optional authorizationId = Optional.of(tokens.get(0))
+ .filter(value -> !value.isEmpty())
+ .map(Username::of);
+ return Optional.of(credentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2)));
+ }
+ return Optional.empty();
+ }
+}
diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java
new file mode 100644
index 00000000000..ddc1eeae5f3
--- /dev/null
+++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java
@@ -0,0 +1,175 @@
+/****************************************************************
+ * 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.sasl.oidc;
+
+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.jwt.OidcJwtTokenVerifier;
+import org.apache.james.protocols.api.sasl.SaslAuthenticationResult;
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslMechanismNames;
+import org.apache.james.protocols.api.sasl.SaslStep;
+import org.junit.jupiter.api.Test;
+
+class OidcSaslMechanismTest {
+ private static final Username USER = Username.of("user@example.com");
+ private static final Username TOKEN_SUBJECT = Username.of("token-subject@example.com");
+ private static final String TOKEN = "token";
+
+ @Test
+ void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() {
+ // GIVEN a decoded OAUTHBEARER initial response
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER,
+ Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001")));
+
+ // WHEN the mechanism consumes and validates the response
+ SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep();
+
+ // THEN it returns the authorized identity directly to the protocol driver
+ assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty()));
+ }
+
+ @Test
+ void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() {
+ // GIVEN a decoded XOAUTH2 initial response
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.XOAUTH2,
+ Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001")));
+
+ // WHEN the mechanism consumes and validates the response
+ SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, verifyingToken()).start(request, authorizing()).firstStep();
+
+ // THEN it exposes the same authorized identity shape
+ assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty()));
+ }
+
+ @Test
+ void shouldChallengeWhenNoInitialResponse() {
+ // GIVEN an OIDC SASL exchange without SASL-IR
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.empty());
+
+ // WHEN the mechanism starts
+ SaslStep firstStep = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep();
+
+ // THEN the server asks for one client response
+ assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty()));
+ }
+
+ @Test
+ void shouldFailMalformedResponse() {
+ // GIVEN a malformed OIDC SASL response
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER,
+ Optional.of(bytes("invalid")));
+
+ // WHEN the mechanism consumes the response
+ SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep();
+
+ // THEN it fails before any token validation side effect
+ assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command.")));
+ }
+
+ @Test
+ void shouldFailWhenTokenIsRejected() {
+ // GIVEN an OIDC SASL response with an invalid bearer token
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER,
+ Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001")));
+
+ // WHEN token validation rejects the token
+ SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, rejectingToken()).start(request, authorizing()).firstStep();
+
+ // THEN the mechanism returns a typed authentication failure
+ assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.authenticationFailed(
+ Optional.empty(), Optional.of(USER), "OAuth authentication failed.")));
+ }
+
+ @Test
+ void shouldReturnAuthorizationFailure() {
+ // GIVEN a valid token but a James authorization rule rejecting the requested identity
+ SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER,
+ Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001")));
+ SaslFailure failure = SaslFailure.delegationForbidden(TOKEN_SUBJECT, USER, "forbidden");
+
+ // WHEN authorization rejects the identity
+ SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep();
+
+ // THEN the failure is returned to the protocol driver
+ assertThat(step).isEqualTo(new SaslStep.Failure(failure));
+ }
+
+ private static OidcJwtTokenVerifier verifyingToken() {
+ return new TestOidcJwtTokenVerifier(Optional.of(TOKEN_SUBJECT));
+ }
+
+ private static OidcJwtTokenVerifier rejectingToken() {
+ return new TestOidcJwtTokenVerifier(Optional.empty());
+ }
+
+ private static SaslAuthenticator authorizing() {
+ return new SaslAuthenticator() {
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) {
+ return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused"));
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Success(identity);
+ }
+ };
+ }
+
+ private static SaslAuthenticator rejectingAuthorization(SaslFailure failure) {
+ return new SaslAuthenticator() {
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) {
+ return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused"));
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Failure(failure);
+ }
+ };
+ }
+
+ private static byte[] bytes(String value) {
+ return value.getBytes(StandardCharsets.US_ASCII);
+ }
+
+ private static class TestOidcJwtTokenVerifier extends OidcJwtTokenVerifier {
+ private final Optional validateTokenResult;
+
+ private TestOidcJwtTokenVerifier(Optional validateTokenResult) {
+ super(null);
+ this.validateTokenResult = validateTokenResult;
+ }
+
+ @Override
+ public Optional validateToken(String token) {
+ return validateTokenResult;
+ }
+ }
+}
diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java
new file mode 100644
index 00000000000..066c47168b3
--- /dev/null
+++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java
@@ -0,0 +1,194 @@
+/****************************************************************
+ * 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.sasl.plain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.SaslFailure;
+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 PlainSaslMechanismTest {
+ private static final Username AUTHENTICATION_ID = Username.of("user@example.com");
+ private static final Username AUTHORIZATION_ID = Username.of("delegated@example.com");
+ private static final String PASSWORD = "secret";
+
+ private final PlainSaslMechanism testee = new PlainSaslMechanism();
+
+ @Test
+ void shouldBeAvailableOnClearTransportByDefault() {
+ assertThat(testee.isAvailableOnTransport(false)).isTrue();
+ }
+
+ @Test
+ void shouldNotBeAvailableOnClearTransportWhenSslIsRequired() {
+ assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(false)).isFalse();
+ }
+
+ @Test
+ void shouldBeAvailableOnEncryptedTransportWhenSslIsRequired() {
+ assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(true)).isTrue();
+ }
+
+ @Test
+ void shouldNotBeAvailableWhenDisabled() {
+ assertThat(new PlainSaslMechanism(false, false).isAvailableOnTransport(true)).isFalse();
+ }
+
+ @Test
+ void shouldChallengeWhenNoInitialResponse() {
+ // GIVEN a PLAIN exchange without SASL-IR
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty());
+
+ // WHEN the mechanism starts
+ SaslStep firstStep = testee.start(request, authenticating()).firstStep();
+
+ // THEN the server asks for one client response
+ assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty()));
+ }
+
+ @Test
+ void shouldAuthenticateInitialResponseWithoutDelegation() {
+ // GIVEN a valid PLAIN initial response without an authorization identity
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME,
+ Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)));
+
+ // WHEN the mechanism consumes the initial response
+ SaslStep step = testee.start(request, authenticating()).firstStep();
+
+ // THEN it authenticates through the shared SASL authenticator and returns the authenticated identity
+ assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty()));
+ }
+
+ @Test
+ void shouldAuthenticateContinuationResponseWithDelegation() {
+ // GIVEN a PLAIN exchange waiting for the client response
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty());
+ SaslExchange exchange = testee.start(request, authenticating());
+
+ // WHEN the client sends a response with an authorization identity
+ SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD));
+
+ // THEN both identities are preserved after mechanism-owned authentication
+ assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID), Optional.empty()));
+ }
+
+ @Test
+ void shouldAcceptTwoPartResponseWithoutAuthorizationIdentity() {
+ // GIVEN a PLAIN response encoded as authcid/password
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME,
+ Optional.of(bytes(AUTHENTICATION_ID.asString() + "\0" + PASSWORD)));
+
+ // WHEN the mechanism consumes the response
+ SaslStep step = testee.start(request, authenticating()).firstStep();
+
+ // THEN it treats the response as non-delegated authentication
+ assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty()));
+ }
+
+ @Test
+ void shouldPassPasswordUnmodifiedToAuthenticator() {
+ // GIVEN a PLAIN response whose password is made of whitespace only
+ AtomicReference capturedPassword = new AtomicReference<>();
+ String whitespacePassword = " ";
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME,
+ Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + whitespacePassword)));
+
+ // WHEN the mechanism consumes the response
+ testee.start(request, authenticating(capturedPassword)).firstStep();
+
+ // THEN the password is kept unchanged instead of being filtered as blank
+ assertThat(capturedPassword).hasValue(whitespacePassword);
+ }
+
+ @Test
+ void shouldReturnAuthenticatorFailure() {
+ // GIVEN a valid PLAIN response but an authenticator rejecting the password
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME,
+ Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)));
+ SaslFailure failure = SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected");
+
+ // WHEN the mechanism consumes the response
+ SaslStep step = testee.start(request, rejecting(failure)).firstStep();
+
+ // THEN the typed failure is returned to the protocol driver
+ assertThat(step).isEqualTo(new SaslStep.Failure(failure));
+ }
+
+ @Test
+ void shouldFailMalformedResponse() {
+ // GIVEN a PLAIN response without the expected separators
+ SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME,
+ Optional.of(bytes("missing-separators")));
+
+ // WHEN the mechanism consumes the response
+ SaslStep step = testee.start(request, authenticating()).firstStep();
+
+ // THEN it fails before calling protocol-neutral authentication
+ assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command.")));
+ }
+
+ private static SaslAuthenticator authenticating() {
+ return authenticating(new AtomicReference<>());
+ }
+
+ private static SaslAuthenticator authenticating(AtomicReference capturedPassword) {
+ return new SaslAuthenticator() {
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) {
+ capturedPassword.set(password);
+ return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId.orElse(authenticationId)));
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Success(identity);
+ }
+ };
+ }
+
+ private static SaslAuthenticator rejecting(SaslFailure failure) {
+ return new SaslAuthenticator() {
+ @Override
+ public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) {
+ return new SaslAuthenticationResult.Failure(failure);
+ }
+
+ @Override
+ public SaslAuthenticationResult authorize(SaslIdentity identity) {
+ return new SaslAuthenticationResult.Success(identity);
+ }
+ };
+ }
+
+ private static byte[] bytes(String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+}
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
+
+ ${james.groupId}
+ james-server-guice-utils
+
${james.groupId}
james-server-mailrepository-memory
@@ -126,6 +130,10 @@
testing-base
test
+
+ ${james.protocols.groupId}
+ protocols-api
+
com.github.stefanbirkner
system-lambda
diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java
index fffe4af299b..63959aecb54 100644
--- a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java
+++ b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java
@@ -49,7 +49,7 @@ public CommonServicesModule(Configuration configuration) {
this.fileSystem = new FileSystemImpl(configuration.directories());
}
-
+
@Override
protected void configure() {
install(new ExtensionModule());
diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java
new file mode 100644
index 00000000000..ad86c882d70
--- /dev/null
+++ b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java
@@ -0,0 +1,31 @@
+/****************************************************************
+ * 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.modules;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+
+import com.google.inject.BindingAnnotation;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+public @interface SaslMechanismFactories {
+}
diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java
new file mode 100644
index 00000000000..c34d2e5804b
--- /dev/null
+++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java
@@ -0,0 +1,89 @@
+/****************************************************************
+ * 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.utils;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import jakarta.inject.Inject;
+
+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;
+
+import com.github.fge.lambdas.Throwing;
+import com.google.common.collect.ImmutableList;
+
+public class GuiceSaslMechanismResolver {
+ private static final NamingScheme SASL_FACTORY_NAMING_SCHEME =
+ new NamingScheme.OptionalPackagePrefix(PackageName.of("org.apache.james.protocols.sasl"));
+
+ private final GuiceLoader.InvocationPerformer factoryLoader;
+
+ @Inject
+ public GuiceSaslMechanismResolver(GuiceLoader guiceLoader) {
+ this.factoryLoader = guiceLoader.withNamingSheme(SASL_FACTORY_NAMING_SCHEME);
+ }
+
+ public ImmutableList resolve(Collection configuredFactoryClassNames,
+ ImmutableList enabledDefaultFactories,
+ HierarchicalConfiguration serverConfiguration) throws ConfigurationException {
+ try {
+ ImmutableList factories = configuredFactoryClassNames.isEmpty()
+ ? enabledDefaultFactories
+ : configuredFactoryClassNames.stream()
+ .map(Throwing.function(this::instantiateFactory))
+ .collect(ImmutableList.toImmutableList());
+
+ return factories.stream()
+ .map(Throwing.function(factory -> factory.create(serverConfiguration)))
+ .collect(Collectors.toMap(
+ mechanism -> normalize(mechanism.name()),
+ Function.identity(),
+ (first, second) -> first,
+ LinkedHashMap::new))
+ .values()
+ .stream()
+ .collect(ImmutableList.toImmutableList());
+ } catch (RuntimeException e) {
+ if (e.getCause() instanceof ConfigurationException configurationException) {
+ throw configurationException;
+ }
+ throw e;
+ }
+ }
+
+ private SaslMechanismFactory instantiateFactory(String className) throws ConfigurationException {
+ try {
+ return factoryLoader.instantiate(new ClassName(className));
+ } catch (ClassNotFoundException | RuntimeException e) {
+ throw new ConfigurationException("Can not load SASL mechanism factory " + className, e);
+ }
+ }
+
+ private String normalize(String mechanismName) {
+ return mechanismName.toUpperCase(Locale.US);
+ }
+}
diff --git a/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java
new file mode 100644
index 00000000000..850ed4c1ce1
--- /dev/null
+++ b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.sasl;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+import org.apache.james.utils.FixedNameSaslMechanism;
+
+public class TestingDefaultPackageSaslMechanismFactory implements SaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) {
+ return new FixedNameSaslMechanism("DEFAULT");
+ }
+}
diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java
new file mode 100644
index 00000000000..c1f59a2eef1
--- /dev/null
+++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java
@@ -0,0 +1,32 @@
+/****************************************************************
+ * 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.utils;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+
+public class ConfigurableFakeSaslMechanismFactory implements SaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) {
+ return new FixedNameSaslMechanism(serverConfiguration.getString("auth.example.realm"));
+ }
+}
diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java
new file mode 100644
index 00000000000..d8607ac7a21
--- /dev/null
+++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java
@@ -0,0 +1,32 @@
+/****************************************************************
+ * 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.utils;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
+
+public class ExternalFakeSaslMechanismFactory implements SaslMechanismFactory {
+ @Override
+ public SaslMechanism create(HierarchicalConfiguration serverConfiguration) {
+ return new FixedNameSaslMechanism("EXTERNAL-FAKE");
+ }
+}
diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java
new file mode 100644
index 00000000000..9b82df2cb2d
--- /dev/null
+++ b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java
@@ -0,0 +1,69 @@
+/****************************************************************
+ * 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.utils;
+
+import org.apache.james.protocols.api.sasl.SaslAuthenticator;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslStep;
+
+public class FixedNameSaslMechanism implements SaslMechanism {
+ private final String name;
+
+ public FixedNameSaslMechanism(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) {
+ return new FixedStepExchange();
+ }
+
+ private record FixedStepExchange() implements SaslExchange {
+ @Override
+ public SaslStep firstStep() {
+ return failure();
+ }
+
+ @Override
+ public SaslStep onResponse(byte[] clientResponse) {
+ return failure();
+ }
+
+ @Override
+ public void abort() {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ private SaslStep failure() {
+ return new SaslStep.Failure(SaslFailure.malformed("not implemented"));
+ }
+ }
+}
diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java
new file mode 100644
index 00000000000..e3551ee400f
--- /dev/null
+++ b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java
@@ -0,0 +1,242 @@
+/****************************************************************
+ * 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.utils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.Optional;
+
+import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
+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;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Module;
+
+class GuiceSaslMechanismResolverTest {
+ private static final HierarchicalConfiguration EMPTY_CONFIGURATION = new BaseHierarchicalConfiguration();
+
+ @Test
+ void resolveShouldUseEnabledDefaultFactoriesWhenNoFactoryClassIsConfigured() throws Exception {
+ // GIVEN an absent auth.saslMechanisms configuration and an ordered default factory list
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving mechanisms for this server
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of(),
+ ImmutableList.of(factory("PLAIN"), factory("OAUTHBEARER")),
+ EMPTY_CONFIGURATION);
+
+ // THEN defaults are used in their declared order
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("PLAIN", "OAUTHBEARER");
+ }
+
+ @Test
+ void resolveShouldResolveSimpleFactoryNameFromDefaultSaslPackage() throws Exception {
+ // GIVEN a configured built-in SASL factory simple name
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving it
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of("TestingDefaultPackageSaslMechanismFactory"),
+ ImmutableList.of(),
+ EMPTY_CONFIGURATION);
+
+ // THEN the factory is loaded from org.apache.james.protocols.sasl
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("DEFAULT");
+ }
+
+ @Test
+ void resolveShouldResolveFullyQualifiedFactoryName() throws Exception {
+ // GIVEN a configured extension factory FQCN
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving it
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()),
+ ImmutableList.of(),
+ EMPTY_CONFIGURATION);
+
+ // THEN the extension factory is loaded directly
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("EXTERNAL-FAKE");
+ }
+
+ @Test
+ void resolveShouldUseConfiguredFactoriesInsteadOfDefaults() throws Exception {
+ // GIVEN both defaults and an explicit auth.saslMechanisms configuration
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving mechanisms
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()),
+ ImmutableList.of(factory("DEFAULT")),
+ EMPTY_CONFIGURATION);
+
+ // THEN the configured list replaces defaults
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("EXTERNAL-FAKE");
+ }
+
+ @Test
+ void resolveShouldCreateConfiguredFactoriesFromCurrentServerConfiguration() throws Exception {
+ // GIVEN two server configurations using the same configured SASL factory
+ BaseHierarchicalConfiguration firstConfiguration = new BaseHierarchicalConfiguration();
+ firstConfiguration.addProperty("auth.example.realm", "FIRST");
+ BaseHierarchicalConfiguration secondConfiguration = new BaseHierarchicalConfiguration();
+ secondConfiguration.addProperty("auth.example.realm", "SECOND");
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving the same configured factory for each server
+ SaslMechanism firstMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()),
+ ImmutableList.of(),
+ firstConfiguration)
+ .getFirst();
+ SaslMechanism secondMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()),
+ ImmutableList.of(),
+ secondConfiguration)
+ .getFirst();
+
+ // THEN each mechanism is created from that server's configuration, not from a global singleton
+ assertThat(firstMechanism.name()).isEqualTo("FIRST");
+ assertThat(secondMechanism.name()).isEqualTo("SECOND");
+ }
+
+ @Test
+ void resolveShouldPreserveConfiguredOrderForDistinctMechanisms() throws Exception {
+ // GIVEN several distinct SASL mechanism factories in a configured order
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving them
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of(),
+ ImmutableList.of(factory("FIRST"), factory("SECOND"), factory("THIRD")),
+ EMPTY_CONFIGURATION);
+
+ // THEN the resolved mechanisms keep the configured order
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("FIRST", "SECOND", "THIRD");
+ }
+
+ @Test
+ void resolveShouldDeduplicateMechanismNamesCaseInsensitively() throws Exception {
+ // GIVEN two factories returning the same SASL mechanism name with different case
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving both factories
+ ImmutableList mechanisms = testee.resolve(ImmutableList.of(),
+ ImmutableList.of(factory("DUPLICATE"), factory("duplicate")),
+ EMPTY_CONFIGURATION);
+
+ // THEN first occurrence wins and order remains stable
+ assertThat(mechanisms)
+ .extracting(SaslMechanism::name)
+ .containsExactly("DUPLICATE");
+ }
+
+ @Test
+ void resolveShouldFailWhenConfiguredFactoryClassDoesNotExist() {
+ // GIVEN a resolver used for configured SASL mechanism factory entries
+ GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader());
+
+ // WHEN resolving an unknown factory class name
+ // THEN startup wiring can fail fast with the configured entry in the error
+ assertThatThrownBy(() -> testee.resolve(ImmutableList.of("MissingSaslMechanismFactory"),
+ ImmutableList.of(),
+ EMPTY_CONFIGURATION))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("MissingSaslMechanismFactory");
+ }
+
+ private static SaslMechanismFactory factory(String mechanismName) {
+ return serverConfiguration -> new FixedNameSaslMechanism(mechanismName);
+ }
+
+ private static class ReflectionGuiceLoader implements GuiceLoader {
+ @Override
+ public T instantiate(ClassName className) throws ClassNotFoundException {
+ return this.withNamingSheme(NamingScheme.IDENTITY).instantiate(className);
+ }
+
+ @Override
+ public InvocationPerformer withNamingSheme(NamingScheme namingSheme) {
+ return new ReflectionInvocationPerformer<>(namingSheme);
+ }
+
+ @Override
+ public InvocationPerformer withChildModule(Module childModule) {
+ return new ReflectionInvocationPerformer<>(NamingScheme.IDENTITY);
+ }
+ }
+
+ private static class ReflectionInvocationPerformer implements GuiceLoader.InvocationPerformer {
+ private final NamingScheme namingScheme;
+
+ private ReflectionInvocationPerformer(NamingScheme namingScheme) {
+ this.namingScheme = namingScheme;
+ }
+
+ @Override
+ public T instantiate(ClassName className) throws ClassNotFoundException {
+ try {
+ return locateClass(className).getDeclaredConstructor().newInstance();
+ } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
+ throw new ClassNotFoundException(className.getName(), e);
+ }
+ }
+
+ @Override
+ public Class locateClass(ClassName className) throws ClassNotFoundException {
+ Optional> locatedClass = namingScheme.toFullyQualifiedClassNames(className)
+ .map(FullyQualifiedClassName::getName)
+ .map(this::tryLocateClass)
+ .flatMap(Optional::stream)
+ .findFirst();
+ return locatedClass.orElseThrow(() -> new ClassNotFoundException(className.getName()));
+ }
+
+ @Override
+ public GuiceLoader.InvocationPerformer withChildModule(Module childModule) {
+ return new ReflectionInvocationPerformer<>(namingScheme);
+ }
+
+ @Override
+ public GuiceLoader.InvocationPerformer withNamingSheme(NamingScheme namingSheme) {
+ return new ReflectionInvocationPerformer<>(namingSheme);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Optional> tryLocateClass(String className) {
+ try {
+ return Optional.of((Class) Class.forName(className));
+ } catch (ClassNotFoundException e) {
+ return Optional.empty();
+ }
+ }
+ }
+}
diff --git a/server/container/guice/protocols/imap/pom.xml b/server/container/guice/protocols/imap/pom.xml
index 11a22740b50..dc99029ba77 100644
--- a/server/container/guice/protocols/imap/pom.xml
+++ b/server/container/guice/protocols/imap/pom.xml
@@ -53,6 +53,10 @@
testing-base
test
+
+ ${james.protocols.groupId}
+ protocols-sasl
+
com.google.inject
guice
diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java
index 3c3fc4ee3d1..5733cea9cd6 100644
--- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java
+++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java
@@ -26,6 +26,7 @@
import org.apache.commons.configuration2.HierarchicalConfiguration;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.james.ProtocolConfigurationSanitizer;
import org.apache.james.RunArguments;
@@ -60,6 +61,7 @@
import org.apache.james.imap.processor.CapabilityProcessor;
import org.apache.james.imap.processor.DefaultProcessor;
import org.apache.james.imap.processor.EnableProcessor;
+import org.apache.james.imap.processor.LoginProcessor;
import org.apache.james.imap.processor.NamespaceSupplier;
import org.apache.james.imap.processor.PermitEnableCapabilityProcessor;
import org.apache.james.imap.processor.SelectProcessor;
@@ -72,12 +74,19 @@
import org.apache.james.lifecycle.api.ConfigurationSanitizer;
import org.apache.james.metrics.api.GaugeRegistry;
import org.apache.james.metrics.api.MetricFactory;
+import org.apache.james.protocols.api.sasl.SaslMechanism;
+import org.apache.james.protocols.api.sasl.SaslMechanismFactory;
import org.apache.james.protocols.lib.netty.CertificateReloadable;
import org.apache.james.protocols.netty.Encryption;
+import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories;
+import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory;
+import org.apache.james.protocols.sasl.PlainSaslMechanismFactory;
+import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory;
import org.apache.james.server.core.configuration.ConfigurationProvider;
import org.apache.james.utils.ClassName;
import org.apache.james.utils.GuiceLoader;
import org.apache.james.utils.GuiceProbe;
+import org.apache.james.utils.GuiceSaslMechanismResolver;
import org.apache.james.utils.InitializationOperation;
import org.apache.james.utils.InitilizationOperationBuilder;
import org.apache.james.utils.KeystoreCreator;
@@ -94,7 +103,6 @@
import com.google.inject.multibindings.ProvidesIntoSet;
public class IMAPServerModule extends AbstractModule {
-
private static Stream> asPairStream(AbstractProcessor p) {
return p.acceptableClasses()
.stream().map(clazz -> Pair.of(clazz, p));
@@ -106,11 +114,13 @@ protected void configure() {
bind(UnpooledStatusResponseFactory.class).in(Scopes.SINGLETON);
bind(StatusResponseFactory.class).to(UnpooledStatusResponseFactory.class);
- bind(CapabilityProcessor.class).in(Scopes.SINGLETON);
- bind(AuthenticateProcessor.class).in(Scopes.SINGLETON);
+ // Keep CapabilityProcessor, AuthenticateProcessor and EnableProcessor unscoped: IMAP suite loading configures
+ // their SASL mechanisms and capability links from each server configuration.
+ bind(CapabilityProcessor.class);
+ bind(AuthenticateProcessor.class);
+ bind(EnableProcessor.class);
bind(SelectProcessor.class).in(Scopes.SINGLETON);
bind(StatusProcessor.class).in(Scopes.SINGLETON);
- bind(EnableProcessor.class).in(Scopes.SINGLETON);
bind(NamespaceSupplier.class).to(NamespaceSupplier.Default.class).in(Scopes.SINGLETON);
bind(PathConverter.Factory.class).to(PathConverter.Factory.Default.class).in(Scopes.SINGLETON);
bind(MailboxTyper.class).to(DefaultMailboxTyper.class).in(Scopes.SINGLETON);
@@ -130,21 +140,35 @@ protected void configure() {
@Singleton
IMAPServerFactory provideServerFactory(FileSystem fileSystem,
GuiceLoader guiceLoader,
+ GuiceSaslMechanismResolver saslMechanismResolver,
+ @ImapDefaultSaslMechanismFactories ImmutableList defaultSaslMechanismFactories,
StatusResponseFactory statusResponseFactory,
MetricFactory metricFactory,
GaugeRegistry gaugeRegistry,
ConnectionCheckFactory connectionCheckFactory,
Encryption.Factory encryptionFactory) {
- IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory);
+ IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismResolver,
+ defaultSaslMechanismFactories, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory);
factory.setEncryptionFactory(encryptionFactory);
return factory;
}
- DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, StatusResponseFactory statusResponseFactory) {
+ @Provides
+ @Singleton
+ @ImapDefaultSaslMechanismFactories
+ ImmutableList provideDefaultImapSaslMechanismFactories(PlainSaslMechanismFactory plain,
+ OauthBearerSaslMechanismFactory oauthBearer,
+ XOauth2SaslMechanismFactory xoauth2) {
+ return ImmutableList.of(plain, oauthBearer, xoauth2);
+ }
+
+ DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader,
+ ImmutableList saslMechanisms, StatusResponseFactory statusResponseFactory) {
ImmutableMap processors = imapPackage.processors()
.stream()
.map(Throwing.function(guiceLoader::instantiate))
.map(AbstractProcessor.class::cast)
+ .map(processor -> configureSaslMechanisms(processor, saslMechanisms))
.flatMap(IMAPServerModule::asPairStream)
.collect(ImmutableMap.toImmutableMap(
Pair::getLeft,
@@ -168,6 +192,16 @@ DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader
return new DefaultProcessor(processors, new UnknownRequestProcessor(statusResponseFactory));
}
+ private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, ImmutableList saslMechanisms) {
+ if (processor instanceof AuthenticateProcessor authenticateProcessor) {
+ authenticateProcessor.configureSaslMechanisms(saslMechanisms);
+ }
+ if (processor instanceof LoginProcessor loginProcessor) {
+ loginProcessor.configureSaslMechanisms(saslMechanisms);
+ }
+ return processor;
+ }
+
private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfiguration configuration) {
String[] imapPackages = configuration.getStringArray("imapPackages");
@@ -185,11 +219,39 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig
return ImapPackage.and(packages);
}
+ private ImmutableList retrieveSaslMechanisms(GuiceSaslMechanismResolver saslMechanismResolver,
+ ImmutableList defaultSaslMechanismFactories,
+ HierarchicalConfiguration configuration) throws ConfigurationException {
+ ImmutableList mechanismFactoryClassNames = retrieveSaslMechanismFactoryClassNames(configuration);
+ ImmutableList enabledDefaultFactories =
+ BuiltInSaslMechanismFactories.enabledForServer(defaultSaslMechanismFactories, configuration);
+ return saslMechanismResolver.resolve(mechanismFactoryClassNames, enabledDefaultFactories, configuration);
+ }
+
+ ImmutableList retrieveSaslMechanismFactoryClassNames(HierarchicalConfiguration configuration) throws ConfigurationException {
+ if (!configuration.containsKey("auth.saslMechanisms")) {
+ return ImmutableList.of();
+ }
+
+ ImmutableList mechanismFactoryClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms"))
+ .flatMap(value -> Arrays.stream(value.split(",")))
+ .map(String::trim)
+ .collect(ImmutableList.toImmutableList());
+
+ if (mechanismFactoryClassNames.isEmpty() || mechanismFactoryClassNames.stream().anyMatch(StringUtils::isBlank)) {
+ throw new ConfigurationException("auth.saslMechanisms must not be blank when configured");
+ }
+ return mechanismFactoryClassNames;
+ }
+
private ThrowingFunction, ImapSuite> imapSuiteLoader(GuiceLoader guiceLoader,
+ GuiceSaslMechanismResolver saslMechanismResolver,
+ ImmutableList defaultSaslMechanismFactories,
StatusResponseFactory statusResponseFactory) {
return configuration -> {
ImapPackage imapPackage = retrievePackages(guiceLoader, configuration);
- DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, statusResponseFactory);
+ ImmutableList saslMechanisms = retrieveSaslMechanisms(saslMechanismResolver, defaultSaslMechanismFactories, configuration);
+ DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory);
ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader);
ImapParserFactory imapParserFactory = provideImapCommandParserFactory(imapPackage, guiceLoader);
@@ -231,6 +293,12 @@ FetchProcessor.LocalCacheConfiguration provideFetchLocalCacheConfiguration(Confi
}
private void configureEnable(EnableProcessor enableProcessor, ImmutableMap processorMap) {
+ processorMap.values().stream()
+ .filter(CapabilityProcessor.class::isInstance)
+ .map(CapabilityProcessor.class::cast)
+ .findFirst()
+ .ifPresent(enableProcessor::configureCapabilityProcessor);
+
processorMap.values().stream()
.filter(PermitEnableCapabilityProcessor.class::isInstance)
.map(PermitEnableCapabilityProcessor.class::cast)
@@ -262,4 +330,4 @@ ConfigurationSanitizer configurationSanitizer(ConfigurationProvider configuratio
FileSystem fileSystem, RunArguments runArguments) {
return new ProtocolConfigurationSanitizer(configurationProvider, keystoreCreator, fileSystem, runArguments, "imapserver");
}
-}
\ No newline at end of file
+}
diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java
new file mode 100644
index 00000000000..6cac7200fd6
--- /dev/null
+++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java
@@ -0,0 +1,31 @@
+/****************************************************************
+ * 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.modules.protocols;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+
+import com.google.inject.BindingAnnotation;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+public @interface ImapDefaultSaslMechanismFactories {
+}
diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java
new file mode 100644
index 00000000000..611ec5ceea3
--- /dev/null
+++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java
@@ -0,0 +1,106 @@
+/****************************************************************
+ * 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.modules.protocols;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory;
+import org.apache.james.protocols.sasl.PlainSaslMechanismFactory;
+import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+
+class IMAPServerModuleTest {
+ private final IMAPServerModule testee = new IMAPServerModule();
+
+ @Test
+ void provideDefaultImapSaslMechanismFactoriesShouldReturnJamesDefaults() {
+ // GIVEN no auth.saslMechanisms configuration
+ // WHEN IMAP provides its default SASL factories
+
+ // THEN existing James IMAP defaults are preserved in order
+ assertThat(testee.provideDefaultImapSaslMechanismFactories(
+ new PlainSaslMechanismFactory(),
+ new OauthBearerSaslMechanismFactory(),
+ new XOauth2SaslMechanismFactory()))
+ .map(factory -> factory.getClass().getSimpleName())
+ .containsExactly(
+ PlainSaslMechanismFactory.class.getSimpleName(),
+ OauthBearerSaslMechanismFactory.class.getSimpleName(),
+ XOauth2SaslMechanismFactory.class.getSimpleName());
+ }
+
+ @Test
+ void retrieveSaslMechanismFactoryClassNamesShouldReturnEmptyWhenAbsent() throws Exception {
+ // GIVEN no auth.saslMechanisms configuration.
+ // The empty configured list lets the resolver use the Guice-provided default factory list.
+ // Community custom IMAP packages can override that default factory list to avoid breaking changes.
+ BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration();
+
+ // WHEN auth.saslMechanisms is absent
+ ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration);
+
+ // THEN there is no configured override
+ assertThat(mechanismFactoryClassNames).isEmpty();
+ }
+
+ @Test
+ void retrieveSaslMechanismFactoryClassNamesShouldReturnConfiguredSaslFactoryList() throws Exception {
+ // GIVEN an explicit server-specific SASL factory list
+ BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration();
+ configuration.addProperty("auth.saslMechanisms",
+ "PlainSaslMechanismFactory,com.example.CustomSaslMechanismFactory,PlainSaslMechanismFactory");
+
+ // WHEN IMAP resolves configured factory class names
+ ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration);
+
+ // THEN the exact configured order is passed to the resolver
+ assertThat(mechanismFactoryClassNames)
+ .containsExactly("PlainSaslMechanismFactory", "com.example.CustomSaslMechanismFactory", "PlainSaslMechanismFactory");
+ }
+
+ @Test
+ void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankConfiguredList() {
+ // GIVEN auth.saslMechanisms is present but blank
+ BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration();
+ configuration.addProperty("auth.saslMechanisms", " ");
+
+ // WHEN resolving factory class names
+ // THEN startup fails instead of silently disabling all mechanisms
+ assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration))
+ .isInstanceOf(ConfigurationException.class);
+ }
+
+ @Test
+ void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankEntry() {
+ // GIVEN auth.saslMechanisms contains a blank entry
+ BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration();
+ configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanismFactory,,XOauth2SaslMechanismFactory");
+
+ // WHEN resolving factory class names
+ // THEN startup fails with an invalid configured list
+ assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration))
+ .isInstanceOf(ConfigurationException.class);
+ }
+}
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
index d0b43c0ff08..9d4cdc492fc 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
@@ -21,9 +21,7 @@
import static org.apache.james.imapserver.netty.HAProxyMessageHandler.PROXY_INFO;
import java.net.InetSocketAddress;
-import java.net.MalformedURLException;
import java.net.SocketAddress;
-import java.net.URISyntaxException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -50,7 +48,6 @@
import org.apache.james.imap.api.process.SelectedMailbox;
import org.apache.james.imap.decode.ImapDecoder;
import org.apache.james.imap.encode.ImapEncoder;
-import org.apache.james.jwt.OidcSASLConfiguration;
import org.apache.james.mailbox.MailboxSession;
import org.apache.james.mailbox.model.MailboxId;
import org.apache.james.metrics.api.GaugeRegistry;
@@ -94,64 +91,6 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC
private static final Logger LOG = LoggerFactory.getLogger(IMAPServer.class);
public static final AttributeKey CONNECTION_DATE = AttributeKey.newInstance("connectionDate");
- public static class AuthenticationConfiguration {
- private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true;
- private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true;
- private static final String OIDC_PATH = "auth.oidc";
-
- public static AuthenticationConfiguration parse(HierarchicalConfiguration configuration) throws ConfigurationException {
- boolean isRequireSSL = configuration.getBoolean("auth.requireSSL", fallback(configuration));
- boolean isPlainAuthEnabled = configuration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT);
-
- if (configuration.immutableConfigurationsAt(OIDC_PATH).isEmpty()) {
- return new AuthenticationConfiguration(
- isRequireSSL,
- isPlainAuthEnabled);
- } else {
- try {
- return new AuthenticationConfiguration(
- isRequireSSL,
- isPlainAuthEnabled,
- OidcSASLConfiguration.parse(configuration.configurationAt(OIDC_PATH)));
- } catch (MalformedURLException | NullPointerException | URISyntaxException exception) {
- throw new ConfigurationException("Failed to retrieve oauth component", exception);
- }
- }
- }
-
- private static boolean fallback(HierarchicalConfiguration configuration) {
- return configuration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT);
- }
-
- private final boolean isSSLRequired;
- private final boolean plainAuthEnabled;
- private final Optional oidcSASLConfiguration;
-
- public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled) {
- this.isSSLRequired = isSSLRequired;
- this.plainAuthEnabled = plainAuthEnabled;
- this.oidcSASLConfiguration = Optional.empty();
- }
-
- public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled, OidcSASLConfiguration oidcSASLConfiguration) {
- this.isSSLRequired = isSSLRequired;
- this.plainAuthEnabled = plainAuthEnabled;
- this.oidcSASLConfiguration = Optional.of(oidcSASLConfiguration);
- }
-
- public boolean isSSLRequired() {
- return isSSLRequired;
- }
-
- public boolean isPlainAuthEnabled() {
- return plainAuthEnabled;
- }
-
- public Optional getOidcSASLConfiguration() {
- return oidcSASLConfiguration;
- }
- }
-
private static final String SOFTWARE_TYPE = "JAMES " + VERSION + " Server ";
private static final String DEFAULT_TIME_UNIT = "SECONDS";
private static final String CAPABILITY_SEPARATOR = "|";
@@ -174,7 +113,6 @@ public Optional getOidcSASLConfiguration() {
private int inMemorySizeLimit;
private int timeout;
private int literalSizeLimit;
- private AuthenticationConfiguration authenticationConfiguration;
private Optional trafficShaping = Optional.empty();
private Optional connectionLimitUpstreamHandler = Optional.empty();
private Optional connectionPerIpLimitUpstreamHandler = Optional.empty();
@@ -212,7 +150,6 @@ public void doConfigure(HierarchicalConfiguration configuration)
if (timeout < DEFAULT_TIMEOUT) {
throw new ConfigurationException("Minimum timeout of 30 minutes required. See rfc2060 5.4 for details");
}
- authenticationConfiguration = AuthenticationConfiguration.parse(configuration);
connectionLimitUpstreamHandler = ConnectionLimitUpstreamHandler.forCount(connectionLimit);
connectionPerIpLimitUpstreamHandler = ConnectionPerIpLimitUpstreamHandler.forCount(connPerIP);
ignoreIDLEUponProcessing = configuration.getBoolean("ignoreIDLEUponProcessing", true);
@@ -368,7 +305,6 @@ protected ChannelInboundHandlerAdapter createCoreHandler() {
.processor(processor)
.encoder(encoder)
.compress(compress)
- .authenticationConfiguration(authenticationConfiguration)
.connectionChecks(connectionChecks)
.secure(secure)
.imapMetrics(imapMetrics)
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
index e4435a7786f..23d953c64ec 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
@@ -19,7 +19,6 @@
package org.apache.james.imapserver.netty;
import static org.apache.james.imap.api.process.ImapSession.MDC_KEY;
-import static org.apache.james.imapserver.netty.IMAPServer.AuthenticationConfiguration;
import java.io.Closeable;
import java.io.IOException;
@@ -88,7 +87,6 @@ public static class ImapChannelUpstreamHandlerBuilder {
private boolean compress;
private ImapProcessor processor;
private ImapEncoder encoder;
- private IMAPServer.AuthenticationConfiguration authenticationConfiguration;
private ImapMetrics imapMetrics;
private boolean ignoreIDLEUponProcessing;
private Duration heartbeatInterval;
@@ -127,11 +125,6 @@ public ImapChannelUpstreamHandlerBuilder encoder(ImapEncoder encoder) {
return this;
}
- public ImapChannelUpstreamHandlerBuilder authenticationConfiguration(IMAPServer.AuthenticationConfiguration authenticationConfiguration) {
- this.authenticationConfiguration = authenticationConfiguration;
- return this;
- }
-
public ImapChannelUpstreamHandlerBuilder connectionChecks(Set connectionChecks) {
this.connectionChecks = connectionChecks;
return this;
@@ -163,7 +156,7 @@ public ImapChannelUpstreamHandlerBuilder imapChannelGroup(ChannelGroup imapChann
}
public ImapChannelUpstreamHandler build() {
- return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, authenticationConfiguration, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup);
+ return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup);
}
}
@@ -182,7 +175,6 @@ public static ImapChannelUpstreamHandlerBuilder builder() {
private final ImapProcessor processor;
private final ImapEncoder encoder;
private final ImapHeartbeatHandler heartbeatHandler;
- private final AuthenticationConfiguration authenticationConfiguration;
private final Metric imapConnectionsMetric;
private final Metric imapCommandsMetric;
private final boolean ignoreIDLEUponProcessing;
@@ -192,15 +184,13 @@ public static ImapChannelUpstreamHandlerBuilder builder() {
private final ChannelGroup imapChannelGroup;
public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEncoder encoder, boolean compress,
- Encryption secure, ImapMetrics imapMetrics, AuthenticationConfiguration authenticationConfiguration,
- boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler,
+ Encryption secure, ImapMetrics imapMetrics, boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler,
Set connectionChecks, boolean proxyRequired, ChannelGroup imapChannelGroup) {
this.hello = hello;
this.processor = processor;
this.encoder = encoder;
this.secure = secure;
this.compress = compress;
- this.authenticationConfiguration = authenticationConfiguration;
this.imapConnectionsMetric = imapMetrics.getConnectionsMetric();
this.imapCommandsMetric = imapMetrics.getCommandsMetric();
this.ignoreIDLEUponProcessing = ignoreIDLEUponProcessing;
@@ -215,9 +205,7 @@ public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEnc
public void channelActive(ChannelHandlerContext ctx) {
imapChannelGroup.add(ctx.channel());
SessionId sessionId = SessionId.generate();
- ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, authenticationConfiguration.isSSLRequired(),
- authenticationConfiguration.isPlainAuthEnabled(), sessionId,
- authenticationConfiguration.getOidcSASLConfiguration());
+ ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, sessionId);
ctx.channel().attr(IMAP_SESSION_ATTRIBUTE_KEY).set(imapsession);
ctx.channel().attr(REQUEST_COUNTER).set(new AtomicLong());
ctx.channel().attr(LINEARIZER_ATTRIBUTE_KEY).set(new ImapLinerarizer());
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
index 9a10733e645..866ea6d579b 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
@@ -39,7 +39,6 @@
import org.apache.james.imap.api.process.SelectedMailbox;
import org.apache.james.imap.encode.ImapResponseWriter;
import org.apache.james.imap.message.Literal;
-import org.apache.james.jwt.OidcSASLConfiguration;
import org.apache.james.mailbox.MailboxSession;
import org.apache.james.protocols.netty.Encryption;
import org.apache.james.protocols.netty.LineHandlerAware;
@@ -61,28 +60,19 @@ public class NettyImapSession implements ImapSession, NettyConstants {
private final Encryption secure;
private final boolean compress;
private final Channel channel;
- private final boolean requiredSSL;
- private final boolean plainAuthEnabled;
private final SessionId sessionId;
- private final boolean supportsOAuth;
- private final Optional oidcSASLConfiguration;
private volatile ImapSessionState state = ImapSessionState.NON_AUTHENTICATED;
private final AtomicReference selectedMailbox = new AtomicReference<>();
private volatile boolean needsCommandInjectionDetection;
private volatile MailboxSession mailboxSession = null;
- public NettyImapSession(Channel channel, Encryption secure, boolean compress, boolean requiredSSL, boolean plainAuthEnabled, SessionId sessionId,
- Optional oidcSASLConfiguration) {
+ public NettyImapSession(Channel channel, Encryption secure, boolean compress, SessionId sessionId) {
this.channel = channel;
this.secure = secure;
this.compress = compress;
- this.requiredSSL = requiredSSL;
- this.plainAuthEnabled = plainAuthEnabled;
this.sessionId = sessionId;
this.needsCommandInjectionDetection = true;
- this.oidcSASLConfiguration = oidcSASLConfiguration;
- this.supportsOAuth = oidcSASLConfiguration.isPresent();
}
@Override
@@ -311,31 +301,11 @@ public void popLineHandler() {
handler.popLineHandler();
}
- @Override
- public boolean isSSLRequired() {
- return requiredSSL;
- }
-
- @Override
- public boolean isPlainAuthEnabled() {
- return plainAuthEnabled;
- }
-
- @Override
- public boolean supportsOAuth() {
- return supportsOAuth;
- }
-
@Override
public InetSocketAddress getRemoteAddress() {
return (InetSocketAddress) channel.remoteAddress();
}
- @Override
- public Optional oidcSaslConfiguration() {
- return oidcSASLConfiguration;
- }
-
@Override
public boolean isTLSActive() {
return channel.pipeline().get(SSL_HANDLER) != null;
diff --git a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml
index 117f0baaa83..2d3e4e51846 100644
--- a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml
+++ b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml
@@ -29,17 +29,21 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java
index 7eb0eb22cc9..a4c6471f4c7 100644
--- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java
+++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java
@@ -120,7 +120,8 @@ protected IMAPServer createImapServer(HierarchicalConfiguration c
memoryIntegrationResources.getQuotaManager(),
memoryIntegrationResources.getQuotaRootResolver(),
metricFactory,
- localCacheConfiguration),
+ localCacheConfiguration,
+ config),
new ImapMetrics(metricFactory),
new NoopGaugeRegistry(), connectionChecks);
diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java
index c63833fb9e6..2341714eea2 100644
--- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java
+++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java
@@ -22,8 +22,12 @@
import static io.restassured.RestAssured.when;
import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT;
import static org.apache.james.jmap.JMAPTestingConstants.LOCALHOST_IP;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.is;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
import org.apache.james.GuiceJamesServer;
import org.apache.james.JamesServerBuilder;
import org.apache.james.JamesServerExtension;
@@ -40,6 +44,7 @@
class MemoryWebAdminServerIntegrationTest extends WebAdminServerIntegrationTest {
private static final String DOMAIN = "domain";
private static final String USERNAME = "bob@" + DOMAIN;
+ private static final String ADMIN_USERNAME = "admin@" + DOMAIN;
private static final String PASSWORD = "password";
@RegisterExtension
@@ -79,4 +84,29 @@ void shouldDescribeConnectedImapChannels(GuiceJamesServer server) throws Excepti
.body("[0].protocolSpecificInformation.userAgent", is("{name=Thunderbird, version=102.7.1}"))
.body("[0].protocolSpecificInformation.requestCount", is("3"));
}
+
+ @Test
+ void shouldDescribeDelegatedImapChannels(GuiceJamesServer server) throws Exception {
+ int imapPort = server.getProbe(ImapGuiceProbe.class).getImapPort();
+
+ server.getProbe(DataProbeImpl.class).addUser(USERNAME, PASSWORD);
+ server.getProbe(DataProbeImpl.class).addUser(ADMIN_USERNAME, PASSWORD);
+
+ String initialClientResponse = Base64.getEncoder()
+ .encodeToString((USERNAME + "\0" + ADMIN_USERNAME + "\0" + PASSWORD).getBytes(StandardCharsets.US_ASCII));
+
+ testIMAPClient.connect(LOCALHOST_IP, imapPort);
+ String authenticateResponse = testIMAPClient.sendCommand("AUTHENTICATE PLAIN " + initialClientResponse);
+ testIMAPClient.select("INBOX");
+
+ assertThat(authenticateResponse).contains("OK AUTHENTICATE completed.");
+
+ // loggedInUser should be well recorded for delegation
+ when()
+ .get("/servers/channels/" + USERNAME)
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .body("[0].username", is(USERNAME))
+ .body("[0].protocolSpecificInformation.loggedInUser", is(ADMIN_USERNAME));
+ }
}
\ No newline at end of file
diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml
index f30afe0b249..c77b813a5c9 100644
--- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml
+++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml
@@ -36,6 +36,11 @@ under the License.
0
0
false
+
+
+ admin@domain
+
+
false
0