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

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

commit 08ff441915cc36ae697129210aa8b25af71b6140
Author: Quan Tran <[email protected]>
AuthorDate: Wed Jun 24 11:18:48 2026 +0700

    JAMES-4210 Add SMTP AuthHook SASL migration adapter
    
    Keep legacy SMTP AuthHook registrations usable by wrapping them as a PLAIN 
SASL mechanism, add post-auth result hook support, deprecate AuthHook, and 
document migration expectations.
---
 .../servers/partials/configure/smtp-hooks.adoc     |   5 +-
 .../servers/partials/customization/smtp-hooks.adoc |   6 +-
 .../smtp/core/esmtp/AuthHookSaslMechanism.java     | 270 +++++++++++++++++++++
 .../apache/james/protocols/smtp/hook/AuthHook.java |  10 +-
 .../{AuthHook.java => SaslAuthResultHook.java}     |  30 +--
 .../james/smtpserver/ConfigurationAuthHook.java    |   5 +-
 .../james/smtpserver/CoreCmdHandlerLoader.java     |   1 -
 .../james/smtpserver/UsersRepositoryAuthHook.java  |  57 +----
 .../smtp/core/esmtp/AuthHookSaslMechanismTest.java | 163 +++++++++++++
 upgrade-instructions.md                            |  18 ++
 10 files changed, 487 insertions(+), 78 deletions(-)

diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc 
b/docs/modules/servers/partials/configure/smtp-hooks.adoc
index 65ab5254fb..5028910546 100644
--- a/docs/modules/servers/partials/configure/smtp-hooks.adoc
+++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc
@@ -498,6 +498,9 @@ include::partial$configure/smtp-limitation-hook.adoc[]
 
 Declarative authentication.
 
+`ConfigurationAuthHook` is deprecated. Existing handler-chain registrations 
remain supported through the SMTP SASL
+adapter, while new authentication logic should use a dedicated SASL mechanism 
factory.
+
 It is possible to open and configure on a dedicated port (eg port 26) to 
accept application traffic in parallel of user
 traffic. Authentication is then done on the supplied configuration.
 
@@ -529,4 +532,4 @@ user accounts for those applications)
             </accounts>
         </handler>
     </handlerchain>
-....
\ No newline at end of file
+....
diff --git a/docs/modules/servers/partials/customization/smtp-hooks.adoc 
b/docs/modules/servers/partials/customization/smtp-hooks.adoc
index 8b27377659..207fec5b05 100644
--- a/docs/modules/servers/partials/customization/smtp-hooks.adoc
+++ b/docs/modules/servers/partials/customization/smtp-hooks.adoc
@@ -5,7 +5,8 @@ enqueued in the MailQueue, and before any mail processing takes 
place.
 
 The following interfaces allows interacting with the following commands:
 
- * *AuthHook*: Implement this interfaces to hook in the AUTH Command.
+ * *AuthHook*: Legacy hook for the AUTH Command. Prefer implementing a SASL 
mechanism factory for new authentication
+   logic, or a `SaslAuthResultHook` when only post-authentication side effects 
are needed.
 
 ....
     HookResult doAuth(SMTPSession session, Username username, String password);
@@ -52,6 +53,9 @@ The following interfaces allows interacting with the 
following commands:
 
 Register you hooks using 
xref:{pages-path}/configure/smtp.adoc[*smtpserver.xml*] handlerchain property.
 
