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 9827c4b455ed135086dbc34e56b0cd7701340f35
Author: Quan Tran <[email protected]>
AuthorDate: Tue Jul 7 09:20:57 2026 +0700

    JAMES-4210 Introduce protocol-neutral SASL SPI
    
    Add the shared SASL exchange model: mechanism factories, initial requests, 
exchange lifecycle, typed steps, identities, authentication results, and 
failures. Add contract tests documenting one-step, multi-step, delegation, 
payload copy, and lifecycle behavior.
---
 .../api/sasl/SaslAuthenticationResult.java         |  32 +++
 .../protocols/api/sasl/SaslAuthenticator.java      |  43 ++++
 .../james/protocols/api/sasl/SaslExchange.java     |  45 ++++
 .../james/protocols/api/sasl/SaslFailure.java      |  66 ++++++
 .../james/protocols/api/sasl/SaslIdentity.java     |  31 +++
 .../protocols/api/sasl/SaslInitialRequest.java     |  42 ++++
 .../james/protocols/api/sasl/SaslMechanism.java    |  44 ++++
 .../protocols/api/sasl/SaslMechanismFactory.java   |  31 +++
 .../protocols/api/sasl/SaslMechanismNames.java     |  29 +++
 .../apache/james/protocols/api/sasl/SaslStep.java  |  66 ++++++
 .../api/sasl/SaslMechanismContractTest.java        | 249 +++++++++++++++++++++
 11 files changed, 678 insertions(+)

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 0000000000..f3c2c10a69
--- /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 0000000000..18b21d7810
--- /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<Username> 
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 0000000000..49921941d2
--- /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 0000000000..385ef2140b
--- /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<Username> authenticationId,
+                          Optional<Username> authorizationId,
+                          String reason,
+                          Optional<Throwable> 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<Username> authorizationId, String reason) {
+        return new SaslFailure(Type.INVALID_CREDENTIALS, 
Optional.of(authenticationId), authorizationId, reason, Optional.empty());
+    }
+
+    public static SaslFailure authenticationFailed(Optional<Username> 
authenticationId, Optional<Username> 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<Username> authenticationId, 
Optional<Username> 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 0000000000..7038651fcd
--- /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 0000000000..4e245eebd1
--- /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<byte[]> 
initialResponse) {
+    public SaslInitialRequest(String mechanismName, Optional<byte[]> 
initialResponse) {
+        this.mechanismName = mechanismName;
+        this.initialResponse = initialResponse.map(byte[]::clone);
+    }
+
+    /**
+     * Returns a defensive copy of the decoded initial client response.
+     */
+    public Optional<byte[]> 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 0000000000..3be6de623b
--- /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 0000000000..77fb9e5e70
--- /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<ImmutableNode> 
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 0000000000..70e76d0503
--- /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 0000000000..37608f30d5
--- /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<byte[]> payload) implements SaslStep {
+        public Challenge(Optional<byte[]> payload) {
+            this.payload = payload.map(byte[]::clone);
+        }
+
+        /**
+         * Returns a defensive copy of the decoded challenge payload.
+         */
+        public Optional<byte[]> payload() {
+            return payload.map(byte[]::clone);
+        }
+    }
+
+    /**
+     * Successful SASL exchange result.
+     */
+    record Success(SaslIdentity identity, Optional<byte[]> serverData) 
implements SaslStep {
+        public Success(SaslIdentity identity, Optional<byte[]> serverData) {
+            this.identity = identity;
+            this.serverData = serverData.map(byte[]::clone);
+        }
+
+        /**
+         * Returns a defensive copy of the decoded final server data.
+         */
+        public Optional<byte[]> 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 0000000000..2b4054284a
--- /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("[email protected]");
+    private static final Username AUTHORIZATION_ID = 
Username.of("[email protected]");
+    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<Username> 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<byte[]> 
initialResponse) {
+        return new SaslInitialRequest("TEST", initialResponse);
+    }
+
+    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