This is an automated email from the ASF dual-hosted git repository.

quantranhong1999 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit c1b7fe031ed774ecc691be00bb1433cfdda65f58
Author: Quan Tran <[email protected]>
AuthorDate: Tue Jul 7 09:21:18 2026 +0700

    JAMES-4210 Add built-in SASL mechanisms module
    
    Add protocols-sasl with James-backed PLAIN, OAUTHBEARER, and XOAUTH2 
factories. Move password and token validation into mechanisms through 
JamesSaslAuthenticator, keep OIDC config per server block, and add unit 
coverage for PLAIN and OIDC behavior.
---
 pom.xml                                            |   5 +
 .../apache/james/protocols/api/OIDCSASLParser.java |  60 ++++---
 protocols/pom.xml                                  |   1 +
 protocols/sasl/pom.xml                             |  66 +++++++
 .../sasl/BuiltInSaslMechanismFactories.java        |  46 +++++
 .../protocols/sasl/JamesSaslAuthenticator.java     | 118 +++++++++++++
 .../sasl/OauthBearerSaslMechanismFactory.java      |  34 ++++
 .../protocols/sasl/OidcSaslMechanismFactory.java   |  43 +++++
 .../protocols/sasl/PlainSaslMechanismFactory.java  |  45 +++++
 .../sasl/XOauth2SaslMechanismFactory.java          |  34 ++++
 .../protocols/sasl/oidc/OAuthSaslMechanism.java    | 105 +++++++++++
 .../protocols/sasl/plain/PlainSaslMechanism.java   | 144 +++++++++++++++
 .../protocols/sasl/oidc/OidcSaslMechanismTest.java | 175 +++++++++++++++++++
 .../sasl/plain/PlainSaslMechanismTest.java         | 194 +++++++++++++++++++++
 14 files changed, 1041 insertions(+), 29 deletions(-)

diff --git a/pom.xml b/pom.xml
index 8c58c28cfd..5611e9c94c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2161,6 +2161,11 @@
                 <artifactId>protocols-pop3</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>${james.protocols.groupId}</groupId>
+                <artifactId>protocols-sasl</artifactId>
+                <version>${project.version}</version>
+            </dependency>
             <dependency>
                 <groupId>${james.protocols.groupId}</groupId>
                 <artifactId>protocols-smtp</artifactId>
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 1623998c95..be557f1cac 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 class OIDCSASLParser {
     }
 
     public static Optional<OIDCInitialResponse> parse(String initialResponse) {
-        Optional<String> 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<OIDCInitialResponse> 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/pom.xml b/protocols/pom.xml
index e374590783..a643b050a3 100644
--- a/protocols/pom.xml
+++ b/protocols/pom.xml
@@ -42,6 +42,7 @@
         <module>managesieve</module>
         <module>netty</module>
         <module>pop3</module>
+        <module>sasl</module>
         <module>smtp</module>
     </modules>
 
diff --git a/protocols/sasl/pom.xml b/protocols/sasl/pom.xml
new file mode 100644
index 0000000000..ddb94437af
--- /dev/null
+++ b/protocols/sasl/pom.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.james.protocols</groupId>
+        <artifactId>protocols</artifactId>
+        <version>3.10.0-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>protocols-sasl</artifactId>
+    <packaging>jar</packaging>
+
+    <name>Apache James :: Protocols :: SASL implementations</name>
+
+    <dependencies>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>apache-james-mailbox-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-jwt</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>testing-base</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.protocols.groupId}</groupId>
+            <artifactId>protocols-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.inject</groupId>
+            <artifactId>jakarta.inject-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-configuration2</artifactId>
+        </dependency>
+    </dependencies>
+</project>
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 0000000000..859895f030
--- /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<SaslMechanismFactory> 
enabledForServer(ImmutableList<SaslMechanismFactory> defaultFactories,
+                                                                       
HierarchicalConfiguration<ImmutableNode> serverConfiguration) {
+        return defaultFactories.stream()
+            .filter(factory -> isEnabledByDefault(factory, 
serverConfiguration))
+            .collect(ImmutableList.toImmutableList());
+    }
+
+    private static boolean isEnabledByDefault(SaslMechanismFactory factory,
+                                              
HierarchicalConfiguration<ImmutableNode> 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 0000000000..b253153bde
--- /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<Username> 
authorizationId,
+                                                         String password) {
+        try {
+            Optional<Username> 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 0000000000..49de4f2e40
--- /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<ImmutableNode> 
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 0000000000..3cf55ed170
--- /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<ImmutableNode> 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 0000000000..9a3537a772
--- /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<ImmutableNode> 
serverConfiguration) {
+        return new PlainSaslMechanism(plainAuthEnabled(serverConfiguration), 
requiresSsl(serverConfiguration));
+    }
+
+    protected boolean 
plainAuthEnabled(HierarchicalConfiguration<ImmutableNode> serverConfiguration) {
+        return serverConfiguration.getBoolean("auth.plainAuthEnabled", 
PLAIN_AUTH_ENABLED_DEFAULT);
+    }
+
+    protected boolean requiresSsl(HierarchicalConfiguration<ImmutableNode> 
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 0000000000..9f9c895b7f
--- /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<ImmutableNode> 
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 0000000000..cd2106d1f0
--- /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<byte[]> initialResponse;
+        private final SaslAuthenticator authenticator;
+
+        private OAuthSaslExchange(Optional<byte[]> 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 0000000000..96aa8212c1
--- /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<Username> authorizationId, 
Username authenticationId, String password) {
+    }
+
+    protected static PlainCredentials credentials(Optional<Username> 
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<byte[]> initialResponse;
+        private final Function<byte[], Optional<PlainCredentials>> 
credentialsParser;
+        private final SaslAuthenticator authenticator;
+
+        private PlainSaslExchange(Optional<byte[]> initialResponse,
+                                  Function<byte[], Optional<PlainCredentials>> 
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<PlainCredentials> parse(byte[] clientResponse) {
+        ImmutableList<String> 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<Username> 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 0000000000..ddc1eeae5f
--- /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("[email protected]");
+    private static final Username TOKEN_SUBJECT = 
Username.of("[email protected]");
+    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<Username> 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<Username> 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<Username> validateTokenResult;
+
+        private TestOidcJwtTokenVerifier(Optional<Username> 
validateTokenResult) {
+            super(null);
+            this.validateTokenResult = validateTokenResult;
+        }
+
+        @Override
+        public Optional<Username> 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 0000000000..066c47168b
--- /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("[email protected]");
+    private static final Username AUTHORIZATION_ID = 
Username.of("[email protected]");
+    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<String> 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<String> 
capturedPassword) {
+        return new SaslAuthenticator() {
+            @Override
+            public SaslAuthenticationResult authenticatePassword(Username 
authenticationId, Optional<Username> 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<Username> 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);
+    }
+}


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

Reply via email to