+For AUTH, `AuthHook` is deprecated. Existing handler-chain password hooks 
remain supported through the SMTP SASL
+adapter while keeping `AuthCmdHandler` on the SASL exchange path.
+
 == Writing additional SMTP commands
 
 What to do if the Hook API is not enough for you ?
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java
new file mode 100644
index 0000000000..0c75392b88
--- /dev/null
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java
@@ -0,0 +1,270 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.protocols.smtp.core.esmtp;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+import org.apache.james.core.Username;
+import org.apache.james.protocols.api.Response;
+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.SaslMechanismNames;
+import org.apache.james.protocols.api.sasl.SaslStep;
+import org.apache.james.protocols.smtp.SMTPResponse;
+import org.apache.james.protocols.smtp.SMTPRetCode;
+import org.apache.james.protocols.smtp.SMTPSession;
+import org.apache.james.protocols.smtp.hook.AuthHook;
+import org.apache.james.protocols.smtp.hook.HookResult;
+import org.apache.james.protocols.smtp.hook.HookResultHook;
+import org.apache.james.protocols.smtp.hook.HookReturnCode;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Legacy AuthHook adapter exposed as a standard PLAIN SASL mechanism.
+ */
+class AuthHookSaslMechanism implements SaslMechanism {
+    static Optional<Response> terminalResponse(SaslExchange exchange) {
+        if (exchange instanceof AuthHookSaslMechanism.Exchange 
authHookExchange) {
+            return authHookExchange.terminalResponse();
+        }
+        return Optional.empty();
+    }
+
+    private final SaslMechanism plainMechanism;
+    private final ImmutableList<AuthHook> authHooks;
+    private final ImmutableList<HookResultHook> hookResultHooks;
+
+    AuthHookSaslMechanism(SaslMechanism plainMechanism, List<AuthHook> 
authHooks, List<HookResultHook> hookResultHooks) {
+        this.plainMechanism = plainMechanism;
+        this.authHooks = ImmutableList.copyOf(authHooks);
+        this.hookResultHooks = ImmutableList.copyOf(hookResultHooks);
+    }
+
+    @Override
+    public String name() {
+        return SaslMechanismNames.PLAIN;
+    }
+
+    @Override
+    public boolean isAvailableOnTransport(boolean channelEncrypted) {
+        return plainMechanism.isAvailableOnTransport(channelEncrypted);
+    }
+
+    @Override
+    public SaslExchange start(SaslInitialRequest request, SaslAuthenticator 
authenticator) {
+        throw new IllegalStateException("Legacy SMTP AuthHook adapter requires 
an SMTP session");
+    }
+
+    Exchange start(SaslInitialRequest request, SMTPSession session) {
+        return new Exchange(request.initialResponse(), session);
+    }
+
+    final class Exchange implements SaslExchange {
+        private final Optional<byte[]> initialResponse;
+        private final SMTPSession session;
+        private Optional<Response> terminalResponse;
+
+        private Exchange(Optional<byte[]> initialResponse, SMTPSession 
session) {
+            this.initialResponse = initialResponse;
+            this.session = session;
+            this.terminalResponse = Optional.empty();
+        }
+
+        @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() {
+        }
+
+        Optional<Response> terminalResponse() {
+            return terminalResponse;
+        }
+
+        private SaslStep authenticate(byte[] clientResponse) {
+            return parse(clientResponse)
+                .map(this::authenticate)
+                .orElseGet(() -> new 
SaslStep.Failure(SaslFailure.malformed("Malformed authentication command.")));
+        }
+
+        private SaslStep authenticate(PlainCredentials credentials) {
+            if (credentials.password().isEmpty()) {
+                return delegateWithHooks(credentials);
+            }
+            return authenticateWithHooks(credentials);
+        }
+
+        private SaslStep authenticateWithHooks(PlainCredentials credentials) {
+            return authHooks.stream()
+                .map(hook -> executeHook(hook, hook2 -> hook2.doAuth(session, 
credentials.authenticationId(), credentials.password()), credentials))
+                .flatMap(Optional::stream)
+                .findFirst()
+                .orElseGet(() -> invalidCredentials(credentials));
+        }
+
+        private SaslStep delegateWithHooks(PlainCredentials credentials) {
+            return authHooks.stream()
+                .map(hook -> executeHook(hook, hook2 -> 
hook2.doDelegation(session, credentials.authenticationId()), credentials))
+                .flatMap(Optional::stream)
+                .findFirst()
+                .orElseGet(() -> invalidCredentials(credentials));
+        }
+
+        private Optional<SaslStep> executeHook(AuthHook hook, 
Function<AuthHook, HookResult> operation, PlainCredentials credentials) {
+            long start = System.currentTimeMillis();
+            HookResult hookResult = operation.apply(hook);
+            long executionTime = System.currentTimeMillis() - start;
+            HookResult decoratedHookResult = hookResultHooks.stream()
+                .reduce(hookResult,
+                    (result, hookResultHook) -> 
hookResultHook.onHookResult(session, result, executionTime, hook),
+                    (left, right) -> {
+                        throw new UnsupportedOperationException();
+                    });
+
+            return toSaslStep(decoratedHookResult, credentials);
+        }
+
+        private Optional<SaslStep> toSaslStep(HookResult hookResult, 
PlainCredentials credentials) {
+            if (hookResult == null) {
+                return Optional.empty();
+            }
+            HookReturnCode.Action action = hookResult.getResult().getAction();
+            Optional<Response> response = toSmtpResponse(hookResult);
+            if (response.isEmpty()) {
+                return Optional.empty();
+            }
+            terminalResponse = response;
+            return switch (action) {
+                case OK -> {
+                    Username authorizedUser = 
Optional.ofNullable(session.getUsername())
+                        .or(() -> credentials.authorizationId())
+                        .orElse(credentials.authenticationId());
+                    yield Optional.of(new SaslStep.Success(
+                        new SaslIdentity(credentials.authenticationId(), 
authorizedUser),
+                        Optional.empty()));
+                }
+                case DENY -> Optional.of(new 
SaslStep.Failure(SaslFailure.invalidCredentials(
+                    credentials.authenticationId(),
+                    credentials.authorizationId(),
+                    failureReason(hookResult, "Invalid credentials"))));
+                case DENYSOFT, DECLINED, NONE -> Optional.of(new 
SaslStep.Failure(SaslFailure.serverError(
+                    Optional.of(credentials.authenticationId()),
+                    credentials.authorizationId(),
+                    failureReason(hookResult, "Authentication failed"))));
+            };
+        }
+
+        private Optional<Response> toSmtpResponse(HookResult hookResult) {
+            HookReturnCode returnCode = hookResult.getResult();
+            if 
(!HookReturnCode.Action.ACTIVE_ACTIONS.contains(returnCode.getAction())) {
+                return returnCode.isDisconnected() ? 
Optional.of(Response.DISCONNECT) : Optional.empty();
+            }
+
+            String smtpReturnCode = 
Optional.ofNullable(hookResult.getSmtpRetCode())
+                .orElseGet(() -> 
defaultSmtpReturnCode(returnCode.getAction()));
+            String smtpDescription = 
Optional.ofNullable(hookResult.getSmtpDescription())
+                .orElseGet(() -> 
defaultSmtpDescription(returnCode.getAction()));
+            SMTPResponse response = new SMTPResponse(smtpReturnCode, 
smtpDescription);
+            if (returnCode.isDisconnected() && returnCode.getAction() != 
HookReturnCode.Action.OK) {
+                response.setEndSession(true);
+            }
+            return Optional.of(response);
+        }
+
+        private String defaultSmtpReturnCode(HookReturnCode.Action action) {
+            return switch (action) {
+                case OK -> SMTPRetCode.AUTH_OK;
+                case DENY -> SMTPRetCode.AUTH_FAILED;
+                case DENYSOFT -> SMTPRetCode.LOCAL_ERROR;
+                case DECLINED, NONE -> throw new IllegalArgumentException("No 
SMTP response for declined AuthHook result");
+            };
+        }
+
+        private String defaultSmtpDescription(HookReturnCode.Action action) {
+            return switch (action) {
+                case OK -> "Authentication Successful";
+                case DENY -> "Authentication Failed";
+                case DENYSOFT -> "Temporary problem. Please try again later";
+                case DECLINED, NONE -> throw new IllegalArgumentException("No 
SMTP response for declined AuthHook result");
+            };
+        }
+
+        private SaslStep invalidCredentials(PlainCredentials credentials) {
+            return new SaslStep.Failure(SaslFailure.invalidCredentials(
+                credentials.authenticationId(),
+                credentials.authorizationId(),
+                "Invalid credentials"));
+        }
+
+        private String failureReason(HookResult hookResult, String 
defaultReason) {
+            return Optional.ofNullable(hookResult.getSmtpDescription())
+                .orElse(defaultReason);
+        }
+
+    }
+
+    private 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() == 4 && tokens.get(3).isEmpty()) {
+            return credentials(tokens.subList(0, 3));
+        }
+        return credentials(tokens);
+    }
+
+    private Optional<PlainCredentials> credentials(List<String> tokens) {
+        try {
+            if (tokens.size() == 2) {
+                return Optional.of(new PlainCredentials(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(new PlainCredentials(authorizationId, 
Username.of(tokens.get(1)), tokens.get(2)));
+            }
+            return Optional.empty();
+        } catch (IllegalArgumentException e) {
+            return Optional.empty();
+        }
+    }
+
+    private record PlainCredentials(Optional<Username> authorizationId, 
Username authenticationId, String password) {
+    }
+}
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
index 3a7234356c..af5d5b01d0 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
@@ -23,8 +23,12 @@ import org.apache.james.jwt.OidcSASLConfiguration;
 import org.apache.james.protocols.smtp.SMTPSession;
 
 /**
- * Implement this interfaces to hook in the AUTH Command
+ * Legacy SMTP authentication hook.
+ *
+ * @deprecated Implement {@code SaslMechanismFactory} for authentication, or 
{@link SaslAuthResultHook}
+ * for post-authentication side effects.
  */
+@Deprecated
 public interface AuthHook extends Hook {
 
     /**
@@ -37,7 +41,9 @@ public interface AuthHook extends Hook {
      */
     HookResult doAuth(SMTPSession session, Username username, String password);
 
-    HookResult doSasl(SMTPSession session, OidcSASLConfiguration 
saslConfiguration, String initialResponse);
+    default HookResult doSasl(SMTPSession session, OidcSASLConfiguration 
saslConfiguration, String initialResponse) {
+        return HookResult.DECLINED;
+    }
 
     default HookResult doDelegation(SMTPSession session, Username target) {
         return HookResult.DECLINED;
diff --git 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java
similarity index 64%
copy from 
protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
copy to 
protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java
index 3a7234356c..caaf729433 100644
--- 
a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java
+++ 
b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java
@@ -16,30 +16,22 @@
  * specific language governing permissions and limitations      *
  * under the License.                                           *
  ****************************************************************/
+
 package org.apache.james.protocols.smtp.hook;
 
-import org.apache.james.core.Username;
-import org.apache.james.jwt.OidcSASLConfiguration;
+import org.apache.james.protocols.api.sasl.SaslFailure;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
 import org.apache.james.protocols.smtp.SMTPSession;
 
 /**
- * Implement this interfaces to hook in the AUTH Command
+ * Decorates terminal SMTP SASL authentication results without participating 
in credential validation.
+ * <p>
+ * Use this hook for legacy {@link AuthHook} use cases that only need 
authentication result side effects,
+ * such as audit events, notifications or metrics. Credential validation 
should be implemented by a SASL
+ * mechanism instead.
  */
-public interface AuthHook extends Hook {
-
-    /**
-     * Return the HookResult after run the hook
-     * 
-     * @param session the SMTPSession
-     * @param username the username
-     * @param password the password
-     * @return HockResult
-     */
-    HookResult doAuth(SMTPSession session, Username username, String password);
-
-    HookResult doSasl(SMTPSession session, OidcSASLConfiguration 
saslConfiguration, String initialResponse);
+public interface SaslAuthResultHook extends Hook {
+    void onSuccess(SMTPSession session, String mechanismName, SaslIdentity 
identity);
 
-    default HookResult doDelegation(SMTPSession session, Username target) {
-        return HookResult.DECLINED;
-    }
+    void onFailure(SMTPSession session, String mechanismName, SaslFailure 
failure);
 }
diff --git 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java
 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java
index f68527afc1..22e575ddff 100644
--- 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java
+++ 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java
@@ -44,9 +44,10 @@ import com.google.common.collect.Multimap;
 /**
  * Declarative authentication.
  *
- * This is helpful for things like service accounts, used by other 
applications (and it is not desirable to create
- * user accounts for those applications)
+ * @deprecated Prefer implementing a SASL mechanism factory. Existing 
handler-chain registrations
+ * are adapted by the SMTP AUTH handler during migration.
  */
+@Deprecated
 public class ConfigurationAuthHook implements AuthHook {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(ConfigurationAuthHook.class);
 
diff --git 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java
 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java
index c0a6c8b9ea..87d5e0e492 100644
--- 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java
+++ 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java
@@ -61,7 +61,6 @@ public class CoreCmdHandlerLoader implements HandlersPackage {
             RsetCmdHandler.class.getName(),
             VrfyCmdHandler.class.getName(),
             MailSizeEsmtpExtension.class.getName(),
-            UsersRepositoryAuthHook.class.getName(),
             AuthRequiredToRelayRcptHook.class.getName(),
             SenderAuthIdentifyVerificationHook.class.getName(),
             AuthRequiredHook.class.getName(),
diff --git 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
index d60b0dafcc..28ffdfcdf6 100644
--- 
a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
+++ 
b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
@@ -23,11 +23,6 @@ import java.util.Optional;
 import jakarta.inject.Inject;
 
 import org.apache.james.core.Username;
-import org.apache.james.jwt.OidcJwtTokenVerifier;
-import org.apache.james.jwt.OidcSASLConfiguration;
-import org.apache.james.mailbox.Authorizator;
-import org.apache.james.mailbox.exception.MailboxException;
-import org.apache.james.protocols.api.OIDCSASLParser;
 import org.apache.james.protocols.smtp.SMTPSession;
 import org.apache.james.protocols.smtp.hook.AuthHook;
 import org.apache.james.protocols.smtp.hook.HookResult;
@@ -38,19 +33,19 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * This Auth hook can be used to authenticate against the james user repository
+ * Legacy SMTP AuthHook backed by the James users repository.
+ *
+ * @deprecated Default SMTP authentication is now handled by PLAIN SASL and 
{@code SaslAuthenticator}.
  */
+@Deprecated
 public class UsersRepositoryAuthHook implements AuthHook {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(UsersRepositoryAuthHook.class);
 
     private final UsersRepository users;
-    private final Authorizator authorizator;
 
     @Inject
-    public UsersRepositoryAuthHook(UsersRepository users,
-                                   Authorizator authorizator) {
+    public UsersRepositoryAuthHook(UsersRepository users) {
         this.users = users;
-        this.authorizator = authorizator;
     }
 
     @Override
@@ -70,46 +65,4 @@ public class UsersRepositoryAuthHook implements AuthHook {
         }
         return HookResult.DECLINED;
     }
-
-    @Override
-    public HookResult doSasl(SMTPSession session, OidcSASLConfiguration 
configuration, String initialResponse) {
-        return OIDCSASLParser.parse(initialResponse)
-            .flatMap(oidcInitialResponseValue -> new 
OidcJwtTokenVerifier(configuration).validateToken(oidcInitialResponseValue.getToken())
-                .map(authenticatedUser -> {
-                    Username associatedUser = 
Username.of(oidcInitialResponseValue.getAssociatedUser());
-                    if (!associatedUser.equals(authenticatedUser)) {
-                        return doAuthWithDelegation(session, 
authenticatedUser, associatedUser);
-                    } else {
-                        return saslSuccess(session, authenticatedUser);
-                    }
-                })
-            )
-            .orElse(HookResult.DECLINED);
-    }
-
-    private HookResult doAuthWithDelegation(SMTPSession session, Username 
authenticatedUser, Username associatedUser) {
-        try {
-            if 
(Authorizator.AuthorizationState.ALLOWED.equals(authorizator.user(authenticatedUser).canLoginAs(associatedUser)))
 {
-                return saslSuccess(session, associatedUser);
-            }
-        } catch (MailboxException e) {
-            LOGGER.info("Unable to authorization", e);
-        }
-        return HookResult.DECLINED;
-    }
-
-    private HookResult saslSuccess(SMTPSession session, Username username) {
-        try {
-            users.assertValid(username);
-            session.setUsername(username);
-            session.setRelayingAllowed(true);
-            return HookResult.builder()
-                .hookReturnCode(HookReturnCode.ok())
-                .smtpDescription("Authentication successful.")
-                .build();
-        } catch (UsersRepositoryException e) {
-            LOGGER.warn("Invalid username", e);
-            return HookResult.DECLINED;
-        }
-    }
 }
diff --git 
a/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java
 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java
new file mode 100644
index 0000000000..5d8265031e
--- /dev/null
+++ 
b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java
@@ -0,0 +1,163 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.protocols.smtp.core.esmtp;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.apache.james.protocols.api.Response;
+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.apache.james.protocols.sasl.plain.PlainSaslMechanism;
+import org.apache.james.protocols.smtp.SMTPRetCode;
+import org.apache.james.protocols.smtp.SMTPSession;
+import org.apache.james.protocols.smtp.hook.AuthHook;
+import org.apache.james.protocols.smtp.hook.HookResult;
+import org.apache.james.protocols.smtp.hook.HookReturnCode;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+
+class AuthHookSaslMechanismTest {
+    private static final Username USERNAME = Username.of("[email protected]");
+
+    private final SMTPSession session = mock(SMTPSession.class);
+
+    @Test
+    void shouldDelegatePlainAvailabilityToWrappedMechanism() {
+        // GIVEN a PLAIN mechanism disabled by the SMTP configuration
+        AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism(
+            new PlainSaslMechanism(false, true),
+            ImmutableList.of(mock(AuthHook.class)),
+            ImmutableList.of());
+
+        // WHEN checking whether it is available on either transport
+        // THEN the legacy adapter preserves the configured policy
+        assertThat(mechanism.isAvailableOnTransport(false)).isFalse();
+        assertThat(mechanism.isAvailableOnTransport(true)).isFalse();
+    }
+
+    @Test
+    void shouldFailWhenAllAuthHooksDecline() {
+        // GIVEN a legacy hook declining the provided credentials
+        AuthHook authHook = mock(AuthHook.class);
+        when(authHook.doAuth(any(SMTPSession.class), eq(USERNAME), 
eq("password"))).thenReturn(HookResult.DECLINED);
+        SMTPSession session = mock(SMTPSession.class);
+        AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism(
+            new PlainSaslMechanism(),
+            ImmutableList.of(authHook),
+            ImmutableList.of());
+
+        // WHEN authenticating through the adapter
+        SaslStep step = 
mechanism.start(initialRequest("\[email protected]\0password"), 
session).firstStep();
+
+        // THEN the legacy terminal failure is preserved without a fallback 
login
+        assertThat(step).isEqualTo(new 
SaslStep.Failure(SaslFailure.invalidCredentials(
+            USERNAME,
+            Optional.empty(),
+            "Invalid credentials")));
+    }
+
+    @Test
+    void shouldDelegateEmptyPasswordToAuthHooks() {
+        // GIVEN the legacy empty-password delegation form
+        AuthHook authHook = mock(AuthHook.class);
+        when(authHook.doDelegation(eq(session), 
eq(USERNAME))).thenReturn(HookResult.builder()
+            .hookReturnCode(HookReturnCode.ok())
+            .build());
+        AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism(
+            new PlainSaslMechanism(),
+            ImmutableList.of(authHook),
+            ImmutableList.of());
+
+        // WHEN authenticating through the adapter
+        SaslStep step = mechanism.start(initialRequest("\[email protected]\0"), 
session).firstStep();
+
+        // THEN the legacy hook receives the delegation call
+        assertThat(step).isEqualTo(new SaslStep.Success(
+            new SaslIdentity(USERNAME, USERNAME),
+            Optional.empty()));
+        verify(authHook).doDelegation(session, USERNAME);
+    }
+
+    @Test
+    void shouldPreserveCustomDeniedResponseAndDisconnect() {
+        // GIVEN a legacy hook denying authentication with a custom response 
and disconnect policy
+        AuthHook authHook = mock(AuthHook.class);
+        when(authHook.doAuth(eq(session), eq(USERNAME), 
eq("password"))).thenReturn(HookResult.builder()
+            
.hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.DENY))
+            .smtpReturnCode("421")
+            .smtpDescription("Too many authentication attempts")
+            .build());
+        AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism(
+            new PlainSaslMechanism(),
+            ImmutableList.of(authHook),
+            ImmutableList.of());
+
+        // WHEN the adapter processes the PLAIN credentials
+        AuthHookSaslMechanism.Exchange exchange = 
mechanism.start(initialRequest("\[email protected]\0password"), session);
+        SaslStep step = exchange.firstStep();
+
+        // THEN the SASL failure keeps the legacy SMTP response for the 
protocol driver
+        assertThat(step).isInstanceOf(SaslStep.Failure.class);
+        Response response = 
AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow();
+        assertThat(response.getRetCode()).isEqualTo("421");
+        assertThat(response.getLines()).containsExactly("421 Too many 
authentication attempts");
+        assertThat(response.isEndSession()).isTrue();
+    }
+
+    @Test
+    void shouldKeepConnectionOpenForSuccessfulAuthHookResponse() {
+        // GIVEN a legacy hook accepting authentication while requesting the 
connection to close
+        AuthHook authHook = mock(AuthHook.class);
+        when(authHook.doAuth(eq(session), eq(USERNAME), 
eq("password"))).thenReturn(HookResult.builder()
+            
.hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.OK))
+            .smtpReturnCode(SMTPRetCode.AUTH_OK)
+            .smtpDescription("Authentication successful")
+            .build());
+        AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism(
+            new PlainSaslMechanism(),
+            ImmutableList.of(authHook),
+            ImmutableList.of());
+
+        // WHEN the adapter processes the PLAIN credentials
+        AuthHookSaslMechanism.Exchange exchange = 
mechanism.start(initialRequest("\[email protected]\0password"), session);
+        SaslStep step = exchange.firstStep();
+
+        // THEN SMTP success keeps the connection open
+        assertThat(step).isInstanceOf(SaslStep.Success.class);
+        
assertThat(AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow().isEndSession()).isFalse();
+    }
+
+    private SaslInitialRequest initialRequest(String value) {
+        return new SaslInitialRequest(SaslMechanismNames.PLAIN, 
Optional.of(value.getBytes(UTF_8)));
+    }
+}
diff --git a/upgrade-instructions.md b/upgrade-instructions.md
index 29987f7061..efa37b07ff 100644
--- a/upgrade-instructions.md
+++ b/upgrade-instructions.md
@@ -19,6 +19,24 @@ Change list:
  - [Adding thread_id column to Cassandra email_query_view_sent_at and 
email_query_view_received_at 
tables](#adding-thread_id-column-to-cassandra-email_query_view_sent_at-and-email_query_view_received_at-tables)
  - [Adding thread_id column to Postgresql email_query_view 
table](#adding-thread_id-column-to-postgresql-email_query_view-table)
  - [Lucene mailbox index schema update for collapseThreads 
support](#lucene-mailbox-index-schema-update-for-collapsethreads-support)
+ - [JAMES-4210 SMTP AuthHook 
deprecation](#james-4210-smtp-authhook-deprecation)
+
+### JAMES-4210 SMTP AuthHook deprecation
+
+Date: 24/06/2026
+
+Concerned products: SMTP servers declaring custom `AuthHook` implementations
+
+`AuthHook` is deprecated. Existing handler-chain registrations remain usable 
through the automatically applied
+`AuthHookSaslMechanism` compatibility adapter, but new and existing extensions 
should avoid relying on `AuthHook`
+going forward and migrate to dedicated SASL mechanisms.
+
+Extensions that only decorate authentication results for audit, notification 
or metrics should migrate to
+`SaslAuthResultHook`.
+
+Normal SMTP user authentication now relies on `PlainSaslMechanism`, not a 
default `UsersRepositoryAuthHook`.
+Deployments that relied on a custom `AuthHook` declining before 
`UsersRepositoryAuthHook` authenticated normal users
+must explicitly declare `UsersRepositoryAuthHook` after their custom hook in 
the SMTP handler chain.
 
 ### Adding metadata column to blob tables
 


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


Reply via email to