From 848578364108d94acc17fe29a77c9b5f7f065352 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 15:17:50 +0700 Subject: [PATCH 01/29] JAMES-4210 Introduce shared SASL SPI Introduce a protocol-neutral SASL SPI in `protocols/api`. The new API models SASL as a stateful exchange with protocol-neutral initial requests, continuation steps, success/failure results, and authentication identities. It also exposes password and bearer-token authentication service contracts through `SaslSessionContext` so future mechanism implementations do not depend directly on IMAP or SMTP classes. Add contract tests covering one-step mechanisms, multi-step mechanisms, password-like authentication through the context service contract, delegated identities, defensive byte-array copying, and exchange cleanup. --- .../BearerTokenSaslAuthenticationService.java | 32 ++ .../PasswordSaslAuthenticationService.java | 34 ++ .../api/sasl/SaslAuthenticationResult.java | 37 ++ .../protocols/api/sasl/SaslExchange.java | 43 ++ .../protocols/api/sasl/SaslIdentity.java | 31 ++ .../api/sasl/SaslInitialRequest.java | 49 +++ .../protocols/api/sasl/SaslMechanism.java | 45 +++ .../protocols/api/sasl/SaslProtocol.java | 30 ++ .../api/sasl/SaslSessionContext.java | 47 +++ .../james/protocols/api/sasl/SaslStep.java | 73 ++++ .../api/sasl/SaslMechanismContractTest.java | 381 ++++++++++++++++++ 11 files changed, 802 insertions(+) create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java new file mode 100644 index 00000000000..a185febcf78 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.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; + +import org.apache.james.core.Username; + +/** + * Protocol-provided service used by bearer-token based SASL mechanisms. + */ +public interface BearerTokenSaslAuthenticationService { + /** + * Authenticates the token and returns the authenticated SASL identity. + */ + SaslAuthenticationResult authenticate(String token, Username authorizationId); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java new file mode 100644 index 00000000000..649dfa84141 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.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.api.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; + +/** + * Protocol-provided service used by password based SASL mechanisms. + */ +public interface PasswordSaslAuthenticationService { + /** + * Authenticates the supplied credentials and returns the authenticated SASL identity. + */ + SaslAuthenticationResult authenticate(Username authenticationId, String password, Optional authorizationId); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java new file mode 100644 index 00000000000..c779a6f4fb9 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java @@ -0,0 +1,37 @@ +/**************************************************************** + * 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 returned by protocol-provided authentication services. + */ +public interface SaslAuthenticationResult { + /** + * Successful authentication result. + */ + record Success(SaslIdentity identity, String log) implements SaslAuthenticationResult { + } + + /** + * Failed authentication result. + */ + record Failure(String log) implements SaslAuthenticationResult { + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java new file mode 100644 index 00000000000..47c491d1ed8 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.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; + +/** + * 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. + */ + void abort(); + + @Override + void close(); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java new file mode 100644 index 00000000000..7038651fcd5 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import org.apache.james.core.Username; + +/** + * SASL authentication and authorization identities. + * + * @param authenticationId identity proven by the SASL mechanism + * @param authorizationId identity requested for protocol access + */ +public record SaslIdentity(Username authenticationId, Username authorizationId) { +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java new file mode 100644 index 00000000000..16393661a2b --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java @@ -0,0 +1,49 @@ +/**************************************************************** + * 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.Objects; +import java.util.Optional; + +/** + * Protocol-neutral initial SASL request. + * + * @param protocol protocol receiving the SASL exchange + * @param mechanismName requested SASL mechanism name + * @param initialResponse decoded initial client response, when supplied by the client + */ +public record SaslInitialRequest(SaslProtocol protocol, String mechanismName, Optional initialResponse) { + public SaslInitialRequest(SaslProtocol protocol, String mechanismName, Optional initialResponse) { + Objects.requireNonNull(protocol); + Objects.requireNonNull(mechanismName); + Objects.requireNonNull(initialResponse); + + this.protocol = protocol; + this.mechanismName = mechanismName; + this.initialResponse = initialResponse.map(byte[]::clone); + } + + /** + * Returns a defensive copy of the decoded initial client response. + */ + public Optional initialResponse() { + return initialResponse.map(byte[]::clone); + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java new file mode 100644 index 00000000000..461f977b8ce --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.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; + +/** + * Protocol-neutral SASL mechanism. + */ +public interface SaslMechanism { + /** + * Returns the SASL mechanism name advertised to clients. + */ + String name(); + + /** + * Whether this mechanism can be used by the supplied protocol. + */ + boolean supports(SaslProtocol protocol); + + /** + * Whether this mechanism is currently usable for the supplied session context. + */ + boolean isAvailable(SaslSessionContext context); + + /** + * Starts a new SASL exchange for one client authentication attempt. + */ + SaslExchange start(SaslInitialRequest request, SaslSessionContext context); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java new file mode 100644 index 00000000000..b422e86592c --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java @@ -0,0 +1,30 @@ +/**************************************************************** + * 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; + +/** + * Protocols that can host SASL authentication. + */ +public enum SaslProtocol { + IMAP, + SMTP, + MANAGESIEVE, + POP3 +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java new file mode 100644 index 00000000000..bd963b9e67d --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java @@ -0,0 +1,47 @@ +/**************************************************************** + * 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-provided context exposed to SASL mechanisms. + */ +public interface SaslSessionContext { + /** + * Protocol currently running the SASL exchange. + */ + SaslProtocol protocol(); + + /** + * Whether TLS is active for the current session. + */ + boolean isTlsStarted(); + + /** + * Looks up optional protocol or server configuration required by a mechanism. + */ + Optional configuration(Class configurationType); + + /** + * Looks up protocol-provided services, such as password or bearer-token authentication. + */ + Optional service(Class serviceType); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java new file mode 100644 index 00000000000..555b88c8091 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java @@ -0,0 +1,73 @@ +/**************************************************************** + * 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.Objects; +import java.util.Optional; + +/** + * Server step produced by a SASL exchange. + */ +public interface SaslStep { + /** + * Server challenge to send back to the client. + */ + record Challenge(Optional payload) implements SaslStep { + public Challenge { + payload = Objects.requireNonNull(payload) + .map(byte[]::clone); + } + + /** + * Returns a defensive copy of the decoded challenge payload. + */ + public Optional payload() { + return payload.map(byte[]::clone); + } + } + + /** + * Successful SASL exchange result. + */ + record Success(SaslIdentity identity, Optional serverData, String log) implements SaslStep { + public Success { + identity = Objects.requireNonNull(identity); + serverData = Objects.requireNonNull(serverData) + .map(byte[]::clone); + log = Objects.requireNonNull(log); + } + + /** + * Returns a defensive copy of the decoded final server data. + */ + public Optional serverData() { + return serverData.map(byte[]::clone); + } + } + + /** + * Failed SASL exchange result. + */ + record Failure(String log) implements SaslStep { + public Failure { + log = Objects.requireNonNull(log); + } + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java new file mode 100644 index 00000000000..e97ed3c2334 --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java @@ -0,0 +1,381 @@ +/**************************************************************** + * 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.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.junit.jupiter.api.Test; + +/** + * Validates the shared SASL SPI shape with fake mechanisms before wiring real protocol mechanisms to it. + */ +class SaslMechanismContractTest { + private static final Username AUTHENTICATION_ID = Username.of("authentication@example.com"); + private static final Username AUTHORIZATION_ID = Username.of("authorization@example.com"); + private static final SaslIdentity SAME_USER_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID); + private static final SaslIdentity DELEGATED_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID); + + /** + * 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 boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP; + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return true; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + 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 boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP; + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return true; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + 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("response received before challenge"); + } + if (new String(clientResponse, StandardCharsets.UTF_8).equals("accepted")) { + return new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty(), "accepted"); + } + return new SaslStep.Failure("rejected"); + } + + @Override + public void abort() { + } + + @Override + public void close() { + } + } + + /** + * Models generic password mechanisms that parse SASL payloads but delegate credential verification to the protocol. + */ + private static class PasswordLikeMechanism implements SaslMechanism { + @Override + public String name() { + return "PASSWORD_LIKE"; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP; + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return context.service(PasswordSaslAuthenticationService.class).isPresent(); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + return new FixedStepExchange(request.initialResponse() + .map(payload -> authenticate(payload, context)) + .orElseGet(() -> new SaslStep.Failure("missing initial response"))); + } + + private SaslStep authenticate(byte[] payload, SaslSessionContext context) { + return context.service(PasswordSaslAuthenticationService.class) + .map(service -> service.authenticate(authenticationId(payload), password(payload), authorizationId(payload))) + .map(this::toSaslStep) + .orElseGet(() -> new SaslStep.Failure("missing password authentication service")); + } + + private Username authenticationId(byte[] payload) { + return Username.of(parts(payload)[1]); + } + + private Optional authorizationId(byte[] payload) { + return Optional.of(parts(payload)[0]) + .filter(value -> !value.isEmpty()) + .map(Username::of); + } + + private String password(byte[] payload) { + return parts(payload)[2]; + } + + private String[] parts(byte[] payload) { + return new String(payload, StandardCharsets.UTF_8).split("\u0000", -1); + } + + private SaslStep toSaslStep(SaslAuthenticationResult authenticationResult) { + if (authenticationResult instanceof SaslAuthenticationResult.Success(SaslIdentity identity, String log)) { + return new SaslStep.Success(identity, Optional.empty(), log); + } + return new SaslStep.Failure(((SaslAuthenticationResult.Failure) authenticationResult).log()); + } + } + + private static class FakeSaslSessionContext implements SaslSessionContext { + private final Map, Object> services; + + private FakeSaslSessionContext(Map, Object> services) { + this.services = services; + } + + private static FakeSaslSessionContext withPasswordAuthenticationService(PasswordSaslAuthenticationService service) { + Map, Object> services = new HashMap<>(); + services.put(PasswordSaslAuthenticationService.class, service); + return new FakeSaslSessionContext(services); + } + + @Override + public SaslProtocol protocol() { + return SaslProtocol.IMAP; + } + + @Override + public boolean isTlsStarted() { + return true; + } + + @Override + public Optional configuration(Class configurationType) { + return Optional.empty(); + } + + @Override + public Optional service(Class serviceType) { + return Optional.ofNullable(services.get(serviceType)) + .map(serviceType::cast); + } + } + + @Test + void oneStepMechanismShouldReturnSuccess() { + // Given a one-step mechanism configured to immediately succeed + SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty(), "success"); + SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty()), new FakeSaslSessionContext(Map.of())); + + // 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("failure"); + SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty()), new FakeSaslSessionContext(Map.of())); + + // 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()), new FakeSaslSessionContext(Map.of())); + + // 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 passwordLikeMechanismShouldAuthenticateThroughSessionContextService() { + // Given a protocol-provided password service and a PLAIN-like initial response + PasswordSaslAuthenticationService service = (authenticationId, password, authorizationId) -> { + assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); + assertThat(password).isEqualTo("secret"); + assertThat(authorizationId).isEmpty(); + return new SaslAuthenticationResult.Success(SAME_USER_IDENTITY, "password accepted"); + }; + SaslExchange exchange = new PasswordLikeMechanism() + .start(initialRequest(Optional.of(bytes("\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), + FakeSaslSessionContext.withPasswordAuthenticationService(service)); + + // When the generic mechanism consumes the initial response + SaslStep firstStep = exchange.firstStep(); + + // Then authentication succeeds without depending on an IMAP or SMTP class + assertThat(((SaslStep.Success) firstStep).identity()).isEqualTo(SAME_USER_IDENTITY); + } + + @Test + void passwordLikeMechanismShouldPreserveDelegatedIdentity() { + // Given a PLAIN-like initial response with distinct authorization and authentication identities + PasswordSaslAuthenticationService service = (authenticationId, password, authorizationId) -> { + assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); + assertThat(password).isEqualTo("secret"); + assertThat(authorizationId).contains(AUTHORIZATION_ID); + return new SaslAuthenticationResult.Success(DELEGATED_IDENTITY, "delegation accepted"); + }; + SaslExchange exchange = new PasswordLikeMechanism() + .start(initialRequest(Optional.of(bytes(AUTHORIZATION_ID.asString() + "\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), + FakeSaslSessionContext.withPasswordAuthenticationService(service)); + + // When the generic mechanism authenticates through the context service + SaslStep firstStep = exchange.firstStep(); + + // Then the success step carries both identities for protocol-level delegation handling + 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), "success"); + + // 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("failure")); + + // When the protocol aborts and then closes it + exchange.abort(); + exchange.close(); + + // Then mechanisms can observe both lifecycle events + assertThat(exchange.aborted).isTrue(); + assertThat(exchange.closed).isTrue(); + } + + private static SaslInitialRequest initialRequest(Optional initialResponse) { + return new SaslInitialRequest(SaslProtocol.IMAP, "TEST", initialResponse); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From 7599d289df5d6799196cad7cf361fad83fffd47a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 15:38:00 +0700 Subject: [PATCH 02/29] JAMES-4210 Add IMAP SASL bridge scaffold Add a minimal IMAP bridge for the shared SASL SPI. The bridge keeps IMAP-specific wire handling outside the generic SPI: it converts IMAP AUTHENTICATE input to SaslInitialRequest, handles the SASL-IR "=" empty initial response marker, base64-encodes challenge continuations, decodes client continuation lines, and wires abort/close lifecycle handling around SaslExchange. Add unit tests for initial response decoding, continuation formatting, client response decoding, and exchange cleanup. --- .../imap/processor/sasl/ImapSaslBridge.java | 88 +++++++++ .../processor/sasl/ImapSaslBridgeTest.java | 170 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java create mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java new file mode 100644 index 00000000000..3b3776848ce --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java @@ -0,0 +1,88 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslStep; + +public class ImapSaslBridge { + /** + * Converts an IMAP AUTHENTICATE request into a protocol-neutral SASL initial request. + */ + public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { + return new SaslInitialRequest(SaslProtocol.IMAP, mechanismName, + Objects.requireNonNull(initialClientResponse).map(this::decodeInitialClientResponse)); + } + + /** + * Encodes a SASL challenge payload as an IMAP continuation payload. + */ + public String continuation(SaslStep.Challenge challenge) { + return challenge.payload() + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + } + + /** + * Decodes an IMAP client continuation line and forwards it to the SASL exchange. + */ + public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { + return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); + } + + /** + * Aborts and closes an active SASL exchange. + */ + public void abort(SaslExchange exchange) { + exchange.abort(); + exchange.close(); + } + + /** + * Closes an active SASL exchange. + */ + public void close(SaslExchange exchange) { + exchange.close(); + } + + private byte[] decodeBase64(String value) { + return Base64.getDecoder().decode(value); + } + + private byte[] decodeInitialClientResponse(String value) { + if (value.equals("=")) { + return new byte[0]; + } + return decodeBase64(value); + } + + private String stripTrailingCrlf(byte[] line) { + String value = new String(line, StandardCharsets.US_ASCII); + return StringUtils.stripEnd(value, "\r\n"); + } +} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java new file mode 100644 index 00000000000..a14d2cfff46 --- /dev/null +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java @@ -0,0 +1,170 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class ImapSaslBridgeTest { + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final ImapSaslBridge testee = new ImapSaslBridge(); + + private static class RecordingExchange implements SaslExchange { + private final List lifecycleEvents; + private byte[] lastClientResponse; + + private RecordingExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + lastClientResponse = clientResponse.clone(); + return new SaslStep.Success(IDENTITY, Optional.empty(), "success"); + } + + @Override + public void abort() { + lifecycleEvents.add("abort"); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + + @Test + void initialRequestShouldDecodeInitialClientResponse() { + String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); + + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); + + assertThat(request.protocol()).isEqualTo(SaslProtocol.IMAP); + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + } + + @Test + void continuationShouldBase64EncodeChallengePayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("challenge"))); + + String continuation = testee.continuation(challenge); + + assertThat(continuation).isEqualTo(Base64.getEncoder().encodeToString(bytes("challenge"))); + } + + @Test + void continuationShouldReturnEmptyStringWhenChallengeHasNoPayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.empty()); + + String continuation = testee.continuation(challenge); + + assertThat(continuation).isEmpty(); + } + + @Test + void onClientResponseShouldDecodeLineAndContinueExchange() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldDecodeLineWithLfOnly() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void isAbortShouldDetectImapSaslAbortLine() { + assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldDetectImapSaslAbortLineWithLfOnly() { + assertThat(testee.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldRejectRegularClientResponse() { + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + assertThat(testee.isAbort(line)).isFalse(); + } + + @Test + void abortShouldAbortThenCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + + @Test + void closeShouldCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.close(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From 4adec8fff6153386cf8b60eef2b6d79435372baf Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 15:52:59 +0700 Subject: [PATCH 03/29] JAMES-4210 Add SMTP SASL bridge scaffold Add a minimal SMTP bridge for the shared SASL SPI. The bridge keeps SMTP-specific AUTH framing outside the generic SPI: it converts SMTP AUTH initial responses to SaslInitialRequest, handles the "=" empty initial response marker, maps SASL challenges to SMTP 334 responses, decodes client continuation lines, and wires abort/close lifecycle handling around SaslExchange. Add unit tests for initial response decoding, SMTP challenge formatting, client response decoding, and exchange cleanup. --- .../smtp/core/esmtp/SmtpSaslBridge.java | 91 ++++++++++ .../smtp/core/esmtp/SmtpSaslBridgeTest.java | 157 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java create mode 100644 protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java new file mode 100644 index 00000000000..79800af9713 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java @@ -0,0 +1,91 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPResponse; +import org.apache.james.protocols.smtp.SMTPRetCode; + +public class SmtpSaslBridge { + /** + * Converts an SMTP AUTH request into a protocol-neutral SASL initial request. + */ + public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { + return new SaslInitialRequest(SaslProtocol.SMTP, mechanismName, + Objects.requireNonNull(initialClientResponse).map(this::decodeInitialClientResponse)); + } + + /** + * Encodes a SASL challenge payload as an SMTP AUTH 334 response. + */ + public SMTPResponse challenge(SaslStep.Challenge challenge) { + return new SMTPResponse(SMTPRetCode.AUTH_READY, + challenge.payload() + .map(Base64.getEncoder()::encodeToString) + .orElse("")); + } + + /** + * Decodes an SMTP client continuation line and forwards it to the SASL exchange. + */ + public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { + return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); + } + + /** + * Aborts and closes an active SASL exchange. + */ + public void abort(SaslExchange exchange) { + exchange.abort(); + exchange.close(); + } + + /** + * Closes an active SASL exchange. + */ + public void close(SaslExchange exchange) { + exchange.close(); + } + + private byte[] decodeBase64(String value) { + return Base64.getDecoder().decode(value); + } + + private byte[] decodeInitialClientResponse(String value) { + if (value.equals("=")) { + return new byte[0]; + } + return decodeBase64(value); + } + + private String stripTrailingCrlf(byte[] line) { + String value = new String(line, StandardCharsets.US_ASCII); + return StringUtils.stripEnd(value, "\r\n"); + } +} diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java new file mode 100644 index 00000000000..e92db541107 --- /dev/null +++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java @@ -0,0 +1,157 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPResponse; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.junit.jupiter.api.Test; + +class SmtpSaslBridgeTest { + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final SmtpSaslBridge testee = new SmtpSaslBridge(); + + private static class RecordingExchange implements SaslExchange { + private final List lifecycleEvents; + private byte[] lastClientResponse; + + private RecordingExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + lastClientResponse = clientResponse.clone(); + return new SaslStep.Success(IDENTITY, Optional.empty(), "success"); + } + + @Override + public void abort() { + lifecycleEvents.add("abort"); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + + @Test + void initialRequestShouldDecodeInitialClientResponse() { + String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); + + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); + + assertThat(request.protocol()).isEqualTo(SaslProtocol.SMTP); + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + } + + @Test + void challengeShouldReturnAuthReadyWithBase64EncodedChallengePayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("challenge"))); + + SMTPResponse response = testee.challenge(challenge); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly(SMTPRetCode.AUTH_READY + " " + Base64.getEncoder().encodeToString(bytes("challenge"))); + } + + @Test + void challengeShouldReturnAuthReadyWithEmptyDescriptionWhenChallengeHasNoPayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.empty()); + + SMTPResponse response = testee.challenge(challenge); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly(SMTPRetCode.AUTH_READY + " "); + } + + @Test + void onClientResponseShouldDecodeLineAndContinueExchange() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldDecodeLineWithLfOnly() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void abortShouldAbortThenCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + + @Test + void closeShouldCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.close(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From 9b05af70334d178647a4e086106417da3a290ebb Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 19:29:06 +0700 Subject: [PATCH 04/29] JAMES-4210 Introduce SASL mechanism registry --- .../api/sasl/SaslMechanismRegistry.java | 81 +++++++++++ .../api/sasl/SaslMechanismRegistryTest.java | 128 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java new file mode 100644 index 00000000000..0874b057e00 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java @@ -0,0 +1,81 @@ +/**************************************************************** + * 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.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * Registry exposing configured SASL mechanisms per protocol. + */ +public final class SaslMechanismRegistry { + private final ImmutableMap> mechanismsByProtocol; + + public SaslMechanismRegistry(Collection mechanisms) { + this.mechanismsByProtocol = Arrays.stream(SaslProtocol.values()) + .collect(ImmutableMap.toImmutableMap(Function.identity(), protocol -> mechanismsFor(mechanisms, protocol))); + } + + /** + * Finds a configured mechanism by protocol and case-insensitive SASL mechanism name. + */ + public Optional find(String mechanismName, SaslProtocol protocol) { + String normalizedName = normalize(mechanismName); + return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) + .stream() + .filter(mechanism -> normalize(mechanism.name()).equals(normalizedName)) + .findFirst(); + } + + /** + * Lists mechanisms configured for the protocol and currently available in the session context. + */ + public Stream availableFor(SaslProtocol protocol, SaslSessionContext context) { + return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) + .stream() + .filter(mechanism -> mechanism.isAvailable(context)); + } + + private ImmutableList mechanismsFor(Collection mechanisms, SaslProtocol protocol) { + return mechanisms.stream() + .filter(mechanism -> mechanism.supports(protocol)) + .collect(Collectors.toMap( + mechanism -> normalize(mechanism.name()), + Function.identity(), + (first, second) -> first, + LinkedHashMap::new)) + .values() + .stream() + .collect(ImmutableList.toImmutableList()); + } + + private String normalize(String mechanismName) { + return mechanismName.toUpperCase(Locale.US); + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java new file mode 100644 index 00000000000..c0fd1dbd51f --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java @@ -0,0 +1,128 @@ +/**************************************************************** + * 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.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class SaslMechanismRegistryTest { + private static final SaslSessionContext IMAP_CONTEXT = new FakeSaslSessionContext(SaslProtocol.IMAP); + private static final SaslSessionContext SMTP_CONTEXT = new FakeSaslSessionContext(SaslProtocol.SMTP); + + @Test + void findShouldBeCaseInsensitive() { + FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain)); + + assertThat(testee.find("plain", SaslProtocol.IMAP)).contains(plain); + } + + @Test + void findShouldFilterByProtocol() { + FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain)); + + assertThat(testee.find("PLAIN", SaslProtocol.SMTP)).isEmpty(); + } + + @Test + void availableForShouldFilterUnavailableMechanisms() { + FakeSaslMechanism available = new FakeSaslMechanism("AVAILABLE", Set.of(SaslProtocol.IMAP), true); + FakeSaslMechanism unavailable = new FakeSaslMechanism("UNAVAILABLE", Set.of(SaslProtocol.IMAP), false); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(available, unavailable)); + + assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(available); + } + + @Test + void constructorShouldDeduplicateSameProtocolAndMechanismName() { + FakeSaslMechanism firstPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); + FakeSaslMechanism secondPlain = new FakeSaslMechanism("plain", Set.of(SaslProtocol.IMAP), true); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(firstPlain, secondPlain)); + + assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(firstPlain); + assertThat(testee.find("PLAIN", SaslProtocol.IMAP)).contains(firstPlain); + } + + @Test + void constructorShouldDeduplicateIndependentlyPerProtocol() { + FakeSaslMechanism imapPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); + FakeSaslMechanism smtpPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.SMTP), true); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(imapPlain, smtpPlain)); + + assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(imapPlain); + assertThat(testee.availableFor(SaslProtocol.SMTP, SMTP_CONTEXT)).containsExactly(smtpPlain); + } + + private static class FakeSaslMechanism implements SaslMechanism { + private final String name; + private final Set supportedProtocols; + private final boolean available; + + private FakeSaslMechanism(String name, Set supportedProtocols, boolean available) { + this.name = name; + this.supportedProtocols = supportedProtocols; + this.available = available; + } + + @Override + public String name() { + return name; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return supportedProtocols.contains(protocol); + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return available; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + throw new UnsupportedOperationException(); + } + } + + private record FakeSaslSessionContext(SaslProtocol protocol) implements SaslSessionContext { + @Override + public boolean isTlsStarted() { + return true; + } + + @Override + public Optional configuration(Class configurationType) { + return Optional.empty(); + } + + @Override + public Optional service(Class serviceType) { + return Optional.empty(); + } + } +} From 84cffd0cf7726fae041a7c1c1eb729690ee9accf Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 19:30:03 +0700 Subject: [PATCH 05/29] JAMES-4210 Implement GuiceSaslMechanismLoader --- .../api/sasl/SaslMechanismLoader.java | 31 ++++++ .../sasl/SaslMechanismLoadingException.java | 29 +++++ server/container/guice/common/pom.xml | 8 ++ .../DefaultSaslMechanismNamingScheme.java | 36 ++++++ .../james/utils/GuiceSaslMechanismLoader.java | 53 +++++++++ .../TestingDefaultPackageSaslMechanism.java | 42 +++++++ .../DefaultSaslMechanismNamingSchemeTest.java | 35 ++++++ .../utils/ExternalFakeSaslMechanism.java | 48 ++++++++ .../utils/GuiceSaslMechanismLoaderTest.java | 105 ++++++++++++++++++ 9 files changed, 387 insertions(+) create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java create mode 100644 server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java create mode 100644 server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java new file mode 100644 index 00000000000..104531c47bd --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.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 java.util.Collection; + +import com.google.common.collect.ImmutableList; + +public interface SaslMechanismLoader { + /** + * Instantiates the configured SASL mechanism classes in declaration order. + */ + ImmutableList load(Collection classNames); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java new file mode 100644 index 00000000000..69ee9404d93 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.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; + +/** + * Thrown when a configured SASL mechanism cannot be loaded. + */ +public class SaslMechanismLoadingException extends RuntimeException { + public SaslMechanismLoadingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/container/guice/common/pom.xml b/server/container/guice/common/pom.xml index c6a56f70283..f8001ee29ee 100644 --- a/server/container/guice/common/pom.xml +++ b/server/container/guice/common/pom.xml @@ -74,6 +74,10 @@ ${james.groupId} james-server-guice-netty + + ${james.groupId} + james-server-guice-utils + ${james.groupId} james-server-mailrepository-memory @@ -126,6 +130,10 @@ testing-base test + + ${james.protocols.groupId} + protocols-api + com.github.stefanbirkner system-lambda diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java b/server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java new file mode 100644 index 00000000000..bd6c92f8fc1 --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java @@ -0,0 +1,36 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.james.protocols.api.sasl.SaslMechanism; + +/** + * Resolves simple SASL mechanism class names against James' default SASL SPI package. + */ +public final class DefaultSaslMechanismNamingScheme { + private static final PackageName DEFAULT_SASL_PACKAGE = PackageName.of(SaslMechanism.class.getPackageName()); + + public static NamingScheme asNamingScheme() { + return new NamingScheme.OptionalPackagePrefix(DEFAULT_SASL_PACKAGE); + } + + private DefaultSaslMechanismNamingScheme() { + } +} diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java new file mode 100644 index 00000000000..8ca1d5e6cdc --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import java.util.Collection; + +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismLoader; +import org.apache.james.protocols.api.sasl.SaslMechanismLoadingException; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Inject; + +public class GuiceSaslMechanismLoader implements SaslMechanismLoader { + private final GuiceLoader.InvocationPerformer mechanismLoader; + + @Inject + public GuiceSaslMechanismLoader(GuiceLoader guiceLoader) { + this.mechanismLoader = guiceLoader.withNamingSheme(DefaultSaslMechanismNamingScheme.asNamingScheme()); + } + + @Override + public ImmutableList load(Collection classNames) { + return classNames.stream() + .map(this::load) + .collect(ImmutableList.toImmutableList()); + } + + private SaslMechanism load(String className) { + try { + return mechanismLoader.instantiate(new ClassName(className)); + } catch (Exception e) { + throw new SaslMechanismLoadingException("Can not load SASL mechanism " + className, e); + } + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java new file mode 100644 index 00000000000..aff5e5b2817 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.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; + +public class TestingDefaultPackageSaslMechanism implements SaslMechanism { + @Override + public String name() { + return "DEFAULT"; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return true; + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return true; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + throw new UnsupportedOperationException(); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java new file mode 100644 index 00000000000..3932214dfbf --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DefaultSaslMechanismNamingSchemeTest { + @Test + void asNamingSchemeShouldResolveSimpleNameAgainstDefaultSaslPackage() { + // avoid breaking changes for default SASL package + assertThat(DefaultSaslMechanismNamingScheme.asNamingScheme() + .toFullyQualifiedClassNames(new ClassName("TestingDefaultPackageSaslMechanism")) + .map(FullyQualifiedClassName::getName)) + .contains("org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism"); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java new file mode 100644 index 00000000000..e6c6e718cc1 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; + +public class ExternalFakeSaslMechanism implements SaslMechanism { + @Override + public String name() { + return "EXTERNAL-FAKE"; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return true; + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return true; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + throw new UnsupportedOperationException(); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java new file mode 100644 index 00000000000..3b30416d7f0 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.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.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.List; + +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismLoadingException; +import org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism; +import org.junit.jupiter.api.Test; + +class GuiceSaslMechanismLoaderTest { + private static final FileSystem THROWING_FILE_SYSTEM = new FileSystem() { + @Override + public InputStream getResource(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public File getFile(String fileURL) throws FileNotFoundException { + throw new FileNotFoundException(); + } + + @Override + public File getBasedir() { + throw new UnsupportedOperationException(); + } + }; + + @Test + void loadShouldResolveSimpleNameFromDefaultSaslPackage() { + // GIVEN a loader using James default SASL package as implicit prefix + GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( + GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); + + // WHEN loading a simple class name + List mechanisms = testee.load(List.of("TestingDefaultPackageSaslMechanism")); + + // THEN the mechanism is instantiated from org.apache.james.protocols.api.sasl + assertThat(mechanisms).hasOnlyElementsOfType(TestingDefaultPackageSaslMechanism.class); + } + + @Test + void loadShouldResolveFullyQualifiedClassName() { + // GIVEN a loader that also accepts extension class names + GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( + GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); + + // WHEN loading a fully qualified class name + List mechanisms = testee.load(List.of(ExternalFakeSaslMechanism.class.getCanonicalName())); + + // THEN the mechanism is instantiated without relying on the default package + assertThat(mechanisms).hasOnlyElementsOfType(ExternalFakeSaslMechanism.class); + } + + @Test + void loadShouldFailWhenClassDoesNotExist() { + // GIVEN a loader used for configured SASL mechanism entries + GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( + GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); + + // WHEN loading an unknown class name + // THEN startup wiring can fail fast with the configured entry in the error + assertThatThrownBy(() -> testee.load(List.of("MissingSaslMechanism"))) + .isInstanceOf(SaslMechanismLoadingException.class) + .hasMessageContaining("MissingSaslMechanism"); + } + + @Test + void loadShouldFailWhenClassIsNotASaslMechanism() { + // GIVEN a configured class name that exists but does not implement the SASL SPI + GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( + GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); + + // WHEN loading that class + // THEN the loader rejects it instead of returning an invalid mechanism + assertThatThrownBy(() -> testee.load(List.of(Object.class.getCanonicalName()))) + .isInstanceOf(SaslMechanismLoadingException.class) + .hasMessageContaining(Object.class.getCanonicalName()); + } +} From 963b836710f6972e7fad1d4144e3b3022be3625b Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 16:03:27 +0700 Subject: [PATCH 06/29] JAMES-4210 Add protocol-neutral SASL mechanisms Introduce reusable PLAIN, OAUTHBEARER and XOAUTH2 SASL mechanisms in protocols-api. Add the service-factory SPI so mechanisms can declare protocol-provided services and the registry can initialize them per session. --- .../james/protocols/api/OIDCSASLParser.java | 60 ++++---- .../api/sasl/AbstractOidcSaslMechanism.java | 102 +++++++++++++ .../api/sasl/OauthBearerSaslMechanism.java | 29 ++++ .../PasswordSaslAuthenticationService.java | 2 +- .../api/sasl/PlainSaslMechanism.java | 136 ++++++++++++++++++ .../api/sasl/SaslAuthenticationResult.java | 4 +- .../SaslAuthenticationServiceFactory.java | 42 ++++++ .../protocols/api/sasl/SaslMechanism.java | 14 ++ .../api/sasl/SaslMechanismRegistry.java | 40 ++++++ .../api/sasl/SaslSessionContext.java | 18 +-- .../james/protocols/api/sasl/SaslStep.java | 9 +- .../api/sasl/XOauth2SaslMechanism.java | 29 ++++ .../api/sasl/OidcSaslMechanismTest.java | 96 +++++++++++++ .../api/sasl/PlainSaslMechanismTest.java | 108 ++++++++++++++ .../api/sasl/SaslMechanismContractTest.java | 42 +++--- .../api/sasl/SaslMechanismRegistryTest.java | 65 +++++++-- .../api/sasl/TestSaslSessionContext.java | 46 ++++++ 17 files changed, 756 insertions(+), 86 deletions(-) create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java index 1623998c954..be557f1cac5 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java @@ -58,41 +58,43 @@ public String getAssociatedUser() { } public static Optional parse(String initialResponse) { - Optional decodeResult = decodeBase64(initialResponse); + return decodeBase64(initialResponse) + .flatMap(OIDCSASLParser::parseDecoded); + } - if (decodeResult.isPresent()) { - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - String decodeValueWithoutDanglingPart = decodeResult.filter(value -> value.startsWith("n,")) - .map(value -> value.substring(2)) - .orElse(decodeResult.get()); + public static Optional parseDecoded(String decodedInitialResponse) { + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + String decodeValueWithoutDanglingPart = Optional.of(decodedInitialResponse) + .filter(value -> value.startsWith("n,")) + .map(value -> value.substring(2)) + .orElse(decodedInitialResponse); - StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); - String tokenPart = null; - String userPart = null; - int tokenPartCounter = 0; - int userPartCounter = 0; + StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); + String tokenPart = null; + String userPart = null; + int tokenPartCounter = 0; + int userPartCounter = 0; - while (stringTokenizer.hasMoreTokens()) { - String stringToken = stringTokenizer.nextToken(); - if (stringToken.startsWith(TOKEN_PART_PREFIX)) { - tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); - tokenPartCounter++; - } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { - userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); - userPartCounter++; - } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { - userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - if (userPart.endsWith(",")) { - userPart = userPart.substring(0, userPart.length() - 1); - } - userPartCounter++; + while (stringTokenizer.hasMoreTokens()) { + String stringToken = stringTokenizer.nextToken(); + if (stringToken.startsWith(TOKEN_PART_PREFIX)) { + tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); + tokenPartCounter++; + } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { + userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); + userPartCounter++; + } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { + userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + if (userPart.endsWith(",")) { + userPart = userPart.substring(0, userPart.length() - 1); } + userPartCounter++; } + } - if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { - return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); - } + if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { + return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); } return Optional.empty(); } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java new file mode 100644 index 00000000000..7d6db4edb8f --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java @@ -0,0 +1,102 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.OIDCSASLParser; + +abstract class AbstractOidcSaslMechanism implements SaslMechanism { + @Override + public boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP || protocol == SaslProtocol.SMTP; + } + + @Override + public Set> requiredServices(SaslProtocol protocol) { + if (supports(protocol)) { + return Set.of(BearerTokenSaslAuthenticationService.class); + } + return Set.of(); + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return context.service(BearerTokenSaslAuthenticationService.class).isPresent(); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + return new OidcSaslExchange(request.initialResponse(), context); + } + + private static class OidcSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final SaslSessionContext context; + + private OidcSaslExchange(Optional initialResponse, SaslSessionContext context) { + this.initialResponse = initialResponse; + this.context = context; + } + + @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 abort() { + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII)) + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); + } + + private SaslStep authenticate(OIDCSASLParser.OIDCInitialResponse initialResponse) { + return context.service(BearerTokenSaslAuthenticationService.class) + .map(service -> service.authenticate(initialResponse.getToken(), Username.of(initialResponse.getAssociatedUser()))) + .map(this::toStep) + .orElseGet(() -> new SaslStep.Failure("OIDC authentication is not available.")); + } + + private SaslStep toStep(SaslAuthenticationResult result) { + if (result instanceof SaslAuthenticationResult.Success success) { + return new SaslStep.Success(success.identity(), Optional.empty()); + } + return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); + } + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java new file mode 100644 index 00000000000..b555c25e613 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.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 class OauthBearerSaslMechanism extends AbstractOidcSaslMechanism { + public static final String NAME = "OAUTHBEARER"; + + @Override + public String name() { + return NAME; + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java index 649dfa84141..a70222bcd45 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java @@ -30,5 +30,5 @@ public interface PasswordSaslAuthenticationService { /** * Authenticates the supplied credentials and returns the authenticated SASL identity. */ - SaslAuthenticationResult authenticate(Username authenticationId, String password, Optional authorizationId); + SaslAuthenticationResult authenticate(Username authenticationId, Optional authorizationId, String password); } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java new file mode 100644 index 00000000000..43b4346322f --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java @@ -0,0 +1,136 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import org.apache.james.core.Username; + +import com.google.common.collect.ImmutableList; + +public class PlainSaslMechanism implements SaslMechanism { + public static final String NAME = "PLAIN"; + + protected record PlainCredentials(Optional authorizationId, Username authenticationId, String password) { + } + + protected static PlainCredentials credentials(Optional authorizationId, Username authenticationId, String password) { + return new PlainCredentials(authorizationId, authenticationId, password); + } + + @Override + public String name() { + return NAME; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP || protocol == SaslProtocol.SMTP; + } + + @Override + public Set> requiredServices(SaslProtocol protocol) { + if (supports(protocol)) { + return Set.of(PasswordSaslAuthenticationService.class); + } + return Set.of(); + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return context.service(PasswordSaslAuthenticationService.class).isPresent(); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + return new PlainSaslExchange(request.initialResponse(), context, this::parse); + } + + private static class PlainSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final SaslSessionContext context; + private final Function> credentialsParser; + + private PlainSaslExchange(Optional initialResponse, SaslSessionContext context, Function> credentialsParser) { + this.initialResponse = initialResponse; + this.context = context; + this.credentialsParser = credentialsParser; + } + + @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 abort() { + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return credentialsParser.apply(clientResponse) + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); + } + + private SaslStep authenticate(PlainCredentials credentials) { + return context.service(PasswordSaslAuthenticationService.class) + .map(service -> service.authenticate(credentials.authenticationId(), credentials.authorizationId(), credentials.password())) + .map(this::toStep) + .orElseGet(() -> new SaslStep.Failure("PLAIN authentication is not available.")); + } + + private SaslStep toStep(SaslAuthenticationResult result) { + if (result instanceof SaslAuthenticationResult.Success success) { + return new SaslStep.Success(success.identity(), Optional.empty()); + } + return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); + } + + } + + protected Optional parse(byte[] clientResponse) { + ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0")) + .filter(token -> !token.isBlank()) + .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) { + return Optional.of(credentials(Optional.of(Username.of(tokens.get(0))), Username.of(tokens.get(1)), tokens.get(2))); + } + return Optional.empty(); + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java index c779a6f4fb9..899abafa4bd 100644 --- 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 @@ -26,12 +26,12 @@ public interface SaslAuthenticationResult { /** * Successful authentication result. */ - record Success(SaslIdentity identity, String log) implements SaslAuthenticationResult { + record Success(SaslIdentity identity) implements SaslAuthenticationResult { } /** * Failed authentication result. */ - record Failure(String log) implements SaslAuthenticationResult { + record Failure(String reason) implements SaslAuthenticationResult { } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java new file mode 100644 index 00000000000..74e4369b89c --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.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-specific factory for services required by SASL mechanisms. + */ +public interface SaslAuthenticationServiceFactory { + /** + * Protocol supported by the produced service. + */ + SaslProtocol protocol(); + + /** + * Service type produced by this factory. + */ + Class serviceType(); + + /** + * Creates the service for the supplied SASL session context when available. + */ + Optional create(SaslSessionContext context); +} 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 index 461f977b8ce..5f41cae009f 100644 --- 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 @@ -19,6 +19,8 @@ package org.apache.james.protocols.api.sasl; +import java.util.Set; + /** * Protocol-neutral SASL mechanism. */ @@ -30,9 +32,21 @@ public interface SaslMechanism { /** * Whether this mechanism can be used by the supplied protocol. + * + *

A mechanism may intentionally support only a subset of protocols when its + * wire payload, authorization semantics, or surrounding protocol state is only + * valid for those protocols. For example, a custom PLAIN variant may support + * only IMAP when it relies on IMAP-specific delegation semantics. */ boolean supports(SaslProtocol protocol); + /** + * Lists protocol-provided service types required by this mechanism. + */ + default Set> requiredServices(SaslProtocol protocol) { + return Set.of(); + } + /** * Whether this mechanism is currently usable for the supplied session context. */ diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java index 0874b057e00..a0a73fcf299 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java @@ -36,10 +36,16 @@ */ public final class SaslMechanismRegistry { private final ImmutableMap> mechanismsByProtocol; + private final ImmutableList> serviceFactories; public SaslMechanismRegistry(Collection mechanisms) { + this(mechanisms, ImmutableList.of()); + } + + public SaslMechanismRegistry(Collection mechanisms, Collection> serviceFactories) { this.mechanismsByProtocol = Arrays.stream(SaslProtocol.values()) .collect(ImmutableMap.toImmutableMap(Function.identity(), protocol -> mechanismsFor(mechanisms, protocol))); + this.serviceFactories = ImmutableList.copyOf(serviceFactories); } /** @@ -62,6 +68,25 @@ public Stream availableFor(SaslProtocol protocol, SaslSessionCont .filter(mechanism -> mechanism.isAvailable(context)); } + /** + * Initializes services for all mechanisms configured for the protocol. + */ + public void initialize(SaslProtocol protocol, SaslSessionContext context) { + mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) + .stream() + .flatMap(mechanism -> mechanism.requiredServices(protocol).stream()) + .distinct() + .forEach(serviceType -> registerService(protocol, context, serviceType)); + } + + /** + * Lists configured mechanisms for the protocol, regardless of session availability. + */ + public Stream configuredFor(SaslProtocol protocol) { + return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) + .stream(); + } + private ImmutableList mechanismsFor(Collection mechanisms, SaslProtocol protocol) { return mechanisms.stream() .filter(mechanism -> mechanism.supports(protocol)) @@ -78,4 +103,19 @@ private ImmutableList mechanismsFor(Collection mec private String normalize(String mechanismName) { return mechanismName.toUpperCase(Locale.US); } + + private void registerService(SaslProtocol protocol, SaslSessionContext context, Class serviceType) { + serviceFactory(protocol, serviceType) + .flatMap(factory -> factory.create(context)) + .ifPresent(service -> context.register(serviceType, service)); + } + + @SuppressWarnings("unchecked") + private Optional> serviceFactory(SaslProtocol protocol, Class serviceType) { + return serviceFactories.stream() + .filter(factory -> factory.protocol() == protocol) + .filter(factory -> factory.serviceType().equals(serviceType)) + .findFirst() + .map(factory -> (SaslAuthenticationServiceFactory) factory); + } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java index bd963b9e67d..f64abdd29ed 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java @@ -26,22 +26,12 @@ */ public interface SaslSessionContext { /** - * Protocol currently running the SASL exchange. - */ - SaslProtocol protocol(); - - /** - * Whether TLS is active for the current session. - */ - boolean isTlsStarted(); - - /** - * Looks up optional protocol or server configuration required by a mechanism. + * Looks up protocol-provided services, such as password or bearer-token authentication. */ - Optional configuration(Class configurationType); + Optional service(Class serviceType); /** - * Looks up protocol-provided services, such as password or bearer-token authentication. + * Registers a protocol-provided service for the current SASL session. */ - Optional service(Class serviceType); + void register(Class serviceType, T service); } 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 index 555b88c8091..b792ca2f65b 100644 --- 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 @@ -25,7 +25,7 @@ /** * Server step produced by a SASL exchange. */ -public interface SaslStep { +public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Success, SaslStep.Failure { /** * Server challenge to send back to the client. */ @@ -46,12 +46,11 @@ public Optional payload() { /** * Successful SASL exchange result. */ - record Success(SaslIdentity identity, Optional serverData, String log) implements SaslStep { + record Success(SaslIdentity identity, Optional serverData) implements SaslStep { public Success { identity = Objects.requireNonNull(identity); serverData = Objects.requireNonNull(serverData) .map(byte[]::clone); - log = Objects.requireNonNull(log); } /** @@ -65,9 +64,9 @@ public Optional serverData() { /** * Failed SASL exchange result. */ - record Failure(String log) implements SaslStep { + record Failure(String reason) implements SaslStep { public Failure { - log = Objects.requireNonNull(log); + reason = Objects.requireNonNull(reason); } } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java new file mode 100644 index 00000000000..f8d4f8b1a5a --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.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 class XOauth2SaslMechanism extends AbstractOidcSaslMechanism { + public static final String NAME = "XOAUTH2"; + + @Override + public String name() { + return NAME; + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java new file mode 100644 index 00000000000..a37e035ffab --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java @@ -0,0 +1,96 @@ +/**************************************************************** + * 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 java.util.concurrent.atomic.AtomicReference; + +import org.apache.james.core.Username; +import org.junit.jupiter.api.Test; + +class OidcSaslMechanismTest { + private static final Username USER = Username.of("user@example.com"); + private static final String TOKEN = "token"; + + @Test + void oauthBearerShouldAuthenticateDecodedInitialResponse() { + AtomicReference token = new AtomicReference<>(); + AtomicReference authorizationId = new AtomicReference<>(); + BearerTokenSaslAuthenticationService service = (bearerToken, user) -> { + token.set(bearerToken); + authorizationId.set(user); + return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); + }; + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, OauthBearerSaslMechanism.NAME, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + SaslStep step = new OauthBearerSaslMechanism() + .start(request, new TestSaslSessionContext(Optional.empty(), Optional.of(service))) + .firstStep(); + + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(token.get()).isEqualTo(TOKEN); + assertThat(authorizationId.get()).isEqualTo(USER); + } + + @Test + void xOauth2ShouldAuthenticateDecodedInitialResponse() { + AtomicReference token = new AtomicReference<>(); + BearerTokenSaslAuthenticationService service = (bearerToken, user) -> { + token.set(bearerToken); + return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); + }; + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, XOauth2SaslMechanism.NAME, + Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + SaslStep step = new XOauth2SaslMechanism() + .start(request, new TestSaslSessionContext(Optional.empty(), Optional.of(service))) + .firstStep(); + + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(token.get()).isEqualTo(TOKEN); + } + + @Test + void shouldFailMalformedResponse() { + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, OauthBearerSaslMechanism.NAME, + Optional.of(bytes("invalid"))); + + SaslStep step = new OauthBearerSaslMechanism() + .start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())) + .firstStep(); + + assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); + } + + @Test + void shouldBeAvailableOnlyWhenBearerTokenServiceExists() { + assertThat(new OauthBearerSaslMechanism().isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.empty()))).isFalse(); + assertThat(new OauthBearerSaslMechanism().isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.of((token, user) -> + new SaslAuthenticationResult.Failure("failure"))))).isTrue(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java new file mode 100644 index 00000000000..adf34f84272 --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java @@ -0,0 +1,108 @@ +/**************************************************************** + * 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 java.util.concurrent.atomic.AtomicReference; + +import org.apache.james.core.Username; +import org.junit.jupiter.api.Test; + +class PlainSaslMechanismTest { + private static final Username AUTHENTICATION_ID = Username.of("user@example.com"); + private static final Username AUTHORIZATION_ID = Username.of("delegated@example.com"); + private static final String PASSWORD = "secret"; + + private final PlainSaslMechanism testee = new PlainSaslMechanism(); + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN a PLAIN exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = testee.start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldAuthenticateInitialResponseWithoutDelegation() { + AtomicReference authenticationId = new AtomicReference<>(); + AtomicReference password = new AtomicReference<>(); + AtomicReference> authorizationId = new AtomicReference<>(); + PasswordSaslAuthenticationService service = (user, delegatedUser, secret) -> { + authenticationId.set(user); + password.set(secret); + authorizationId.set(delegatedUser); + return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); + }; + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + SaslStep step = testee.start(request, new TestSaslSessionContext(Optional.of(service), Optional.empty())).firstStep(); + + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(authenticationId.get()).isEqualTo(AUTHENTICATION_ID); + assertThat(password.get()).isEqualTo(PASSWORD); + assertThat(authorizationId.get()).isEmpty(); + } + + @Test + void shouldAuthenticateContinuationResponseWithDelegation() { + AtomicReference> authorizationId = new AtomicReference<>(); + PasswordSaslAuthenticationService service = (user, delegatedUser, secret) -> { + authorizationId.set(delegatedUser); + return new SaslAuthenticationResult.Success(new SaslIdentity(user, delegatedUser.orElse(user))); + }; + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, Optional.empty()); + SaslExchange exchange = testee.start(request, new TestSaslSessionContext(Optional.of(service), Optional.empty())); + + SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)); + + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(authorizationId.get()).contains(AUTHORIZATION_ID); + } + + @Test + void shouldFailMalformedResponse() { + SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, + Optional.of(bytes("missing-separators"))); + + SaslStep step = testee.start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())).firstStep(); + + assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); + } + + @Test + void shouldBeAvailableOnlyWhenPasswordServiceExists() { + assertThat(testee.isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.empty()))).isFalse(); + assertThat(testee.isAvailable(new TestSaslSessionContext(Optional.of((user, authorizationId, password) -> + new SaslAuthenticationResult.Failure("failure")), Optional.empty()))).isTrue(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java index e97ed3c2334..e8f8bc85817 100644 --- 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 @@ -139,7 +139,7 @@ public SaslStep onResponse(byte[] clientResponse) { return new SaslStep.Failure("response received before challenge"); } if (new String(clientResponse, StandardCharsets.UTF_8).equals("accepted")) { - return new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty(), "accepted"); + return new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty()); } return new SaslStep.Failure("rejected"); } @@ -181,7 +181,7 @@ public SaslExchange start(SaslInitialRequest request, SaslSessionContext context private SaslStep authenticate(byte[] payload, SaslSessionContext context) { return context.service(PasswordSaslAuthenticationService.class) - .map(service -> service.authenticate(authenticationId(payload), password(payload), authorizationId(payload))) + .map(service -> service.authenticate(authenticationId(payload), authorizationId(payload), password(payload))) .map(this::toSaslStep) .orElseGet(() -> new SaslStep.Failure("missing password authentication service")); } @@ -205,10 +205,10 @@ private String[] parts(byte[] payload) { } private SaslStep toSaslStep(SaslAuthenticationResult authenticationResult) { - if (authenticationResult instanceof SaslAuthenticationResult.Success(SaslIdentity identity, String log)) { - return new SaslStep.Success(identity, Optional.empty(), log); + if (authenticationResult instanceof SaslAuthenticationResult.Success(SaslIdentity identity)) { + return new SaslStep.Success(identity, Optional.empty()); } - return new SaslStep.Failure(((SaslAuthenticationResult.Failure) authenticationResult).log()); + return new SaslStep.Failure(((SaslAuthenticationResult.Failure) authenticationResult).reason()); } } @@ -225,32 +225,22 @@ private static FakeSaslSessionContext withPasswordAuthenticationService(Password return new FakeSaslSessionContext(services); } - @Override - public SaslProtocol protocol() { - return SaslProtocol.IMAP; - } - - @Override - public boolean isTlsStarted() { - return true; - } - - @Override - public Optional configuration(Class configurationType) { - return Optional.empty(); - } - @Override public Optional service(Class serviceType) { return Optional.ofNullable(services.get(serviceType)) .map(serviceType::cast); } + + @Override + public void register(Class serviceType, T service) { + services.put(serviceType, service); + } } @Test void oneStepMechanismShouldReturnSuccess() { // Given a one-step mechanism configured to immediately succeed - SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty(), "success"); + SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty()); SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty()), new FakeSaslSessionContext(Map.of())); // When the exchange starts @@ -290,11 +280,11 @@ void multiStepMechanismShouldKeepStateAcrossResponses() { @Test void passwordLikeMechanismShouldAuthenticateThroughSessionContextService() { // Given a protocol-provided password service and a PLAIN-like initial response - PasswordSaslAuthenticationService service = (authenticationId, password, authorizationId) -> { + PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> { assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); assertThat(password).isEqualTo("secret"); assertThat(authorizationId).isEmpty(); - return new SaslAuthenticationResult.Success(SAME_USER_IDENTITY, "password accepted"); + return new SaslAuthenticationResult.Success(SAME_USER_IDENTITY); }; SaslExchange exchange = new PasswordLikeMechanism() .start(initialRequest(Optional.of(bytes("\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), @@ -310,11 +300,11 @@ void passwordLikeMechanismShouldAuthenticateThroughSessionContextService() { @Test void passwordLikeMechanismShouldPreserveDelegatedIdentity() { // Given a PLAIN-like initial response with distinct authorization and authentication identities - PasswordSaslAuthenticationService service = (authenticationId, password, authorizationId) -> { + PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> { assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); assertThat(password).isEqualTo("secret"); assertThat(authorizationId).contains(AUTHORIZATION_ID); - return new SaslAuthenticationResult.Success(DELEGATED_IDENTITY, "delegation accepted"); + return new SaslAuthenticationResult.Success(DELEGATED_IDENTITY); }; SaslExchange exchange = new PasswordLikeMechanism() .start(initialRequest(Optional.of(bytes(AUTHORIZATION_ID.asString() + "\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), @@ -346,7 +336,7 @@ void saslStepsShouldDefensivelyCopyPayloads() { 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), "success"); + SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.of(serverData)); // When the caller mutates the original arrays challengePayload[0] = 'C'; diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java index c0fd1dbd51f..930cc45ef05 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -29,8 +30,8 @@ import com.google.common.collect.ImmutableList; class SaslMechanismRegistryTest { - private static final SaslSessionContext IMAP_CONTEXT = new FakeSaslSessionContext(SaslProtocol.IMAP); - private static final SaslSessionContext SMTP_CONTEXT = new FakeSaslSessionContext(SaslProtocol.SMTP); + private static final SaslSessionContext IMAP_CONTEXT = new FakeSaslSessionContext(); + private static final SaslSessionContext SMTP_CONTEXT = new FakeSaslSessionContext(); @Test void findShouldBeCaseInsensitive() { @@ -77,14 +78,46 @@ void constructorShouldDeduplicateIndependentlyPerProtocol() { assertThat(testee.availableFor(SaslProtocol.SMTP, SMTP_CONTEXT)).containsExactly(smtpPlain); } + @Test + void initializeShouldRegisterRequiredServicesForConfiguredMechanisms() { + PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> new SaslAuthenticationResult.Failure("failed"); + FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true, Set.of(PasswordSaslAuthenticationService.class)); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain), + ImmutableList.of(new FakeSaslAuthenticationServiceFactory<>(SaslProtocol.IMAP, PasswordSaslAuthenticationService.class, service))); + FakeSaslSessionContext context = new FakeSaslSessionContext(); + + testee.initialize(SaslProtocol.IMAP, context); + + assertThat(context.service(PasswordSaslAuthenticationService.class)).contains(service); + } + + @Test + void initializeShouldNotRegisterServicesForOtherProtocols() { + PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> new SaslAuthenticationResult.Failure("failed"); + FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true, Set.of(PasswordSaslAuthenticationService.class)); + SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain), + ImmutableList.of(new FakeSaslAuthenticationServiceFactory<>(SaslProtocol.SMTP, PasswordSaslAuthenticationService.class, service))); + FakeSaslSessionContext context = new FakeSaslSessionContext(); + + testee.initialize(SaslProtocol.IMAP, context); + + assertThat(context.service(PasswordSaslAuthenticationService.class)).isEmpty(); + } + private static class FakeSaslMechanism implements SaslMechanism { private final String name; private final Set supportedProtocols; + private final Set> requiredServices; private final boolean available; private FakeSaslMechanism(String name, Set supportedProtocols, boolean available) { + this(name, supportedProtocols, available, Set.of()); + } + + private FakeSaslMechanism(String name, Set supportedProtocols, boolean available, Set> requiredServices) { this.name = name; this.supportedProtocols = supportedProtocols; + this.requiredServices = requiredServices; this.available = available; } @@ -98,6 +131,11 @@ public boolean supports(SaslProtocol protocol) { return supportedProtocols.contains(protocol); } + @Override + public Set> requiredServices(SaslProtocol protocol) { + return requiredServices; + } + @Override public boolean isAvailable(SaslSessionContext context) { return available; @@ -109,20 +147,29 @@ public SaslExchange start(SaslInitialRequest request, SaslSessionContext context } } - private record FakeSaslSessionContext(SaslProtocol protocol) implements SaslSessionContext { + private record FakeSaslAuthenticationServiceFactory(SaslProtocol protocol, Class serviceType, T service) implements SaslAuthenticationServiceFactory { @Override - public boolean isTlsStarted() { - return true; + public Optional create(SaslSessionContext context) { + return Optional.of(service); + } + } + + private static class FakeSaslSessionContext implements SaslSessionContext { + private final Map, Object> services; + + private FakeSaslSessionContext() { + this.services = new java.util.HashMap<>(); } @Override - public Optional configuration(Class configurationType) { - return Optional.empty(); + public Optional service(Class serviceType) { + return Optional.ofNullable(services.get(serviceType)) + .map(serviceType::cast); } @Override - public Optional service(Class serviceType) { - return Optional.empty(); + public void register(Class serviceType, T service) { + services.put(serviceType, service); } } } diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java new file mode 100644 index 00000000000..a19bce70223 --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.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.api.sasl; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +class TestSaslSessionContext implements SaslSessionContext { + private final Map, Object> services; + + TestSaslSessionContext(Optional passwordService, + Optional bearerTokenService) { + this.services = new HashMap<>(); + passwordService.ifPresent(service -> register(PasswordSaslAuthenticationService.class, service)); + bearerTokenService.ifPresent(service -> register(BearerTokenSaslAuthenticationService.class, service)); + } + + @Override + public Optional service(Class serviceType) { + return Optional.ofNullable(services.get(serviceType)) + .map(serviceType::cast); + } + + @Override + public void register(Class serviceType, T service) { + services.put(serviceType, service); + } +} From e214f0abd7d3d7e28d8fd39152250ff5fb66341d Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 16:03:54 +0700 Subject: [PATCH 07/29] JAMES-4210 Route IMAP AUTHENTICATE through SASL mechanisms Refactor IMAP AuthenticateProcessor to use SaslMechanismRegistry while preserving direct non-Guice defaults. Move IMAP password and bearer-token authentication into protocol service factories and keep mailbox session and failure details in the IMAP SASL session context. --- .../encode/AuthenticateResponseEncoder.java | 6 +- .../response/AuthenticateResponse.java | 16 +- .../imap/processor/AbstractAuthProcessor.java | 31 +- .../imap/processor/AuthenticateProcessor.java | 283 ++++++++++-------- ...pBearerTokenSaslAuthenticationService.java | 101 +++++++ ...TokenSaslAuthenticationServiceFactory.java | 57 ++++ ...ImapPasswordSaslAuthenticationService.java | 106 +++++++ ...swordSaslAuthenticationServiceFactory.java | 57 ++++ .../imap/processor/sasl/ImapSaslBridge.java | 7 +- .../sasl/ImapSaslSessionContext.java | 106 +++++++ .../processor/sasl/ImapSaslBridgeTest.java | 2 +- .../sasl/ImapSaslSessionContextTest.java | 51 ++++ 12 files changed, 666 insertions(+), 157 deletions(-) create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java create mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java index a4dab679c8b..3e77db72e68 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java @@ -30,6 +30,10 @@ public Class acceptableMessages() { @Override public void encode(AuthenticateResponse message, ImapResponseComposer composer) throws IOException { - composer.continuationResponse(); + if (message.getResponse().filter(response -> !response.isEmpty()).isPresent()) { + composer.continuationResponse(message.getResponse().get()); + } else { + composer.continuationResponse(); + } } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java index 345fc9e5f4d..dcf4355e345 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java @@ -19,8 +19,22 @@ package org.apache.james.imap.message.response; +import java.util.Optional; + import org.apache.james.imap.api.message.response.ImapResponseMessage; -public class AuthenticateResponse implements ImapResponseMessage{ +public class AuthenticateResponse implements ImapResponseMessage { + private final Optional response; + + public AuthenticateResponse() { + this.response = Optional.empty(); + } + + public AuthenticateResponse(String response) { + this.response = Optional.of(response); + } + public Optional getResponse() { + return response; + } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java index 8786a6ec941..7ce94a317f4 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java @@ -27,8 +27,6 @@ import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.main.PathConverter; -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.DefaultMailboxes; import org.apache.james.mailbox.MailboxManager; @@ -41,7 +39,6 @@ import org.apache.james.mailbox.model.MailboxConstants; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.OIDCSASLParser; import org.apache.james.util.AuditTrail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -150,39 +147,13 @@ protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mai Optional.of(delegatorUser), "Delegation target user does not exist."); } catch (ForbiddenDelegationException e) { authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN, Optional.of(authenticateUser), - Optional.of(delegatorUser), "Requested delegation is forbidden."); + Optional.of(delegatorUser), "Requested delegation is forbidden."); } catch (MailboxException e) { - // This is probably not a user error, so we do not increase the failure count or add the - // event to the audit log. LOGGER.info("Authentication failed", e); no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); } } - protected void doOAuth(OIDCSASLParser.OIDCInitialResponse oidcInitialResponse, OidcSASLConfiguration oidcSASLConfiguration, - ImapSession session, ImapRequest request, Responder responder) { - new OidcJwtTokenVerifier(oidcSASLConfiguration).validateToken(oidcInitialResponse.getToken()) - .ifPresentOrElse(authenticatedUser -> { - Username associatedUser = Username.of(oidcInitialResponse.getAssociatedUser()); - if (!associatedUser.equals(authenticatedUser)) { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(authenticatedUser) - .as(associatedUser), - session, request, responder, authenticatedUser, associatedUser); - } else { - authSuccess(session, getMailboxManager().createSystemSession(authenticatedUser), request, responder, - "OAuth authentication succeeded." - ); - } - }, () -> { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.of(Username.of(oidcInitialResponse.getAssociatedUser())), - "OAuth authentication failed." - ); - }); - } - protected void provisionInbox(ImapSession session, MailboxManager mailboxManager, MailboxSession mailboxSession) throws MailboxException { MailboxPath inboxPath = pathConverterFactory.forSession(session).buildFullPath(MailboxConstants.INBOX); if (Mono.from(mailboxManager.mailboxExists(inboxPath, mailboxSession)).block()) { diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java index cfae43c6f37..84138a71312 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java @@ -19,18 +19,13 @@ package org.apache.james.imap.processor; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; import java.util.List; +import java.util.Locale; import java.util.Optional; -import java.util.stream.Collectors; import jakarta.inject.Inject; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.core.Username; import org.apache.james.imap.api.display.HumanReadableText; import org.apache.james.imap.api.message.Capability; import org.apache.james.imap.api.message.request.ImapRequest; @@ -40,15 +35,27 @@ import org.apache.james.imap.message.request.AuthenticateRequest; import org.apache.james.imap.message.request.IRAuthenticateRequest; import org.apache.james.imap.message.response.AuthenticateResponse; +import org.apache.james.imap.processor.sasl.ImapBearerTokenSaslAuthenticationServiceFactory; +import org.apache.james.imap.processor.sasl.ImapPasswordSaslAuthenticationServiceFactory; +import org.apache.james.imap.processor.sasl.ImapSaslBridge; +import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.sasl.OauthBearerSaslMechanism; +import org.apache.james.protocols.api.sasl.PlainSaslMechanism; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismRegistry; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.api.sasl.XOauth2SaslMechanism; import org.apache.james.util.MDCBuilder; import org.apache.james.util.ReactorUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import reactor.core.publisher.Mono; @@ -59,17 +66,22 @@ public class AuthenticateProcessor extends AbstractAuthProcessor implements CapabilityImplementingProcessor { public static final String AUTH_PLAIN = "AUTH=PLAIN"; public static final Capability AUTH_PLAIN_CAPABILITY = Capability.of(AUTH_PLAIN); - private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class); - private static final String AUTH_TYPE_PLAIN = "PLAIN"; - private static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER"; - private static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2"; - private static final List OAUTH_CAPABILITIES = ImmutableList.of(Capability.of("AUTH=" + AUTH_TYPE_OAUTHBEARER), Capability.of("AUTH=" + AUTH_TYPE_XOAUTH2)); public static final Capability SASL_CAPABILITY = Capability.of("SASL-IR"); + private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class); + private static final ImmutableList DEFAULT_SASL_MECHANISMS = ImmutableList.of( + new PlainSaslMechanism(), + new OauthBearerSaslMechanism(), + new XOauth2SaslMechanism()); + + private final ImapSaslBridge saslBridge; + private SaslMechanismRegistry saslMechanisms; @Inject public AuthenticateProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { super(AuthenticateRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); + this.saslBridge = new ImapSaslBridge(); + this.saslMechanisms = defaultSaslMechanisms(); } @Override @@ -79,127 +91,40 @@ public List> acceptableClasses() { @Override protected void processRequest(AuthenticateRequest request, ImapSession session, final Responder responder) { - final String authType = request.getAuthType(); + ImapSaslSessionContext context = buildContext(session); + Optional mechanism = saslMechanisms.find(request.getAuthType(), SaslProtocol.IMAP); - if (authType.equalsIgnoreCase(AUTH_TYPE_PLAIN)) { - // See if AUTH=PLAIN is allowed. See IMAP-304 - if (session.isPlainAuthDisallowed()) { - LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel"); - no(request, responder, HumanReadableText.DISABLED_LOGIN); - } else { - if (request instanceof IRAuthenticateRequest) { - IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request; - parseAndDoPlainAuth(irRequest.getInitialClientResponse(), session, request, responder); - } else { - session.executeSafely(() -> { - responder.respond(new AuthenticateResponse()); - responder.flush(); - session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> { - parseAndDoPlainAuth(extractInitialClientResponse(data), requestSession, request, responder); - // remove the handler now - requestSession.popLineHandler(); - responder.flush(); - }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then()); - }); - } - } - } else if (authType.equalsIgnoreCase(AUTH_TYPE_OAUTHBEARER) || authType.equalsIgnoreCase(AUTH_TYPE_XOAUTH2)) { - if (!session.supportsOAuth()) { - LOGGER.warn("OAuth authentication rejected because it is disabled"); - no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); - } else { - if (request instanceof IRAuthenticateRequest) { - IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request; - parseAndDoOAuth(irRequest.getInitialClientResponse(), session, request, responder); - } else { - session.executeSafely(() -> { - responder.respond(new AuthenticateResponse()); - responder.flush(); - session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> { - parseAndDoOAuth(extractInitialClientResponse(data), requestSession, request, responder); - requestSession.popLineHandler(); - responder.flush(); - }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then()); - }); - } - } - } else { - LOGGER.debug("Unsupported authentication mechanism '{}'", authType); + if (mechanism.isEmpty()) { + LOGGER.debug("Unsupported authentication mechanism '{}'", request.getAuthType()); no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); + return; } - } - /** - * Parse the initial client response and start plain authentication. - */ - protected void parseAndDoPlainAuth(String initialClientResponse, ImapSession session, ImapRequest request, Responder responder) { - AuthenticationAttempt authenticationAttempt = parseDelegationAttempt(initialClientResponse); - if (authenticationAttempt.isDelegation()) { - doPasswordAuthWithDelegation(authenticationAttempt, session, request, responder); - } else { - doPasswordAuth(authenticationAttempt, session, request, responder); + if (!mechanism.get().isAvailable(context)) { + rejectUnavailable(request, responder, mechanism.get()); + return; } - } - /** - * Parse the initial client response and start oauth authentication. - */ - protected void parseAndDoOAuth(String initialResponse, ImapSession session, ImapRequest request, Responder responder) { - OIDCSASLParser.parse(initialResponse) - .flatMap(oidcInitialResponseValue -> session.oidcSaslConfiguration().map(configure -> Pair.of(oidcInitialResponseValue, configure))) - .ifPresentOrElse(pair -> doOAuth(pair.getLeft(), pair.getRight(), session, request, responder), - () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.empty(), "Malformed authentication command.")); - } - - private AuthenticationAttempt parseDelegationAttempt(String initialClientResponse) { try { - String userpass = new String(Base64.getDecoder().decode(initialClientResponse)); - List tokens = Arrays.stream(userpass.split("\0")) - .filter(token -> !token.isBlank()) - .collect(Collectors.toList()); - Preconditions.checkArgument(tokens.size() == 2 || tokens.size() == 3); - if (tokens.size() == 2) { - // If we got here, this is what happened. RFC 2595 - // says that "the client may leave the authorization - // identity empty to indicate that it is the same as - // the authentication identity." As noted above, - // that would be represented as a decoded string of - // the form: "\0authenticate-id\0password". The - // first call to nextToken will skip the empty - // authorize-id, and give us the authenticate-id, - // which we would store as the authorize-id. The - // second call will give us the password, which we - // think is the authenticate-id (user). Then when - // we ask for the password, there are no more - // elements, leading to the exception we just - // caught. So we need to move the user to the - // password, and the authorize_id to the user. - return noDelegation(Username.of(tokens.get(0)), tokens.get(1)); - } else { - return delegation(Username.of(tokens.get(0)), Username.of(tokens.get(1)), tokens.get(2)); - } - } catch (Exception e) { - // Ignored - this exception in parsing will be dealt - // with in the if clause below + SaslInitialRequest initialRequest = saslBridge.initialRequest(request.getAuthType(), initialClientResponse(request)); + SaslExchange exchange = mechanism.get().start(initialRequest, context); + handleFirstStep(exchange, exchange.firstStep(), context, session, request, responder); + } catch (IllegalArgumentException e) { LOGGER.info("Invalid syntax in AUTHENTICATE initial client response", e); - return noDelegation(null, null); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); } } @Override public List getImplementedCapabilities(ImapSession session) { - List caps = new ArrayList<>(); - // Only ounce AUTH=PLAIN if the session does allow plain auth or TLS is active. - // See IMAP-304 - if (!session.isPlainAuthDisallowed()) { - caps.add(AUTH_PLAIN_CAPABILITY); - } + ImapSaslSessionContext context = buildContext(session); + List caps = saslMechanisms.availableFor(SaslProtocol.IMAP, context) + .map(mechanism -> Capability.of("AUTH=" + mechanism.name())) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + // Support for SASL-IR. See RFC4959 caps.add(SASL_CAPABILITY); - if (session.supportsOAuth()) { - caps.addAll(OAUTH_CAPABILITIES); - } return ImmutableList.copyOf(caps); } @@ -210,9 +135,123 @@ protected MDCBuilder mdc(AuthenticateRequest request) { .addToContext("authType", request.getAuthType()); } - private static String extractInitialClientResponse(byte[] data) { - // cut of the CRLF - return new String(data, 0, data.length - 2, StandardCharsets.US_ASCII); + public void configureSaslMechanisms(SaslMechanismRegistry saslMechanisms) { + this.saslMechanisms = saslMechanisms; + } + + private Optional initialClientResponse(AuthenticateRequest request) { + if (request instanceof IRAuthenticateRequest irAuthenticateRequest) { + return Optional.of(irAuthenticateRequest.getInitialClientResponse()); + } + return Optional.empty(); } + private ImapSaslSessionContext buildContext(ImapSession session) { + ImapSaslSessionContext context = new ImapSaslSessionContext(session, withAdminUsers()); + saslMechanisms.initialize(SaslProtocol.IMAP, context); + return context; + } + + private SaslMechanismRegistry defaultSaslMechanisms() { + return new SaslMechanismRegistry(DEFAULT_SASL_MECHANISMS, + ImmutableList.of( + new ImapPasswordSaslAuthenticationServiceFactory(getMailboxManager()), + new ImapBearerTokenSaslAuthenticationServiceFactory(getMailboxManager()))); + } + + private void rejectUnavailable(AuthenticateRequest request, Responder responder, SaslMechanism mechanism) { + if (PlainSaslMechanism.NAME.equals(mechanism.name())) { + LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel"); + no(request, responder, HumanReadableText.DISABLED_LOGIN); + } else { + LOGGER.warn("{} authentication rejected because it is disabled", mechanism.name()); + no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); + } + } + + private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSaslSessionContext context, + ImapSession session, AuthenticateRequest request, Responder responder) { + if (step instanceof SaslStep.Challenge challenge) { + session.executeSafely(() -> { + responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))); + responder.flush(); + session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleContinuationLine(exchange, context, requestSession, request, responder, data)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + .then()); + }); + return; + } + handleTerminalStep(exchange, step, context, session, request, responder); + } + + private void handleContinuationLine(SaslExchange exchange, ImapSaslSessionContext context, + ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { + if (saslBridge.isAbort(data)) { + saslBridge.abort(exchange); + session.popLineHandler(); + no(request, responder, HumanReadableText.AUTHENTICATION_FAILED); + responder.flush(); + return; + } + + try { + SaslStep step = saslBridge.onClientResponse(exchange, data); + if (step instanceof SaslStep.Challenge challenge) { + responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))); + responder.flush(); + } else { + session.popLineHandler(); + handleTerminalStep(exchange, step, context, session, request, responder); + responder.flush(); + } + } catch (IllegalArgumentException e) { + LOGGER.info("Invalid syntax in AUTHENTICATE client response", e); + session.popLineHandler(); + saslBridge.close(exchange); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); + responder.flush(); + } + } + + private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSaslSessionContext context, + ImapSession session, AuthenticateRequest request, Responder responder) { + if (step instanceof SaslStep.Success success) { + handleSuccess(context, session, request, responder, success.identity()); + } else if (step instanceof SaslStep.Failure failure) { + handleFailure(context, session, request, responder, failure.reason()); + } + saslBridge.close(exchange); + } + + private void handleSuccess(ImapSaslSessionContext context, ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { + context.mailboxSession() + .ifPresentOrElse(mailboxSession -> authSuccess(session, mailboxSession, request, responder, successLog(request, identity)), + () -> handleMissingMailboxSession(session, request, responder, identity)); + } + + private void handleMissingMailboxSession(ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { + LOGGER.error("SASL mechanism {} returned Success without creating a mailbox session for authenticationId={} authorizationId={}", + request.getAuthType(), identity.authenticationId(), identity.authorizationId()); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.of(identity.authenticationId()), + Optional.of(identity.authorizationId()), "Authentication failed."); + } + + private String successLog(AuthenticateRequest request, SaslIdentity identity) { + String authType = request.getAuthType().toUpperCase(Locale.US); + if (!identity.authenticationId().equals(identity.authorizationId())) { + return "Authentication with delegation succeeded using " + authType + "."; + } + return authType + " authentication succeeded."; + } + + private void handleFailure(ImapSaslSessionContext context, ImapSession session, ImapRequest request, Responder responder, String reason) { + if (context.hasProcessingFailure()) { + no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); + return; + } + context.failureDetails() + .ifPresentOrElse(failure -> authFailure(session, request, responder, failure.text(), failure.username(), failure.assumedUser(), failure.reason()), + () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), reason)); + } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java new file mode 100644 index 00000000000..27a19e5f98b --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java @@ -0,0 +1,101 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.imap.api.display.HumanReadableText; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxSession; +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.BearerTokenSaslAuthenticationService; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ImapBearerTokenSaslAuthenticationService implements BearerTokenSaslAuthenticationService { + private static final Logger LOGGER = LoggerFactory.getLogger(ImapBearerTokenSaslAuthenticationService.class); + + private final MailboxManager mailboxManager; + private final ImapSaslSessionContext context; + + public ImapBearerTokenSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context) { + this.mailboxManager = mailboxManager; + this.context = context; + } + + @Override + public SaslAuthenticationResult authenticate(String token, Username authorizationId) { + return context.session().oidcSaslConfiguration() + .flatMap(configuration -> new OidcJwtTokenVerifier(configuration).validateToken(token)) + .map(authenticationId -> authenticateOidcUser(authenticationId, authorizationId)) + .orElseGet(() -> { + String reason = "OAuth authentication failed."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.AUTHENTICATION_FAILED, + Optional.empty(), Optional.of(authorizationId), reason)); + return new SaslAuthenticationResult.Failure(reason); + }); + } + + private SaslAuthenticationResult authenticateOidcUser(Username authenticationId, Username authorizationId) { + if (!authorizationId.equals(authenticationId)) { + return authenticateOidcDelegation(authenticationId, authorizationId); + } + + context.authenticationSucceeded(mailboxManager.createSystemSession(authenticationId)); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId)); + } + + private SaslAuthenticationResult authenticateOidcDelegation(Username authenticationId, Username authorizationId) { + try { + MailboxSession mailboxSession = mailboxManager + .withExtraAuthorizator(context.delegationAuthorizator()) + .authenticate(authenticationId) + .as(authorizationId); + context.authenticationSucceeded(mailboxSession); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId)); + } catch (BadCredentialsException e) { + String reason = "Password authentication with delegation failed because of bad credentials."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, + Optional.of(authenticationId), Optional.of(authorizationId), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (UserDoesNotExistException e) { + String reason = "Delegation target user does not exist."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.USER_DOES_NOT_EXIST, + Optional.of(authenticationId), Optional.of(authorizationId), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (ForbiddenDelegationException e) { + String reason = "Requested delegation is forbidden."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.DELEGATION_FORBIDDEN, + Optional.of(authenticationId), Optional.of(authorizationId), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (MailboxException e) { + LOGGER.info("Authentication failed", e); + context.processingFailed(); + return new SaslAuthenticationResult.Failure("Authentication failed."); + } + } +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java new file mode 100644 index 00000000000..bf45c6586e9 --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.protocols.api.sasl.BearerTokenSaslAuthenticationService; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; + +public class ImapBearerTokenSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { + private final MailboxManager mailboxManager; + + @Inject + public ImapBearerTokenSaslAuthenticationServiceFactory(MailboxManager mailboxManager) { + this.mailboxManager = mailboxManager; + } + + @Override + public SaslProtocol protocol() { + return SaslProtocol.IMAP; + } + + @Override + public Class serviceType() { + return BearerTokenSaslAuthenticationService.class; + } + + @Override + public Optional create(SaslSessionContext context) { + if (context instanceof ImapSaslSessionContext imapContext && imapContext.supportsOAuth()) { + return Optional.of(new ImapBearerTokenSaslAuthenticationService(mailboxManager, imapContext)); + } + return Optional.empty(); + } +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java new file mode 100644 index 00000000000..29be46248ab --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java @@ -0,0 +1,106 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.imap.api.display.HumanReadableText; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxSession; +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.PasswordSaslAuthenticationService; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ImapPasswordSaslAuthenticationService implements PasswordSaslAuthenticationService { + private static final Logger LOGGER = LoggerFactory.getLogger(ImapPasswordSaslAuthenticationService.class); + + private final MailboxManager mailboxManager; + private final ImapSaslSessionContext context; + + public ImapPasswordSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context) { + this.mailboxManager = mailboxManager; + this.context = context; + } + + @Override + public SaslAuthenticationResult authenticate(Username authenticationId, Optional authorizationId, String password) { + Username authorizedUser = authorizationId.orElse(authenticationId); + if (authorizedUser.equals(authenticationId)) { + return authenticateWithoutDelegation(authenticationId, password); + } + return authenticateWithDelegation(authenticationId, password, authorizedUser); + } + + private SaslAuthenticationResult authenticateWithoutDelegation(Username authenticationId, String password) { + try { + MailboxSession mailboxSession = mailboxManager + .authenticate(authenticationId, password) + .withoutDelegation(); + context.authenticationSucceeded(mailboxSession); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authenticationId)); + } catch (BadCredentialsException e) { + String reason = "Password authentication failed because of bad credentials."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, + Optional.of(authenticationId), Optional.empty(), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (MailboxException e) { + LOGGER.error("Authentication failed", e); + context.processingFailed(); + return new SaslAuthenticationResult.Failure("Authentication failed."); + } + } + + private SaslAuthenticationResult authenticateWithDelegation(Username authenticationId, String password, Username authorizedUser) { + try { + MailboxSession mailboxSession = mailboxManager + .withExtraAuthorizator(context.delegationAuthorizator()) + .authenticate(authenticationId, password) + .as(authorizedUser); + context.authenticationSucceeded(mailboxSession); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizedUser)); + } catch (BadCredentialsException e) { + String reason = "Password authentication with delegation failed because of bad credentials."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, + Optional.of(authenticationId), Optional.of(authorizedUser), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (UserDoesNotExistException e) { + String reason = "Delegation target user does not exist."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.USER_DOES_NOT_EXIST, + Optional.of(authenticationId), Optional.of(authorizedUser), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (ForbiddenDelegationException e) { + String reason = "Requested delegation is forbidden."; + context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.DELEGATION_FORBIDDEN, + Optional.of(authenticationId), Optional.of(authorizedUser), reason)); + return new SaslAuthenticationResult.Failure(reason); + } catch (MailboxException e) { + LOGGER.info("Authentication failed", e); + context.processingFailed(); + return new SaslAuthenticationResult.Failure("Authentication failed."); + } + } +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java new file mode 100644 index 00000000000..d0129e1eb46 --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.protocols.api.sasl.PasswordSaslAuthenticationService; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; + +public class ImapPasswordSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { + private final MailboxManager mailboxManager; + + @Inject + public ImapPasswordSaslAuthenticationServiceFactory(MailboxManager mailboxManager) { + this.mailboxManager = mailboxManager; + } + + @Override + public SaslProtocol protocol() { + return SaslProtocol.IMAP; + } + + @Override + public Class serviceType() { + return PasswordSaslAuthenticationService.class; + } + + @Override + public Optional create(SaslSessionContext context) { + if (context instanceof ImapSaslSessionContext imapContext && !imapContext.isPlainAuthDisallowed()) { + return Optional.of(new ImapPasswordSaslAuthenticationService(mailboxManager, imapContext)); + } + return Optional.empty(); + } +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java index 3b3776848ce..4e6a5a31eea 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java @@ -21,7 +21,6 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Objects; import java.util.Optional; import org.apache.commons.lang3.StringUtils; @@ -36,7 +35,7 @@ public class ImapSaslBridge { */ public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { return new SaslInitialRequest(SaslProtocol.IMAP, mechanismName, - Objects.requireNonNull(initialClientResponse).map(this::decodeInitialClientResponse)); + initialClientResponse.map(this::decodeInitialClientResponse)); } /** @@ -55,6 +54,10 @@ public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); } + public boolean isAbort(byte[] line) { + return "*".equals(stripTrailingCrlf(line)); + } + /** * Aborts and closes an active SASL exchange. */ diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java new file mode 100644 index 00000000000..2dc1dfeec72 --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java @@ -0,0 +1,106 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.imap.api.display.HumanReadableText; +import org.apache.james.imap.api.process.ImapSession; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.protocols.api.sasl.SaslSessionContext; + +public class ImapSaslSessionContext implements SaslSessionContext { + public record FailureDetails(HumanReadableText text, Optional username, Optional assumedUser, String reason) { + } + + private final ImapSession session; + private final Authorizator delegationAuthorizator; + private final Map, Object> services; + private Optional mailboxSession; + private Optional failureDetails; + private boolean processingFailure; + + public ImapSaslSessionContext(ImapSession session) { + this(session, (userId, otherUserId) -> Authorizator.AuthorizationState.FORBIDDEN); + } + + public ImapSaslSessionContext(ImapSession session, Authorizator delegationAuthorizator) { + this.session = session; + this.delegationAuthorizator = delegationAuthorizator; + this.services = new HashMap<>(); + this.mailboxSession = Optional.empty(); + this.failureDetails = Optional.empty(); + } + + @Override + public Optional service(Class serviceType) { + return Optional.ofNullable(services.get(serviceType)) + .map(serviceType::cast); + } + + @Override + public void register(Class serviceType, T service) { + services.put(serviceType, service); + } + + public boolean isPlainAuthDisallowed() { + return session.isPlainAuthDisallowed(); + } + + public boolean supportsOAuth() { + return session.supportsOAuth(); + } + + public Authorizator delegationAuthorizator() { + return delegationAuthorizator; + } + + public ImapSession session() { + return session; + } + + public void authenticationSucceeded(MailboxSession mailboxSession) { + this.mailboxSession = Optional.of(mailboxSession); + } + + public void recordFailureDetails(FailureDetails failureDetails) { + this.failureDetails = Optional.of(failureDetails); + } + + public void processingFailed() { + this.processingFailure = true; + } + + public Optional mailboxSession() { + return mailboxSession; + } + + public Optional failureDetails() { + return failureDetails; + } + + public boolean hasProcessingFailure() { + return processingFailure; + } +} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java index a14d2cfff46..6a14b449eee 100644 --- a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java @@ -57,7 +57,7 @@ public SaslStep firstStep() { @Override public SaslStep onResponse(byte[] clientResponse) { lastClientResponse = clientResponse.clone(); - return new SaslStep.Success(IDENTITY, Optional.empty(), "success"); + return new SaslStep.Success(IDENTITY, Optional.empty()); } @Override diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java new file mode 100644 index 00000000000..53c849c6f04 --- /dev/null +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java @@ -0,0 +1,51 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.apache.james.imap.api.process.ImapSession; +import org.junit.jupiter.api.Test; + +class ImapSaslSessionContextTest { + private interface ExtensionSaslService { + } + + private static class FakeExtensionSaslService implements ExtensionSaslService { + } + + @Test + void serviceShouldExposeRegisteredProtocolService() { + ImapSaslSessionContext testee = new ImapSaslSessionContext(mock(ImapSession.class)); + ExtensionSaslService service = new FakeExtensionSaslService(); + + testee.register(ExtensionSaslService.class, service); + + assertThat(testee.service(ExtensionSaslService.class)).contains(service); + } + + @Test + void serviceShouldReturnEmptyWhenProtocolServiceIsNotRegistered() { + ImapSaslSessionContext testee = new ImapSaslSessionContext(mock(ImapSession.class)); + + assertThat(testee.service(ExtensionSaslService.class)).isEmpty(); + } +} From 46a64e48eca133619f60906a01a9c93d37783f3f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 16:04:08 +0700 Subject: [PATCH 08/29] JAMES-4210 Load IMAP SASL mechanisms from configuration Wire SASL mechanism loading into the Guice IMAP server module. Add a default mechanism class-name provider and an extension service-factory provider so custom SASL extensions can load their own auth configuration from imapserver.xml. --- .../james/modules/CommonServicesModule.java | 3 + ...ltImapSaslMechanismClassNamesProvider.java | 29 ++++ .../modules/protocols/IMAPServerModule.java | 81 ++++++++++- ...lAuthenticationServiceFactoryProvider.java | 31 ++++ ...ltImapSaslMechanismClassNamesProvider.java | 38 +++++ .../protocols/IMAPServerModuleTest.java | 135 ++++++++++++++++++ 6 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java create mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java create mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java create mode 100644 server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java index fffe4af299b..2eea9948330 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java @@ -26,6 +26,7 @@ import org.apache.james.modules.server.DNSServiceModule; import org.apache.james.modules.server.DropWizardMetricsModule; import org.apache.james.onami.lifecycle.PreDestroyModule; +import org.apache.james.protocols.api.sasl.SaslMechanismLoader; import org.apache.james.server.core.configuration.Configuration; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.server.core.configuration.FileConfigurationProvider; @@ -33,6 +34,7 @@ import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.ExtensionModule; import org.apache.james.utils.GuiceProbe; +import org.apache.james.utils.GuiceSaslMechanismLoader; import org.apache.james.utils.PropertiesProvider; import com.google.inject.AbstractModule; @@ -67,6 +69,7 @@ protected void configure() { bind(FileSystem.class).toInstance(fileSystem); bind(Configuration.class).toInstance(configuration); + bind(SaslMechanismLoader.class).to(GuiceSaslMechanismLoader.class); bind(ConfigurationProvider.class).toInstance(new FileConfigurationProvider(fileSystem, configuration)); diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java new file mode 100644 index 00000000000..db9abd0427c --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.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.modules.protocols; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; + +import com.google.common.collect.ImmutableList; + +public interface DefaultImapSaslMechanismClassNamesProvider { + ImmutableList resolve(HierarchicalConfiguration configuration); +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 3c3fc4ee3d1..0dc95f96e29 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -20,12 +20,14 @@ import java.util.Arrays; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.ProtocolConfigurationSanitizer; import org.apache.james.RunArguments; @@ -67,11 +69,18 @@ import org.apache.james.imap.processor.base.AbstractProcessor; import org.apache.james.imap.processor.base.UnknownRequestProcessor; import org.apache.james.imap.processor.fetch.FetchProcessor; +import org.apache.james.imap.processor.sasl.ImapBearerTokenSaslAuthenticationServiceFactory; +import org.apache.james.imap.processor.sasl.ImapPasswordSaslAuthenticationServiceFactory; import org.apache.james.imapserver.netty.IMAPHealthCheck; import org.apache.james.imapserver.netty.IMAPServerFactory; import org.apache.james.lifecycle.api.ConfigurationSanitizer; +import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.GaugeRegistry; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismLoader; +import org.apache.james.protocols.api.sasl.SaslMechanismRegistry; import org.apache.james.protocols.lib.netty.CertificateReloadable; import org.apache.james.protocols.netty.Encryption; import org.apache.james.server.core.configuration.ConfigurationProvider; @@ -94,7 +103,6 @@ import com.google.inject.multibindings.ProvidesIntoSet; public class IMAPServerModule extends AbstractModule { - private static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); @@ -111,6 +119,7 @@ protected void configure() { bind(SelectProcessor.class).in(Scopes.SINGLETON); bind(StatusProcessor.class).in(Scopes.SINGLETON); bind(EnableProcessor.class).in(Scopes.SINGLETON); + bind(DefaultImapSaslMechanismClassNamesProvider.class).to(JamesDefaultImapSaslMechanismClassNamesProvider.class); bind(NamespaceSupplier.class).to(NamespaceSupplier.Default.class).in(Scopes.SINGLETON); bind(PathConverter.Factory.class).to(PathConverter.Factory.Default.class).in(Scopes.SINGLETON); bind(MailboxTyper.class).to(DefaultMailboxTyper.class).in(Scopes.SINGLETON); @@ -130,21 +139,27 @@ protected void configure() { @Singleton IMAPServerFactory provideServerFactory(FileSystem fileSystem, GuiceLoader guiceLoader, + SaslMechanismLoader saslMechanismLoader, + Set saslAuthenticationServiceFactoryProviders, + DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, StatusResponseFactory statusResponseFactory, MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory, Encryption.Factory encryptionFactory) { - IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); + IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismLoader, + saslAuthenticationServiceFactoryProviders, defaultImapSaslMechanismClassNamesProvider, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); factory.setEncryptionFactory(encryptionFactory); return factory; } - DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, StatusResponseFactory statusResponseFactory) { + DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, + SaslMechanismRegistry saslMechanisms, StatusResponseFactory statusResponseFactory) { ImmutableMap processors = imapPackage.processors() .stream() .map(Throwing.function(guiceLoader::instantiate)) .map(AbstractProcessor.class::cast) + .map(processor -> configureSaslMechanisms(processor, saslMechanisms)) .flatMap(IMAPServerModule::asPairStream) .collect(ImmutableMap.toImmutableMap( Pair::getLeft, @@ -168,6 +183,13 @@ DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader return new DefaultProcessor(processors, new UnknownRequestProcessor(statusResponseFactory)); } + private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, SaslMechanismRegistry saslMechanisms) { + if (processor instanceof AuthenticateProcessor authenticateProcessor) { + authenticateProcessor.configureSaslMechanisms(saslMechanisms); + } + return processor; + } + private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfiguration configuration) { String[] imapPackages = configuration.getStringArray("imapPackages"); @@ -185,11 +207,53 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig return ImapPackage.and(packages); } + private SaslMechanismRegistry retrieveSaslMechanisms(SaslMechanismLoader saslMechanismLoader, + Set saslAuthenticationServiceFactoryProviders, + DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, + HierarchicalConfiguration configuration) throws ConfigurationException { + ImmutableList mechanismClassNames = retrieveSaslMechanismClassNames(configuration, defaultImapSaslMechanismClassNamesProvider); + ImmutableList mechanisms = saslMechanismLoader.load(mechanismClassNames); + ImmutableList> saslAuthenticationServiceFactories = + retrieveSaslAuthenticationServiceFactories(configuration, saslAuthenticationServiceFactoryProviders); + return new SaslMechanismRegistry(mechanisms, saslAuthenticationServiceFactories); + } + + ImmutableList> retrieveSaslAuthenticationServiceFactories(HierarchicalConfiguration configuration, + Set providers) throws ConfigurationException { + ImmutableList.Builder> factories = ImmutableList.builder(); + for (ImapSaslAuthenticationServiceFactoryProvider provider : providers) { + factories.addAll(provider.provide(configuration)); + } + return factories.build(); + } + + ImmutableList retrieveSaslMechanismClassNames(HierarchicalConfiguration configuration, + DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider) throws ConfigurationException { + if (!configuration.containsKey("auth.saslMechanisms")) { + return defaultImapSaslMechanismClassNamesProvider.resolve(configuration); + } + + ImmutableList mechanismClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms")) + .flatMap(value -> Arrays.stream(value.split(","))) + .map(String::trim) + .collect(ImmutableList.toImmutableList()); + + if (mechanismClassNames.isEmpty() || mechanismClassNames.stream().anyMatch(StringUtils::isBlank)) { + throw new ConfigurationException("auth.saslMechanisms must not be blank when configured"); + } + return mechanismClassNames; + } + private ThrowingFunction, ImapSuite> imapSuiteLoader(GuiceLoader guiceLoader, + SaslMechanismLoader saslMechanismLoader, + Set saslAuthenticationServiceFactoryProviders, + DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, StatusResponseFactory statusResponseFactory) { return configuration -> { ImapPackage imapPackage = retrievePackages(guiceLoader, configuration); - DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, statusResponseFactory); + SaslMechanismRegistry saslMechanisms = retrieveSaslMechanisms(saslMechanismLoader, saslAuthenticationServiceFactoryProviders, + defaultImapSaslMechanismClassNamesProvider, configuration); + DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory); ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader); ImapParserFactory imapParserFactory = provideImapCommandParserFactory(imapPackage, guiceLoader); @@ -215,6 +279,13 @@ ImapEncoder provideImapEncoder(ImapPackage imapPackage, GuiceLoader guiceLoader) return new DefaultImapEncoderFactory.DefaultImapEncoder(encoders, new EndImapEncoder()); } + @ProvidesIntoSet + ImapSaslAuthenticationServiceFactoryProvider provideDefaultImapSaslAuthenticationServiceFactoryProvider(MailboxManager mailboxManager) { + return configuration -> ImmutableList.of( + new ImapPasswordSaslAuthenticationServiceFactory(mailboxManager), + new ImapBearerTokenSaslAuthenticationServiceFactory(mailboxManager)); + } + @ProvidesIntoSet InitializationOperation configureImap(ConfigurationProvider configurationProvider, IMAPServerFactory imapServerFactory) { return InitilizationOperationBuilder @@ -262,4 +333,4 @@ ConfigurationSanitizer configurationSanitizer(ConfigurationProvider configuratio FileSystem fileSystem, RunArguments runArguments) { return new ProtocolConfigurationSanitizer(configurationProvider, keystoreCreator, fileSystem, runArguments, "imapserver"); } -} \ No newline at end of file +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java new file mode 100644 index 00000000000..57e3a9f1faa --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import 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.SaslAuthenticationServiceFactory; + +import com.google.common.collect.ImmutableList; + +public interface ImapSaslAuthenticationServiceFactoryProvider { + ImmutableList> provide(HierarchicalConfiguration configuration) throws ConfigurationException; +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java new file mode 100644 index 00000000000..6a4988d593b --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.OauthBearerSaslMechanism; +import org.apache.james.protocols.api.sasl.PlainSaslMechanism; +import org.apache.james.protocols.api.sasl.XOauth2SaslMechanism; + +import com.google.common.collect.ImmutableList; + +public class JamesDefaultImapSaslMechanismClassNamesProvider implements DefaultImapSaslMechanismClassNamesProvider { + @Override + public ImmutableList resolve(HierarchicalConfiguration configuration) { + return ImmutableList.of( + PlainSaslMechanism.class.getSimpleName(), + OauthBearerSaslMechanism.class.getSimpleName(), + XOauth2SaslMechanism.class.getSimpleName()); + } +} diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java new file mode 100644 index 00000000000..bfbc8204d7a --- /dev/null +++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java @@ -0,0 +1,135 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +class IMAPServerModuleTest { + private static final JamesDefaultImapSaslMechanismClassNamesProvider JAMES_DEFAULT_PROVIDER = new JamesDefaultImapSaslMechanismClassNamesProvider(); + + private record CustomAuthenticationServiceFactoryProvider() implements ImapSaslAuthenticationServiceFactoryProvider { + @Override + public ImmutableList> provide(HierarchicalConfiguration configuration) { + return ImmutableList.of(new CustomAuthenticationServiceFactory(configuration.getString("auth.custom.realm"))); + } + } + + private record CustomAuthenticationServiceFactory(String realm) implements SaslAuthenticationServiceFactory { + @Override + public SaslProtocol protocol() { + return SaslProtocol.IMAP; + } + + @Override + public Class serviceType() { + return CustomAuthenticationService.class; + } + + @Override + public Optional create(SaslSessionContext context) { + return Optional.of(new CustomAuthenticationService(realm)); + } + } + + private record CustomAuthenticationService(String realm) { + } + + private final IMAPServerModule testee = new IMAPServerModule(); + + @Test + void retrieveSaslMechanismClassNamesShouldReturnDefaultsWhenAbsent() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + + assertThat(testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + .containsExactly("PlainSaslMechanism", "OauthBearerSaslMechanism", "XOauth2SaslMechanism"); + } + + @Test + void retrieveSaslMechanismClassNamesShouldUseDefaultProviderWhenAbsent() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + DefaultImapSaslMechanismClassNamesProvider defaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); + + assertThat(testee.retrieveSaslMechanismClassNames(configuration, defaultProvider)) + .containsExactly("com.example.CustomSaslMechanism"); + } + + @Test + void retrieveSaslMechanismClassNamesShouldReturnConfiguredList() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", + "PlainSaslMechanism,com.example.CustomSaslMechanism,PlainSaslMechanism"); + + assertThat(testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + .containsExactly("PlainSaslMechanism", "com.example.CustomSaslMechanism", "PlainSaslMechanism"); + } + + @Test + void retrieveSaslMechanismClassNamesShouldIgnoreDefaultProviderWhenConfigured() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism"); + DefaultImapSaslMechanismClassNamesProvider defaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); + + assertThat(testee.retrieveSaslMechanismClassNames(configuration, defaultProvider)) + .containsExactly("PlainSaslMechanism"); + } + + @Test + void retrieveSaslMechanismClassNamesShouldRejectBlankConfiguredList() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", " "); + + assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + .isInstanceOf(ConfigurationException.class); + } + + @Test + void retrieveSaslMechanismClassNamesShouldRejectBlankEntry() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism,,XOauth2SaslMechanism"); + + assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + .isInstanceOf(ConfigurationException.class); + } + + @Test + void extensionSaslMechanismShouldLoadItsOwnAuthConfiguration() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.custom.realm", "james.example"); + ImapSaslAuthenticationServiceFactoryProvider provider = new CustomAuthenticationServiceFactoryProvider(); + + assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, ImmutableSet.of(provider))) + .containsExactly(new CustomAuthenticationServiceFactory("james.example")); + } +} From 52573c6868e4af3eff4d96091280b07631237f57 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 16:04:20 +0700 Subject: [PATCH 09/29] JAMES-4210 Prepare SMTP SASL bridge reuse for LMTP Allow SmtpSaslBridge to be reused with a configured SASL protocol and add LMTP to SaslProtocol for future LMTP AUTH support. --- .../protocols/api/sasl/SaslProtocol.java | 1 + .../smtp/core/esmtp/SmtpSaslBridge.java | 12 ++++++++++- .../smtp/core/esmtp/SmtpSaslBridgeTest.java | 21 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java index b422e86592c..f51c2f61fc3 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java @@ -25,6 +25,7 @@ public enum SaslProtocol { IMAP, SMTP, + LMTP, MANAGESIEVE, POP3 } diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java index 79800af9713..f0c355178af 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java @@ -33,11 +33,21 @@ import org.apache.james.protocols.smtp.SMTPRetCode; public class SmtpSaslBridge { + private final SaslProtocol protocol; + + public SmtpSaslBridge() { + this(SaslProtocol.SMTP); + } + + protected SmtpSaslBridge(SaslProtocol protocol) { + this.protocol = Objects.requireNonNull(protocol); + } + /** * Converts an SMTP AUTH request into a protocol-neutral SASL initial request. */ public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { - return new SaslInitialRequest(SaslProtocol.SMTP, mechanismName, + return new SaslInitialRequest(protocol, mechanismName, Objects.requireNonNull(initialClientResponse).map(this::decodeInitialClientResponse)); } diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java index e92db541107..93a97d8f2f7 100644 --- a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java +++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java @@ -41,7 +41,11 @@ class SmtpSaslBridgeTest { private static final Username USER = Username.of("user@example.com"); private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); - private final SmtpSaslBridge testee = new SmtpSaslBridge(); + private static class LmtpSaslBridge extends SmtpSaslBridge { + private LmtpSaslBridge() { + super(SaslProtocol.LMTP); + } + } private static class RecordingExchange implements SaslExchange { private final List lifecycleEvents; @@ -59,7 +63,7 @@ public SaslStep firstStep() { @Override public SaslStep onResponse(byte[] clientResponse) { lastClientResponse = clientResponse.clone(); - return new SaslStep.Success(IDENTITY, Optional.empty(), "success"); + return new SaslStep.Success(IDENTITY, Optional.empty()); } @Override @@ -73,6 +77,8 @@ public void close() { } } + private final SmtpSaslBridge testee = new SmtpSaslBridge(); + @Test void initialRequestShouldDecodeInitialClientResponse() { String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); @@ -84,6 +90,17 @@ void initialRequestShouldDecodeInitialClientResponse() { assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); } + @Test + void initialRequestShouldUseConfiguredProtocol() { + String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); + + SaslInitialRequest request = new LmtpSaslBridge().initialRequest("PLAIN", Optional.of(encodedInitialResponse)); + + assertThat(request.protocol()).isEqualTo(SaslProtocol.LMTP); + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + @Test void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); From 77cc2b9528b9a556c55d189b6483758d29976410 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 17:14:36 +0700 Subject: [PATCH 10/29] JAMES-4210 Allow IMAP SASL provider extensions from configuration Load extra IMAP SASL authentication service factory providers from auth.saslAuthenticationServiceFactoryProviderExtensions. Providers are Guice-instantiated, merged with built-in providers, and can parse their own IMAP auth configuration. --- .../modules/protocols/IMAPServerModule.java | 42 +++++++++++++- .../protocols/IMAPServerModuleTest.java | 55 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 0dc95f96e29..929db10d6a0 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -103,6 +103,8 @@ import com.google.inject.multibindings.ProvidesIntoSet; public class IMAPServerModule extends AbstractModule { + private static final String SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS = "auth.saslAuthenticationServiceFactoryProviderExtensions"; + private static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); @@ -208,25 +210,59 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig } private SaslMechanismRegistry retrieveSaslMechanisms(SaslMechanismLoader saslMechanismLoader, + GuiceLoader guiceLoader, Set saslAuthenticationServiceFactoryProviders, DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, HierarchicalConfiguration configuration) throws ConfigurationException { ImmutableList mechanismClassNames = retrieveSaslMechanismClassNames(configuration, defaultImapSaslMechanismClassNamesProvider); ImmutableList mechanisms = saslMechanismLoader.load(mechanismClassNames); ImmutableList> saslAuthenticationServiceFactories = - retrieveSaslAuthenticationServiceFactories(configuration, saslAuthenticationServiceFactoryProviders); + retrieveSaslAuthenticationServiceFactories(configuration, guiceLoader, saslAuthenticationServiceFactoryProviders); return new SaslMechanismRegistry(mechanisms, saslAuthenticationServiceFactories); } ImmutableList> retrieveSaslAuthenticationServiceFactories(HierarchicalConfiguration configuration, + GuiceLoader guiceLoader, Set providers) throws ConfigurationException { ImmutableList.Builder> factories = ImmutableList.builder(); - for (ImapSaslAuthenticationServiceFactoryProvider provider : providers) { + ImmutableList allProviders = ImmutableList.builder() + .addAll(providers) + .addAll(retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, guiceLoader)) + .build(); + + for (ImapSaslAuthenticationServiceFactoryProvider provider : allProviders) { factories.addAll(provider.provide(configuration)); } return factories.build(); } + ImmutableList retrieveConfiguredSaslAuthenticationServiceFactoryProviders(HierarchicalConfiguration configuration, + GuiceLoader guiceLoader) throws ConfigurationException { + if (!configuration.containsKey(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS)) { + return ImmutableList.of(); + } + + ImmutableList providerClassNames = Arrays.stream(configuration.getStringArray(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS)) + .flatMap(value -> Arrays.stream(value.split(","))) + .map(String::trim) + .collect(ImmutableList.toImmutableList()); + + if (providerClassNames.isEmpty() || providerClassNames.stream().anyMatch(StringUtils::isBlank)) { + throw new ConfigurationException(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS + " must not be blank when configured"); + } + + ImmutableList.Builder providers = ImmutableList.builder(); + for (String providerClassName : providerClassNames) { + try { + ImapSaslAuthenticationServiceFactoryProvider provider = guiceLoader.instantiate(new ClassName(providerClassName)); + providers.add(provider); + } catch (ClassNotFoundException e) { + throw new ConfigurationException("Failed to load " + SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS + " class " + providerClassName, e); + } + } + return providers.build(); + } + ImmutableList retrieveSaslMechanismClassNames(HierarchicalConfiguration configuration, DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider) throws ConfigurationException { if (!configuration.containsKey("auth.saslMechanisms")) { @@ -251,7 +287,7 @@ private ThrowingFunction, ImapSuite> im StatusResponseFactory statusResponseFactory) { return configuration -> { ImapPackage imapPackage = retrievePackages(guiceLoader, configuration); - SaslMechanismRegistry saslMechanisms = retrieveSaslMechanisms(saslMechanismLoader, saslAuthenticationServiceFactoryProviders, + SaslMechanismRegistry saslMechanisms = retrieveSaslMechanisms(saslMechanismLoader, guiceLoader, saslAuthenticationServiceFactoryProviders, defaultImapSaslMechanismClassNamesProvider, configuration); DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory); ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader); diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java index bfbc8204d7a..5949875f584 100644 --- a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java +++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java @@ -31,13 +31,36 @@ import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; import org.apache.james.protocols.api.sasl.SaslProtocol; import org.apache.james.protocols.api.sasl.SaslSessionContext; +import org.apache.james.utils.ClassName; +import org.apache.james.utils.GuiceLoader; +import org.apache.james.utils.NamingScheme; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.inject.Module; class IMAPServerModuleTest { private static final JamesDefaultImapSaslMechanismClassNamesProvider JAMES_DEFAULT_PROVIDER = new JamesDefaultImapSaslMechanismClassNamesProvider(); + private static final GuiceLoader GUICE_LOADER = new GuiceLoader() { + @Override + public T instantiate(ClassName className) throws ClassNotFoundException { + if (className.getName().equals(CustomAuthenticationServiceFactoryProvider.class.getName())) { + return (T) new CustomAuthenticationServiceFactoryProvider(); + } + throw new ClassNotFoundException(className.getName()); + } + + @Override + public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { + throw new UnsupportedOperationException(); + } + + @Override + public InvocationPerformer withChildModule(Module childModule) { + throw new UnsupportedOperationException(); + } + }; private record CustomAuthenticationServiceFactoryProvider() implements ImapSaslAuthenticationServiceFactoryProvider { @Override @@ -124,12 +147,40 @@ void retrieveSaslMechanismClassNamesShouldRejectBlankEntry() { } @Test - void extensionSaslMechanismShouldLoadItsOwnAuthConfiguration() throws Exception { + void extensionSaslMechanismShouldLoadItsOwnAuthConfigurationFromBoundProvider() throws Exception { BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.custom.realm", "james.example"); ImapSaslAuthenticationServiceFactoryProvider provider = new CustomAuthenticationServiceFactoryProvider(); - assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, ImmutableSet.of(provider))) + assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, GUICE_LOADER, ImmutableSet.of(provider))) .containsExactly(new CustomAuthenticationServiceFactory("james.example")); } + + @Test + void extensionSaslMechanismShouldLoadItsOwnAuthConfigurationFromConfiguredProvider() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.custom.realm", "james.example"); + configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", CustomAuthenticationServiceFactoryProvider.class.getName()); + + assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, GUICE_LOADER, ImmutableSet.of())) + .containsExactly(new CustomAuthenticationServiceFactory("james.example")); + } + + @Test + void retrieveConfiguredSaslAuthenticationServiceFactoryProvidersShouldRejectBlankConfiguredList() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", " "); + + assertThatThrownBy(() -> testee.retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, GUICE_LOADER)) + .isInstanceOf(ConfigurationException.class); + } + + @Test + void retrieveConfiguredSaslAuthenticationServiceFactoryProvidersShouldFailWhenClassIsUnknown() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", "com.example.MissingProvider"); + + assertThatThrownBy(() -> testee.retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, GUICE_LOADER)) + .isInstanceOf(ConfigurationException.class); + } } From cb204cf4ccb60e205d9125453e913db85ddfbec8 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Jun 2026 17:15:35 +0700 Subject: [PATCH 11/29] JAMES-4210 Add custom IMAP SASL extension example Add an EXAMPLE-TOKEN SASL mechanism to examples/custom-imap to demonstrate a custom mechanism, provider extension, and auth.exampleToken configuration. Add dedicated tests for custom SASL advertisement, successful custom auth, invalid-token rejection, and preserving built-in PLAIN authentication. --- examples/custom-imap/README.md | 42 +++++++ examples/custom-imap/pom.xml | 6 + .../sample-configuration/imapserver.xml | 8 ++ ...ExampleTokenSaslAuthenticationService.java | 48 ++++++++ ...TokenSaslAuthenticationServiceFactory.java | 56 +++++++++ ...lAuthenticationServiceFactoryProvider.java | 44 +++++++ .../sasl/ExampleTokenSaslConfiguration.java | 43 +++++++ .../imap/sasl/ExampleTokenSaslMechanism.java | 108 +++++++++++++++++ .../src/main/resources/imapserver.xml | 16 +++ .../imap/CustomSaslMechanismTest.java | 109 ++++++++++++++++++ 10 files changed, 480 insertions(+) create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java create mode 100644 examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md index 7d0eee03256..de47b4c724b 100644 --- a/examples/custom-imap/README.md +++ b/examples/custom-imap/README.md @@ -14,6 +14,29 @@ Sample configure file: [imapserver.xml](./sample-configuration/imapserver.xml) Note that when `imapPackages` is not provided, James will implicit use `org.apache.James.modules.protocols.DefaultImapPackage` +# Creating your own IMAP SASL mechanisms + +This example also demonstrates how to add a custom IMAP SASL mechanism. +The `EXAMPLE-TOKEN` mechanism is declared through `auth.saslMechanisms`, +its authentication service factory provider is declared through +`auth.saslAuthenticationServiceFactoryProviderExtensions`, while `auth.exampleToken` +is a custom configuration block owned by the extension: + +```xml + + PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider + + secret-token + bob@domain.tld + + +``` + +James loads the provider through the extension classloader and instantiates it +with Guice, so the provider can use James services and parse its own +configuration block. + ## Running the example Build the project: @@ -56,4 +79,23 @@ a02 OK LOGIN completed. A03 PING * PONG A03 OK PING completed. +A04 LOGOUT +``` + +Test the custom SASL mechanism: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 CAPABILITY +* CAPABILITY IMAP4rev1 AUTH=PLAIN SASL-IR AUTH=EXAMPLE-TOKEN PING +A01 OK CAPABILITY completed. +A02 AUTHENTICATE EXAMPLE-TOKEN c2VjcmV0LXRva2Vu +A02 OK AUTHENTICATE completed. +A03 PING +* PONG +A03 OK PING completed. ``` diff --git a/examples/custom-imap/pom.xml b/examples/custom-imap/pom.xml index bbdfc87b089..a4665f87dac 100644 --- a/examples/custom-imap/pom.xml +++ b/examples/custom-imap/pom.xml @@ -67,6 +67,12 @@ ${james.baseVersion} provided + + ${james.protocols.groupId} + protocols-api + ${james.baseVersion} + provided + com.google.inject guice diff --git a/examples/custom-imap/sample-configuration/imapserver.xml b/examples/custom-imap/sample-configuration/imapserver.xml index 3333910586a..4d344790d9f 100644 --- a/examples/custom-imap/sample-configuration/imapserver.xml +++ b/examples/custom-imap/sample-configuration/imapserver.xml @@ -33,6 +33,14 @@ under the License. 0 false false + + PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java new file mode 100644 index 00000000000..59c970448fa --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslIdentity; + +public class ExampleTokenSaslAuthenticationService { + private final MailboxManager mailboxManager; + private final ImapSaslSessionContext context; + private final ExampleTokenSaslConfiguration configuration; + + public ExampleTokenSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context, ExampleTokenSaslConfiguration configuration) { + this.mailboxManager = mailboxManager; + this.context = context; + this.configuration = configuration; + } + + public SaslAuthenticationResult authenticate(String token) { + if (!configuration.expectedToken().equals(token)) { + return new SaslAuthenticationResult.Failure("EXAMPLE-TOKEN authentication failed."); + } + + MailboxSession mailboxSession = mailboxManager.createSystemSession(configuration.authorizedUser()); + context.authenticationSucceeded(mailboxSession); + return new SaslAuthenticationResult.Success(new SaslIdentity(configuration.authorizedUser(), configuration.authorizedUser())); + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java new file mode 100644 index 00000000000..d279e0ec1f2 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import java.util.Optional; + +import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; + +public class ExampleTokenSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { + private final MailboxManager mailboxManager; + private final ExampleTokenSaslConfiguration configuration; + + public ExampleTokenSaslAuthenticationServiceFactory(MailboxManager mailboxManager, ExampleTokenSaslConfiguration configuration) { + this.mailboxManager = mailboxManager; + this.configuration = configuration; + } + + @Override + public SaslProtocol protocol() { + return SaslProtocol.IMAP; + } + + @Override + public Class serviceType() { + return ExampleTokenSaslAuthenticationService.class; + } + + @Override + public Optional create(SaslSessionContext context) { + if (context instanceof ImapSaslSessionContext imapContext) { + return Optional.of(new ExampleTokenSaslAuthenticationService(mailboxManager, imapContext, configuration)); + } + return Optional.empty(); + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java new file mode 100644 index 00000000000..f7a282bdef9 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.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.examples.imap.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.modules.protocols.ImapSaslAuthenticationServiceFactoryProvider; +import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Inject; + +public class ExampleTokenSaslAuthenticationServiceFactoryProvider implements ImapSaslAuthenticationServiceFactoryProvider { + private final MailboxManager mailboxManager; + + @Inject + public ExampleTokenSaslAuthenticationServiceFactoryProvider(MailboxManager mailboxManager) { + this.mailboxManager = mailboxManager; + } + + @Override + public ImmutableList> provide(HierarchicalConfiguration configuration) throws ConfigurationException { + return ImmutableList.of(new ExampleTokenSaslAuthenticationServiceFactory(mailboxManager, ExampleTokenSaslConfiguration.from(configuration))); + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java new file mode 100644 index 00000000000..6bf0c219cc6 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.core.Username; + +public record ExampleTokenSaslConfiguration(String expectedToken, Username authorizedUser) { + private static final String EXPECTED_TOKEN_PROPERTY = "auth.exampleToken.expectedToken"; + private static final String AUTHORIZED_USER_PROPERTY = "auth.exampleToken.authorizedUser"; + + public static ExampleTokenSaslConfiguration from(HierarchicalConfiguration configuration) throws ConfigurationException { + if (!configuration.containsKey(EXPECTED_TOKEN_PROPERTY)) { + throw new ConfigurationException(EXPECTED_TOKEN_PROPERTY + " is mandatory"); + } + if (!configuration.containsKey(AUTHORIZED_USER_PROPERTY)) { + throw new ConfigurationException(AUTHORIZED_USER_PROPERTY + " is mandatory"); + } + + return new ExampleTokenSaslConfiguration( + configuration.getString(EXPECTED_TOKEN_PROPERTY), + Username.of(configuration.getString(AUTHORIZED_USER_PROPERTY))); + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java new file mode 100644 index 00000000000..6fe9f577ac9 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java @@ -0,0 +1,108 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; + +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslProtocol; +import org.apache.james.protocols.api.sasl.SaslSessionContext; +import org.apache.james.protocols.api.sasl.SaslStep; + +public class ExampleTokenSaslMechanism implements SaslMechanism { + public static final String NAME = "EXAMPLE-TOKEN"; + + @Override + public String name() { + return NAME; + } + + @Override + public boolean supports(SaslProtocol protocol) { + return protocol == SaslProtocol.IMAP; + } + + @Override + public Set> requiredServices(SaslProtocol protocol) { + if (supports(protocol)) { + return Set.of(ExampleTokenSaslAuthenticationService.class); + } + return Set.of(); + } + + @Override + public boolean isAvailable(SaslSessionContext context) { + return context.service(ExampleTokenSaslAuthenticationService.class).isPresent(); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + return new ExampleTokenSaslExchange(request.initialResponse(), context); + } + + private static class ExampleTokenSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final SaslSessionContext context; + + private ExampleTokenSaslExchange(Optional initialResponse, SaslSessionContext context) { + this.initialResponse = initialResponse; + this.context = context; + } + + @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 abort() { + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return context.service(ExampleTokenSaslAuthenticationService.class) + .map(service -> service.authenticate(new String(clientResponse, StandardCharsets.UTF_8))) + .map(this::toStep) + .orElseGet(() -> new SaslStep.Failure("EXAMPLE-TOKEN authentication is not available.")); + } + + private SaslStep toStep(SaslAuthenticationResult result) { + if (result instanceof SaslAuthenticationResult.Success success) { + return new SaslStep.Success(success.identity(), Optional.empty()); + } + return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); + } + } +} diff --git a/examples/custom-imap/src/main/resources/imapserver.xml b/examples/custom-imap/src/main/resources/imapserver.xml index 9f5ec0e98b9..a39e54b7935 100644 --- a/examples/custom-imap/src/main/resources/imapserver.xml +++ b/examples/custom-imap/src/main/resources/imapserver.xml @@ -36,6 +36,14 @@ under the License. pong.response=customImapParameter prop.b=anotherValue false + + PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages @@ -55,6 +63,14 @@ under the License. pong.response=bad prop.b=baad false + + PlainSaslMechanism,OauthBearerSaslMechanism,XOauth2SaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java new file mode 100644 index 00000000000..cc71d78804b --- /dev/null +++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java @@ -0,0 +1,109 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.apache.james.jmap.JMAPTestingConstants.BOB; +import static org.apache.james.jmap.JMAPTestingConstants.BOB_PASSWORD; +import static org.apache.james.jmap.JMAPTestingConstants.DOMAIN; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.MemoryJamesConfiguration; +import org.apache.james.MemoryJamesServerMain; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.TestIMAPClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class CustomSaslMechanismTest { + private static final String EXPECTED_TOKEN = "secret-token"; + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + MemoryJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(MemoryJamesServerMain::createServer) + .build(); + + @BeforeEach + void setup(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(BOB.asString(), BOB_PASSWORD); + } + + @Test + void imapServerShouldAdvertiseCustomSaslMechanism(GuiceJamesServer server) throws IOException { + assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server)) + .sendCommand("CAPABILITY")) + .contains("AUTH=PLAIN", "AUTH=EXAMPLE-TOKEN"); + } + + @Test + void imapServerShouldAuthenticateCustomSaslMechanismUsingOwnConfiguration(GuiceJamesServer server) throws IOException { + TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); + + assertThat(client.sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode(EXPECTED_TOKEN))) + .contains("OK AUTHENTICATE completed."); + assertThat(client.sendCommand("PING")) + .contains("PONG"); + } + + @Test + void plainSaslAuthenticationShouldStillWork(GuiceJamesServer server) throws IOException { + TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); + + assertThat(client.sendCommand("AUTHENTICATE PLAIN " + encodePlainInitialResponse())) + .contains("OK AUTHENTICATE completed."); + assertThat(client.sendCommand("PING")) + .contains("PONG"); + } + + @Test + void imapServerShouldRejectInvalidCustomSaslToken(GuiceJamesServer server) throws IOException { + assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server)) + .sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode("invalid-token"))) + .contains("NO AUTHENTICATE failed."); + } + + private int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + private String encode(String token) { + return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); + } + + private String encodePlainInitialResponse() { + return encode(BOB.asString() + "\0" + BOB.asString() + "\0" + BOB_PASSWORD); + } +} From b28545216ddb19d6528898eec73053202203b52f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 10 Jun 2026 09:20:33 +0700 Subject: [PATCH 12/29] JAMES-4210 Custom IMAP SASL extension example: add a conditional continuation behavior --- examples/custom-imap/README.md | 19 +++++++++++++++ .../imap/sasl/ExampleTokenSaslMechanism.java | 4 +++- .../imap/CustomSaslMechanismTest.java | 23 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md index de47b4c724b..5845aaa9c13 100644 --- a/examples/custom-imap/README.md +++ b/examples/custom-imap/README.md @@ -99,3 +99,22 @@ A03 PING * PONG A03 OK PING completed. ``` + +The custom SASL mechanism also supports a continuation when the client does not send +the initial response in the `AUTHENTICATE` command. The continuation payload is +base64-encoded by IMAP, so `R28gYWhlYWQ` decodes to `Go ahead`: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 AUTHENTICATE EXAMPLE-TOKEN ++ R28gYWhlYWQ +c2VjcmV0LXRva2Vu +A01 OK AUTHENTICATE completed. +A02 PING +* PONG +A02 OK PING completed. +``` diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java index 6fe9f577ac9..e4f6e4517d8 100644 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java @@ -33,6 +33,7 @@ public class ExampleTokenSaslMechanism implements SaslMechanism { public static final String NAME = "EXAMPLE-TOKEN"; + public static final String CONTINUATION_PROMPT = "Go ahead"; @Override public String name() { @@ -75,7 +76,8 @@ private ExampleTokenSaslExchange(Optional initialResponse, SaslSessionCo public SaslStep firstStep() { return initialResponse .map(this::authenticate) - .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + .orElseGet(() -> new SaslStep.Challenge(Optional.of(CONTINUATION_PROMPT + .getBytes(StandardCharsets.UTF_8)))); } @Override diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java index cc71d78804b..f7e45fc20c0 100644 --- a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java +++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java @@ -29,11 +29,13 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +import org.apache.commons.net.imap.IMAPClient; import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.MemoryJamesConfiguration; import org.apache.james.MemoryJamesServerMain; +import org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism; import org.apache.james.modules.protocols.ImapGuiceProbe; import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.TestIMAPClient; @@ -43,6 +45,7 @@ class CustomSaslMechanismTest { private static final String EXPECTED_TOKEN = "secret-token"; + private static final String LOCALHOST_IP = "127.0.0.1"; @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> @@ -78,6 +81,26 @@ void imapServerShouldAuthenticateCustomSaslMechanismUsingOwnConfiguration(GuiceJ .contains("PONG"); } + @Test + void imapServerShouldAuthenticateCustomSaslMechanismUsingContinuation(GuiceJamesServer server) throws IOException { + IMAPClient client = new IMAPClient(); + client.connect(LOCALHOST_IP, imapPort(server)); + try { + client.sendCommand("AUTHENTICATE EXAMPLE-TOKEN"); + assertThat(client.getReplyString()).contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + client.sendData(encode(EXPECTED_TOKEN)); + assertThat(client.getReplyString()) + .contains("OK AUTHENTICATE completed."); + + client.sendCommand("PING"); + assertThat(client.getReplyString()) + .contains("PONG"); + } finally { + client.disconnect(); + } + } + @Test void plainSaslAuthenticationShouldStillWork(GuiceJamesServer server) throws IOException { TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); From 8bc52cc2c68772710dc2312ed6fe0c8d4a495d98 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 15:37:06 +0700 Subject: [PATCH 13/29] JAMES-4210 Custom IMAP SASL extension example: strengthen test case for conditional continuation behavior --- .../imap/CustomSaslMechanismTest.java | 81 +++++++++++++++---- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java index f7e45fc20c0..6c7a6d0ca75 100644 --- a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java +++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java @@ -25,11 +25,18 @@ import static org.apache.james.jmap.JMAPTestingConstants.DOMAIN; import static org.assertj.core.api.Assertions.assertThat; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.EOFException; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; +import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.function.Predicate; -import org.apache.commons.net.imap.IMAPClient; import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; @@ -44,6 +51,45 @@ import org.junit.jupiter.api.extension.RegisterExtension; class CustomSaslMechanismTest { + private static class ClientConnection implements AutoCloseable { + private final Socket socket; + private final BufferedReader reader; + private final BufferedWriter writer; + + private ClientConnection(String host, int port) throws IOException { + socket = new Socket(); + socket.connect(new InetSocketAddress(host, port)); + socket.setSoTimeout(5_000); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)); + writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII)); + } + + private void writeLine(String line) throws IOException { + writer.write(line); + writer.write("\r\n"); + writer.flush(); + } + + private String readUntil(Predicate condition) throws IOException { + StringBuilder response = new StringBuilder(); + while (true) { + String line = reader.readLine(); + if (line == null) { + throw new EOFException("Connection closed while waiting for IMAP response"); + } + response.append(line).append("\n"); + if (condition.test(line)) { + return response.toString(); + } + } + } + + @Override + public void close() throws IOException { + socket.close(); + } + } + private static final String EXPECTED_TOKEN = "secret-token"; private static final String LOCALHOST_IP = "127.0.0.1"; @@ -83,21 +129,22 @@ void imapServerShouldAuthenticateCustomSaslMechanismUsingOwnConfiguration(GuiceJ @Test void imapServerShouldAuthenticateCustomSaslMechanismUsingContinuation(GuiceJamesServer server) throws IOException { - IMAPClient client = new IMAPClient(); - client.connect(LOCALHOST_IP, imapPort(server)); - try { - client.sendCommand("AUTHENTICATE EXAMPLE-TOKEN"); - assertThat(client.getReplyString()).contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); - - client.sendData(encode(EXPECTED_TOKEN)); - assertThat(client.getReplyString()) - .contains("OK AUTHENTICATE completed."); - - client.sendCommand("PING"); - assertThat(client.getReplyString()) + try (ClientConnection client = clientConnection(server)) { + client.readUntil(line -> line.startsWith("* OK")); + + client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN"); + assertThat(client.readUntil(line -> line.startsWith("+"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + client.writeLine(encode(EXPECTED_TOKEN)); + String authenticationResponse = client.readUntil(line -> line.startsWith("A01")); + assertThat(authenticationResponse) + .contains("OK AUTHENTICATE completed.") + .doesNotContain("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + client.writeLine("A02 PING"); + assertThat(client.readUntil(line -> line.startsWith("A02"))) .contains("PONG"); - } finally { - client.disconnect(); } } @@ -122,6 +169,10 @@ private int imapPort(GuiceJamesServer server) { return server.getProbe(ImapGuiceProbe.class).getImapPort(); } + private ClientConnection clientConnection(GuiceJamesServer server) throws IOException { + return new ClientConnection(LOCALHOST_IP, imapPort(server)); + } + private String encode(String token) { return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); } From 542f0b85b2ecf47d4403513addc04ba7ec134412 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:00:24 +0700 Subject: [PATCH 14/29] JAMES-4210 Simplify SASL SPI around exchanges and credentials Replace the service/context-driven SASL SPI with mechanism-owned exchanges returning protocol-applied credentials, challenges, success data, or failures. Update built-in PLAIN/OIDC mechanisms and API tests to the simplified model. --- .../api/sasl/OauthBearerSaslMechanism.java | 7 +- ...Mechanism.java => OidcSaslMechanisms.java} | 46 +--- .../api/sasl/PlainSaslMechanism.java | 53 +---- .../api/sasl/SaslAuthenticationResult.java | 37 --- .../SaslAuthenticationServiceFactory.java | 42 ---- ...ationService.java => SaslCredentials.java} | 19 +- .../api/sasl/SaslInitialRequest.java | 11 +- .../protocols/api/sasl/SaslMechanism.java | 26 +-- ...Service.java => SaslMechanismFactory.java} | 13 +- .../api/sasl/SaslMechanismLoader.java | 31 --- .../sasl/SaslMechanismLoadingException.java | 29 --- .../api/sasl/SaslMechanismRegistry.java | 121 ---------- .../protocols/api/sasl/SaslProtocol.java | 31 --- .../api/sasl/SaslSessionContext.java | 37 --- .../james/protocols/api/sasl/SaslStep.java | 24 +- .../api/sasl/XOauth2SaslMechanism.java | 7 +- .../api/sasl/OidcSaslMechanismTest.java | 70 +++--- .../api/sasl/PlainSaslMechanismTest.java | 75 +++--- .../api/sasl/SaslMechanismContractTest.java | 221 +++++++----------- .../api/sasl/SaslMechanismRegistryTest.java | 175 -------------- .../api/sasl/TestSaslSessionContext.java | 46 ---- 21 files changed, 212 insertions(+), 909 deletions(-) rename protocols/api/src/main/java/org/apache/james/protocols/api/sasl/{AbstractOidcSaslMechanism.java => OidcSaslMechanisms.java} (57%) delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java rename protocols/api/src/main/java/org/apache/james/protocols/api/sasl/{PasswordSaslAuthenticationService.java => SaslCredentials.java} (63%) rename protocols/api/src/main/java/org/apache/james/protocols/api/sasl/{BearerTokenSaslAuthenticationService.java => SaslMechanismFactory.java} (74%) delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java delete mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java delete mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java delete mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java index b555c25e613..26a1572d816 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java @@ -19,11 +19,16 @@ package org.apache.james.protocols.api.sasl; -public class OauthBearerSaslMechanism extends AbstractOidcSaslMechanism { +public class OauthBearerSaslMechanism implements SaslMechanism { public static final String NAME = "OAUTHBEARER"; @Override public String name() { return NAME; } + + @Override + public SaslExchange start(SaslInitialRequest request) { + return OidcSaslMechanisms.start(request.initialResponse()); + } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java similarity index 57% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java rename to protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java index 7d6db4edb8f..2f584070f4d 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/AbstractOidcSaslMechanism.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java @@ -21,42 +21,23 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; -import java.util.Set; import org.apache.james.core.Username; import org.apache.james.protocols.api.OIDCSASLParser; -abstract class AbstractOidcSaslMechanism implements SaslMechanism { - @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP || protocol == SaslProtocol.SMTP; +public final class OidcSaslMechanisms { + static SaslExchange start(Optional initialResponse) { + return new OidcSaslExchange(initialResponse); } - @Override - public Set> requiredServices(SaslProtocol protocol) { - if (supports(protocol)) { - return Set.of(BearerTokenSaslAuthenticationService.class); - } - return Set.of(); - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return context.service(BearerTokenSaslAuthenticationService.class).isPresent(); - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - return new OidcSaslExchange(request.initialResponse(), context); + private OidcSaslMechanisms() { } private static class OidcSaslExchange implements SaslExchange { private final Optional initialResponse; - private final SaslSessionContext context; - private OidcSaslExchange(Optional initialResponse, SaslSessionContext context) { + private OidcSaslExchange(Optional initialResponse) { this.initialResponse = initialResponse; - this.context = context; } @Override @@ -81,22 +62,9 @@ public void close() { private SaslStep authenticate(byte[] clientResponse) { return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII)) - .map(this::authenticate) + .map(response -> (SaslStep) new SaslStep.Credentials(new SaslCredentials.BearerToken( + response.getToken(), Username.of(response.getAssociatedUser())))) .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); } - - private SaslStep authenticate(OIDCSASLParser.OIDCInitialResponse initialResponse) { - return context.service(BearerTokenSaslAuthenticationService.class) - .map(service -> service.authenticate(initialResponse.getToken(), Username.of(initialResponse.getAssociatedUser()))) - .map(this::toStep) - .orElseGet(() -> new SaslStep.Failure("OIDC authentication is not available.")); - } - - private SaslStep toStep(SaslAuthenticationResult result) { - if (result instanceof SaslAuthenticationResult.Success success) { - return new SaslStep.Success(success.identity(), Optional.empty()); - } - return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); - } } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java index 43b4346322f..5f01ea9e98c 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java @@ -22,7 +22,6 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Optional; -import java.util.Set; import java.util.function.Function; import org.apache.james.core.Username; @@ -45,36 +44,16 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP || protocol == SaslProtocol.SMTP; - } - - @Override - public Set> requiredServices(SaslProtocol protocol) { - if (supports(protocol)) { - return Set.of(PasswordSaslAuthenticationService.class); - } - return Set.of(); - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return context.service(PasswordSaslAuthenticationService.class).isPresent(); - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - return new PlainSaslExchange(request.initialResponse(), context, this::parse); + public SaslExchange start(SaslInitialRequest request) { + return new PlainSaslExchange(request.initialResponse(), this::parse); } private static class PlainSaslExchange implements SaslExchange { private final Optional initialResponse; - private final SaslSessionContext context; private final Function> credentialsParser; - private PlainSaslExchange(Optional initialResponse, SaslSessionContext context, Function> credentialsParser) { + private PlainSaslExchange(Optional initialResponse, Function> credentialsParser) { this.initialResponse = initialResponse; - this.context = context; this.credentialsParser = credentialsParser; } @@ -100,36 +79,24 @@ public void close() { private SaslStep authenticate(byte[] clientResponse) { return credentialsParser.apply(clientResponse) - .map(this::authenticate) + .map(credentials -> (SaslStep) new SaslStep.Credentials(new SaslCredentials.Password( + credentials.authenticationId(), credentials.authorizationId(), credentials.password()))) .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); } - - private SaslStep authenticate(PlainCredentials credentials) { - return context.service(PasswordSaslAuthenticationService.class) - .map(service -> service.authenticate(credentials.authenticationId(), credentials.authorizationId(), credentials.password())) - .map(this::toStep) - .orElseGet(() -> new SaslStep.Failure("PLAIN authentication is not available.")); - } - - private SaslStep toStep(SaslAuthenticationResult result) { - if (result instanceof SaslAuthenticationResult.Success success) { - return new SaslStep.Success(success.identity(), Optional.empty()); - } - return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); - } - } protected Optional parse(byte[] clientResponse) { - ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0")) - .filter(token -> !token.isBlank()) + ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) .collect(ImmutableList.toImmutableList()); if (tokens.size() == 2) { return Optional.of(credentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1))); } if (tokens.size() == 3) { - return Optional.of(credentials(Optional.of(Username.of(tokens.get(0))), Username.of(tokens.get(1)), tokens.get(2))); + Optional authorizationId = Optional.of(tokens.get(0)) + .filter(value -> !value.isEmpty()) + .map(Username::of); + return Optional.of(credentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2))); } return Optional.empty(); } diff --git a/protocols/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 deleted file mode 100644 index 899abafa4bd..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java +++ /dev/null @@ -1,37 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -/** - * Result returned by protocol-provided authentication services. - */ -public interface SaslAuthenticationResult { - /** - * Successful authentication result. - */ - record Success(SaslIdentity identity) implements SaslAuthenticationResult { - } - - /** - * Failed authentication result. - */ - record Failure(String reason) implements SaslAuthenticationResult { - } -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java deleted file mode 100644 index 74e4369b89c..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationServiceFactory.java +++ /dev/null @@ -1,42 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import java.util.Optional; - -/** - * Protocol-specific factory for services required by SASL mechanisms. - */ -public interface SaslAuthenticationServiceFactory { - /** - * Protocol supported by the produced service. - */ - SaslProtocol protocol(); - - /** - * Service type produced by this factory. - */ - Class serviceType(); - - /** - * Creates the service for the supplied SASL session context when available. - */ - Optional create(SaslSessionContext context); -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java similarity index 63% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java rename to protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java index a70222bcd45..b2c0a5433d5 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PasswordSaslAuthenticationService.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java @@ -24,11 +24,18 @@ import org.apache.james.core.Username; /** - * Protocol-provided service used by password based SASL mechanisms. + * Credentials parsed by SASL mechanisms and applied by protocol handlers. */ -public interface PasswordSaslAuthenticationService { - /** - * Authenticates the supplied credentials and returns the authenticated SASL identity. - */ - SaslAuthenticationResult authenticate(Username authenticationId, Optional authorizationId, String password); +public sealed interface SaslCredentials permits SaslCredentials.Password, SaslCredentials.BearerToken { + record Password(Username authenticationId, Optional authorizationId, String password) implements SaslCredentials { + public String toString() { + return "Password[authenticationId=" + authenticationId.asString() + ", authorizationId=" + authorizationId.map(Username::asString) + ", password=******]"; + } + } + + record BearerToken(String token, Username authorizationId) implements SaslCredentials { + public String toString() { + return "BearerToken[token=******, authorizationId=" + authorizationId.asString() + "]"; + } + } } 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 index 16393661a2b..4e245eebd18 100644 --- 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 @@ -19,23 +19,16 @@ package org.apache.james.protocols.api.sasl; -import java.util.Objects; import java.util.Optional; /** * Protocol-neutral initial SASL request. * - * @param protocol protocol receiving the SASL exchange * @param mechanismName requested SASL mechanism name * @param initialResponse decoded initial client response, when supplied by the client */ -public record SaslInitialRequest(SaslProtocol protocol, String mechanismName, Optional initialResponse) { - public SaslInitialRequest(SaslProtocol protocol, String mechanismName, Optional initialResponse) { - Objects.requireNonNull(protocol); - Objects.requireNonNull(mechanismName); - Objects.requireNonNull(initialResponse); - - this.protocol = protocol; +public record SaslInitialRequest(String mechanismName, Optional initialResponse) { + public SaslInitialRequest(String mechanismName, Optional initialResponse) { this.mechanismName = mechanismName; this.initialResponse = 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 index 5f41cae009f..ae856b518ff 100644 --- 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 @@ -19,8 +19,6 @@ package org.apache.james.protocols.api.sasl; -import java.util.Set; - /** * Protocol-neutral SASL mechanism. */ @@ -30,30 +28,8 @@ public interface SaslMechanism { */ String name(); - /** - * Whether this mechanism can be used by the supplied protocol. - * - *

A mechanism may intentionally support only a subset of protocols when its - * wire payload, authorization semantics, or surrounding protocol state is only - * valid for those protocols. For example, a custom PLAIN variant may support - * only IMAP when it relies on IMAP-specific delegation semantics. - */ - boolean supports(SaslProtocol protocol); - - /** - * Lists protocol-provided service types required by this mechanism. - */ - default Set> requiredServices(SaslProtocol protocol) { - return Set.of(); - } - - /** - * Whether this mechanism is currently usable for the supplied session context. - */ - boolean isAvailable(SaslSessionContext context); - /** * Starts a new SASL exchange for one client authentication attempt. */ - SaslExchange start(SaslInitialRequest request, SaslSessionContext context); + SaslExchange start(SaslInitialRequest request); } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java similarity index 74% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java rename to protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java index a185febcf78..77fb9e5e709 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/BearerTokenSaslAuthenticationService.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java @@ -19,14 +19,13 @@ package org.apache.james.protocols.api.sasl; -import org.apache.james.core.Username; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; /** - * Protocol-provided service used by bearer-token based SASL mechanisms. + * Creates a SASL mechanism for one server configuration block. */ -public interface BearerTokenSaslAuthenticationService { - /** - * Authenticates the token and returns the authenticated SASL identity. - */ - SaslAuthenticationResult authenticate(String token, Username authorizationId); +public interface SaslMechanismFactory { + SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException; } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java deleted file mode 100644 index 104531c47bd..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoader.java +++ /dev/null @@ -1,31 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import java.util.Collection; - -import com.google.common.collect.ImmutableList; - -public interface SaslMechanismLoader { - /** - * Instantiates the configured SASL mechanism classes in declaration order. - */ - ImmutableList load(Collection classNames); -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java deleted file mode 100644 index 69ee9404d93..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismLoadingException.java +++ /dev/null @@ -1,29 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -/** - * Thrown when a configured SASL mechanism cannot be loaded. - */ -public class SaslMechanismLoadingException extends RuntimeException { - public SaslMechanismLoadingException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java deleted file mode 100644 index a0a73fcf299..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistry.java +++ /dev/null @@ -1,121 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import java.util.Arrays; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; - -/** - * Registry exposing configured SASL mechanisms per protocol. - */ -public final class SaslMechanismRegistry { - private final ImmutableMap> mechanismsByProtocol; - private final ImmutableList> serviceFactories; - - public SaslMechanismRegistry(Collection mechanisms) { - this(mechanisms, ImmutableList.of()); - } - - public SaslMechanismRegistry(Collection mechanisms, Collection> serviceFactories) { - this.mechanismsByProtocol = Arrays.stream(SaslProtocol.values()) - .collect(ImmutableMap.toImmutableMap(Function.identity(), protocol -> mechanismsFor(mechanisms, protocol))); - this.serviceFactories = ImmutableList.copyOf(serviceFactories); - } - - /** - * Finds a configured mechanism by protocol and case-insensitive SASL mechanism name. - */ - public Optional find(String mechanismName, SaslProtocol protocol) { - String normalizedName = normalize(mechanismName); - return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) - .stream() - .filter(mechanism -> normalize(mechanism.name()).equals(normalizedName)) - .findFirst(); - } - - /** - * Lists mechanisms configured for the protocol and currently available in the session context. - */ - public Stream availableFor(SaslProtocol protocol, SaslSessionContext context) { - return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) - .stream() - .filter(mechanism -> mechanism.isAvailable(context)); - } - - /** - * Initializes services for all mechanisms configured for the protocol. - */ - public void initialize(SaslProtocol protocol, SaslSessionContext context) { - mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) - .stream() - .flatMap(mechanism -> mechanism.requiredServices(protocol).stream()) - .distinct() - .forEach(serviceType -> registerService(protocol, context, serviceType)); - } - - /** - * Lists configured mechanisms for the protocol, regardless of session availability. - */ - public Stream configuredFor(SaslProtocol protocol) { - return mechanismsByProtocol.getOrDefault(protocol, ImmutableList.of()) - .stream(); - } - - private ImmutableList mechanismsFor(Collection mechanisms, SaslProtocol protocol) { - return mechanisms.stream() - .filter(mechanism -> mechanism.supports(protocol)) - .collect(Collectors.toMap( - mechanism -> normalize(mechanism.name()), - Function.identity(), - (first, second) -> first, - LinkedHashMap::new)) - .values() - .stream() - .collect(ImmutableList.toImmutableList()); - } - - private String normalize(String mechanismName) { - return mechanismName.toUpperCase(Locale.US); - } - - private void registerService(SaslProtocol protocol, SaslSessionContext context, Class serviceType) { - serviceFactory(protocol, serviceType) - .flatMap(factory -> factory.create(context)) - .ifPresent(service -> context.register(serviceType, service)); - } - - @SuppressWarnings("unchecked") - private Optional> serviceFactory(SaslProtocol protocol, Class serviceType) { - return serviceFactories.stream() - .filter(factory -> factory.protocol() == protocol) - .filter(factory -> factory.serviceType().equals(serviceType)) - .findFirst() - .map(factory -> (SaslAuthenticationServiceFactory) factory); - } -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java deleted file mode 100644 index f51c2f61fc3..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslProtocol.java +++ /dev/null @@ -1,31 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -/** - * Protocols that can host SASL authentication. - */ -public enum SaslProtocol { - IMAP, - SMTP, - LMTP, - MANAGESIEVE, - POP3 -} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java deleted file mode 100644 index f64abdd29ed..00000000000 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslSessionContext.java +++ /dev/null @@ -1,37 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import java.util.Optional; - -/** - * Protocol-provided context exposed to SASL mechanisms. - */ -public interface SaslSessionContext { - /** - * Looks up protocol-provided services, such as password or bearer-token authentication. - */ - Optional service(Class serviceType); - - /** - * Registers a protocol-provided service for the current SASL session. - */ - void register(Class serviceType, T service); -} 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 index b792ca2f65b..6d5fa844335 100644 --- 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 @@ -19,20 +19,18 @@ package org.apache.james.protocols.api.sasl; -import java.util.Objects; import java.util.Optional; /** * Server step produced by a SASL exchange. */ -public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Success, SaslStep.Failure { +public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Credentials, SaslStep.Success, SaslStep.Failure { /** * Server challenge to send back to the client. */ record Challenge(Optional payload) implements SaslStep { - public Challenge { - payload = Objects.requireNonNull(payload) - .map(byte[]::clone); + public Challenge(Optional payload) { + this.payload = payload.map(byte[]::clone); } /** @@ -43,14 +41,19 @@ public Optional payload() { } } + /** + * Parsed credentials to be applied by the protocol handler. + */ + record Credentials(SaslCredentials credentials) implements SaslStep { + } + /** * Successful SASL exchange result. */ record Success(SaslIdentity identity, Optional serverData) implements SaslStep { - public Success { - identity = Objects.requireNonNull(identity); - serverData = Objects.requireNonNull(serverData) - .map(byte[]::clone); + public Success(SaslIdentity identity, Optional serverData) { + this.identity = identity; + this.serverData = serverData.map(byte[]::clone); } /** @@ -65,8 +68,5 @@ public Optional serverData() { * Failed SASL exchange result. */ record Failure(String reason) implements SaslStep { - public Failure { - reason = Objects.requireNonNull(reason); - } } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java index f8d4f8b1a5a..0c05e7476f8 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java @@ -19,11 +19,16 @@ package org.apache.james.protocols.api.sasl; -public class XOauth2SaslMechanism extends AbstractOidcSaslMechanism { +public class XOauth2SaslMechanism implements SaslMechanism { public static final String NAME = "XOAUTH2"; @Override public String name() { return NAME; } + + @Override + public SaslExchange start(SaslInitialRequest request) { + return OidcSaslMechanisms.start(request.initialResponse()); + } } diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java index a37e035ffab..cf317966e6c 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java @@ -23,7 +23,6 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; import org.apache.james.core.Username; import org.junit.jupiter.api.Test; @@ -33,61 +32,54 @@ class OidcSaslMechanismTest { private static final String TOKEN = "token"; @Test - void oauthBearerShouldAuthenticateDecodedInitialResponse() { - AtomicReference token = new AtomicReference<>(); - AtomicReference authorizationId = new AtomicReference<>(); - BearerTokenSaslAuthenticationService service = (bearerToken, user) -> { - token.set(bearerToken); - authorizationId.set(user); - return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); - }; - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, OauthBearerSaslMechanism.NAME, + void oauthBearerShouldReturnBearerTokenCredentialsFromDecodedInitialResponse() { + // GIVEN a decoded OAUTHBEARER initial response + SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); - SaslStep step = new OauthBearerSaslMechanism() - .start(request, new TestSaslSessionContext(Optional.empty(), Optional.of(service))) - .firstStep(); + // WHEN the mechanism consumes the response + SaslStep step = new OauthBearerSaslMechanism().start(request).firstStep(); - assertThat(step).isInstanceOf(SaslStep.Success.class); - assertThat(token.get()).isEqualTo(TOKEN); - assertThat(authorizationId.get()).isEqualTo(USER); + // THEN it returns protocol-neutral bearer token credentials + assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.BearerToken(TOKEN, USER))); } @Test - void xOauth2ShouldAuthenticateDecodedInitialResponse() { - AtomicReference token = new AtomicReference<>(); - BearerTokenSaslAuthenticationService service = (bearerToken, user) -> { - token.set(bearerToken); - return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); - }; - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, XOauth2SaslMechanism.NAME, + void xOauth2ShouldReturnBearerTokenCredentialsFromDecodedInitialResponse() { + // GIVEN a decoded XOAUTH2 initial response + SaslInitialRequest request = new SaslInitialRequest(XOauth2SaslMechanism.NAME, Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); - SaslStep step = new XOauth2SaslMechanism() - .start(request, new TestSaslSessionContext(Optional.empty(), Optional.of(service))) - .firstStep(); + // WHEN the mechanism consumes the response + SaslStep step = new XOauth2SaslMechanism().start(request).firstStep(); - assertThat(step).isInstanceOf(SaslStep.Success.class); - assertThat(token.get()).isEqualTo(TOKEN); + // THEN it exposes the same generic bearer-token credential shape + assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.BearerToken(TOKEN, USER))); } @Test - void shouldFailMalformedResponse() { - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, OauthBearerSaslMechanism.NAME, - Optional.of(bytes("invalid"))); + void shouldChallengeWhenNoInitialResponse() { + // GIVEN an OIDC SASL exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, Optional.empty()); - SaslStep step = new OauthBearerSaslMechanism() - .start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())) - .firstStep(); + // WHEN the mechanism starts + SaslStep firstStep = new OauthBearerSaslMechanism().start(request).firstStep(); - assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); } @Test - void shouldBeAvailableOnlyWhenBearerTokenServiceExists() { - assertThat(new OauthBearerSaslMechanism().isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.empty()))).isFalse(); - assertThat(new OauthBearerSaslMechanism().isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.of((token, user) -> - new SaslAuthenticationResult.Failure("failure"))))).isTrue(); + void shouldFailMalformedResponse() { + // GIVEN a malformed OIDC SASL response + SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + Optional.of(bytes("invalid"))); + + // WHEN the mechanism consumes the response + SaslStep step = new OauthBearerSaslMechanism().start(request).firstStep(); + + // THEN it fails before any protocol-specific token validation + assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); } private static byte[] bytes(String value) { diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java index adf34f84272..71a2544e445 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java @@ -23,7 +23,6 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; import org.apache.james.core.Username; import org.junit.jupiter.api.Test; @@ -38,68 +37,68 @@ class PlainSaslMechanismTest { @Test void shouldChallengeWhenNoInitialResponse() { // GIVEN a PLAIN exchange without SASL-IR - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, Optional.empty()); + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); // WHEN the mechanism starts - SaslStep firstStep = testee.start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())).firstStep(); + SaslStep firstStep = testee.start(request).firstStep(); // THEN the server asks for one client response assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); } @Test - void shouldAuthenticateInitialResponseWithoutDelegation() { - AtomicReference authenticationId = new AtomicReference<>(); - AtomicReference password = new AtomicReference<>(); - AtomicReference> authorizationId = new AtomicReference<>(); - PasswordSaslAuthenticationService service = (user, delegatedUser, secret) -> { - authenticationId.set(user); - password.set(secret); - authorizationId.set(delegatedUser); - return new SaslAuthenticationResult.Success(new SaslIdentity(user, user)); - }; - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, + void shouldReturnPasswordCredentialsForInitialResponseWithoutDelegation() { + // 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))); - SaslStep step = testee.start(request, new TestSaslSessionContext(Optional.of(service), Optional.empty())).firstStep(); + // WHEN the mechanism consumes the initial response + SaslStep step = testee.start(request).firstStep(); - assertThat(step).isInstanceOf(SaslStep.Success.class); - assertThat(authenticationId.get()).isEqualTo(AUTHENTICATION_ID); - assertThat(password.get()).isEqualTo(PASSWORD); - assertThat(authorizationId.get()).isEmpty(); + // THEN it returns protocol-neutral password credentials + assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( + AUTHENTICATION_ID, Optional.empty(), PASSWORD))); } @Test - void shouldAuthenticateContinuationResponseWithDelegation() { - AtomicReference> authorizationId = new AtomicReference<>(); - PasswordSaslAuthenticationService service = (user, delegatedUser, secret) -> { - authorizationId.set(delegatedUser); - return new SaslAuthenticationResult.Success(new SaslIdentity(user, delegatedUser.orElse(user))); - }; - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, Optional.empty()); - SaslExchange exchange = testee.start(request, new TestSaslSessionContext(Optional.of(service), Optional.empty())); + void shouldReturnPasswordCredentialsForContinuationResponseWithDelegation() { + // GIVEN a PLAIN exchange waiting for the client response + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + SaslExchange exchange = testee.start(request); + // WHEN the client sends a response with an authorization identity SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)); - assertThat(step).isInstanceOf(SaslStep.Success.class); - assertThat(authorizationId.get()).contains(AUTHORIZATION_ID); + // THEN both identities are preserved for protocol-level authentication and delegation + assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( + AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD))); } @Test - void shouldFailMalformedResponse() { - SaslInitialRequest request = new SaslInitialRequest(SaslProtocol.IMAP, PlainSaslMechanism.NAME, - Optional.of(bytes("missing-separators"))); + void shouldAcceptTwoPartResponseWithoutAuthorizationIdentity() { + // GIVEN a PLAIN response encoded as authcid/password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes(AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); - SaslStep step = testee.start(request, new TestSaslSessionContext(Optional.empty(), Optional.empty())).firstStep(); + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request).firstStep(); - assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); + // THEN it treats the response as non-delegated password credentials + assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( + AUTHENTICATION_ID, Optional.empty(), PASSWORD))); } @Test - void shouldBeAvailableOnlyWhenPasswordServiceExists() { - assertThat(testee.isAvailable(new TestSaslSessionContext(Optional.empty(), Optional.empty()))).isFalse(); - assertThat(testee.isAvailable(new TestSaslSessionContext(Optional.of((user, authorizationId, password) -> - new SaslAuthenticationResult.Failure("failure")), Optional.empty()))).isTrue(); + 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).firstStep(); + + // THEN it fails before any protocol-specific authentication side effect + assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); } private static byte[] bytes(String value) { 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 index e8f8bc85817..3836fc00222 100644 --- 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 @@ -22,8 +22,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; import org.apache.james.core.Username; @@ -35,6 +33,8 @@ class SaslMechanismContractTest { private static final Username AUTHENTICATION_ID = Username.of("authentication@example.com"); private static final Username AUTHORIZATION_ID = Username.of("authorization@example.com"); + private static final String PASSWORD = "secret"; + private static final String TOKEN = "access-token"; 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); @@ -54,17 +54,7 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP; - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return true; - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + public SaslExchange start(SaslInitialRequest request) { return new FixedStepExchange(firstStep); } } @@ -109,17 +99,7 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP; - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return true; - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + public SaslExchange start(SaslInitialRequest request) { return new TwoStepExchange(); } } @@ -154,7 +134,7 @@ public void close() { } /** - * Models generic password mechanisms that parse SASL payloads but delegate credential verification to the protocol. + * Models generic password mechanisms that parse SASL payloads but leave verification to the protocol. */ private static class PasswordLikeMechanism implements SaslMechanism { @Override @@ -163,206 +143,167 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP; - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return context.service(PasswordSaslAuthenticationService.class).isPresent(); - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { + public SaslExchange start(SaslInitialRequest request) { return new FixedStepExchange(request.initialResponse() - .map(payload -> authenticate(payload, context)) - .orElseGet(() -> new SaslStep.Failure("missing initial response"))); - } - - private SaslStep authenticate(byte[] payload, SaslSessionContext context) { - return context.service(PasswordSaslAuthenticationService.class) - .map(service -> service.authenticate(authenticationId(payload), authorizationId(payload), password(payload))) - .map(this::toSaslStep) - .orElseGet(() -> new SaslStep.Failure("missing password authentication service")); - } - - private Username authenticationId(byte[] payload) { - return Username.of(parts(payload)[1]); - } - - private Optional authorizationId(byte[] payload) { - return Optional.of(parts(payload)[0]) - .filter(value -> !value.isEmpty()) - .map(Username::of); - } - - private String password(byte[] payload) { - return parts(payload)[2]; - } - - private String[] parts(byte[] payload) { - return new String(payload, StandardCharsets.UTF_8).split("\u0000", -1); + .map(this::credentials) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty()))); } - private SaslStep toSaslStep(SaslAuthenticationResult authenticationResult) { - if (authenticationResult instanceof SaslAuthenticationResult.Success(SaslIdentity identity)) { - return new SaslStep.Success(identity, Optional.empty()); - } - return new SaslStep.Failure(((SaslAuthenticationResult.Failure) authenticationResult).reason()); - } - } - - private static class FakeSaslSessionContext implements SaslSessionContext { - private final Map, Object> services; - - private FakeSaslSessionContext(Map, Object> services) { - this.services = services; - } - - private static FakeSaslSessionContext withPasswordAuthenticationService(PasswordSaslAuthenticationService service) { - Map, Object> services = new HashMap<>(); - services.put(PasswordSaslAuthenticationService.class, service); - return new FakeSaslSessionContext(services); - } - - @Override - public Optional service(Class serviceType) { - return Optional.ofNullable(services.get(serviceType)) - .map(serviceType::cast); - } - - @Override - public void register(Class serviceType, T service) { - services.put(serviceType, service); + private SaslStep credentials(byte[] payload) { + String[] parts = new String(payload, StandardCharsets.UTF_8).split("\u0000", -1); + return new SaslStep.Credentials(new SaslCredentials.Password( + Username.of(parts[1]), + Optional.of(parts[0]).filter(value -> !value.isEmpty()).map(Username::of), + parts[2])); } } @Test void oneStepMechanismShouldReturnSuccess() { - // Given a one-step mechanism configured to immediately succeed + // 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()), new FakeSaslSessionContext(Map.of())); + SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty())); - // When the exchange starts + // WHEN the exchange starts SaslStep firstStep = exchange.firstStep(); - // Then the mechanism can complete without a client continuation + // 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 + // GIVEN a one-step mechanism configured to immediately fail SaslStep.Failure failure = new SaslStep.Failure("failure"); - SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty()), new FakeSaslSessionContext(Map.of())); + SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty())); - // When the exchange starts + // WHEN the exchange starts SaslStep firstStep = exchange.firstStep(); - // Then the mechanism can fail without a client continuation + // 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()), new FakeSaslSessionContext(Map.of())); + // GIVEN a mechanism that requires one challenge before accepting a response + SaslExchange exchange = new TwoStepMechanism().start(initialRequest(Optional.empty())); - // When the server sends a challenge and later receives the expected client response + // 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 + // 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 passwordLikeMechanismShouldAuthenticateThroughSessionContextService() { - // Given a protocol-provided password service and a PLAIN-like initial response - PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> { - assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); - assertThat(password).isEqualTo("secret"); - assertThat(authorizationId).isEmpty(); - return new SaslAuthenticationResult.Success(SAME_USER_IDENTITY); - }; + void passwordLikeMechanismShouldReturnProtocolNeutralCredentials() { + // GIVEN a password-like mechanism and a PLAIN-like initial response SaslExchange exchange = new PasswordLikeMechanism() - .start(initialRequest(Optional.of(bytes("\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), - FakeSaslSessionContext.withPasswordAuthenticationService(service)); + .start(initialRequest(Optional.of(bytes("\u0000" + AUTHENTICATION_ID.asString() + "\u0000" + PASSWORD)))); - // When the generic mechanism consumes the initial response + // WHEN the generic mechanism consumes the initial response SaslStep firstStep = exchange.firstStep(); - // Then authentication succeeds without depending on an IMAP or SMTP class - assertThat(((SaslStep.Success) firstStep).identity()).isEqualTo(SAME_USER_IDENTITY); + // THEN it returns credentials without depending on IMAP or SMTP authentication services + assertThat(firstStep).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( + AUTHENTICATION_ID, Optional.empty(), PASSWORD))); } @Test - void passwordLikeMechanismShouldPreserveDelegatedIdentity() { - // Given a PLAIN-like initial response with distinct authorization and authentication identities - PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> { - assertThat(authenticationId).isEqualTo(AUTHENTICATION_ID); - assertThat(password).isEqualTo("secret"); - assertThat(authorizationId).contains(AUTHORIZATION_ID); - return new SaslAuthenticationResult.Success(DELEGATED_IDENTITY); - }; + void passwordLikeMechanismShouldPreserveDelegatedIdentityInCredentials() { + // GIVEN a PLAIN-like initial response with distinct authorization and authentication identities SaslExchange exchange = new PasswordLikeMechanism() - .start(initialRequest(Optional.of(bytes(AUTHORIZATION_ID.asString() + "\u0000" + AUTHENTICATION_ID.asString() + "\u0000secret"))), - FakeSaslSessionContext.withPasswordAuthenticationService(service)); + .start(initialRequest(Optional.of(bytes(AUTHORIZATION_ID.asString() + "\u0000" + AUTHENTICATION_ID.asString() + "\u0000" + PASSWORD)))); + + // WHEN the generic mechanism consumes the initial response + SaslStep firstStep = exchange.firstStep(); + + // THEN the credentials carry both identities for protocol-level delegation handling + assertThat(firstStep).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( + AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD))); + } + + @Test + void credentialsToStringShouldRedactSecrets() { + // GIVEN credentials carrying sensitive password and bearer token values + SaslCredentials.Password password = new SaslCredentials.Password(AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD); + SaslCredentials.BearerToken bearerToken = new SaslCredentials.BearerToken(TOKEN, AUTHORIZATION_ID); + + // WHEN credentials are converted to strings, for example by accidental logging + String passwordString = password.toString(); + String bearerTokenString = bearerToken.toString(); + + // THEN the sensitive fields are redacted while identity fields remain useful for diagnostics + assertThat(passwordString) + .contains("password=******", AUTHENTICATION_ID.asString(), AUTHORIZATION_ID.asString()) + .doesNotContain(PASSWORD); + assertThat(bearerTokenString) + .contains("token=******", AUTHORIZATION_ID.asString()) + .doesNotContain(TOKEN); + } + + @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())); - // When the generic mechanism authenticates through the context service + // WHEN the exchange starts SaslStep firstStep = exchange.firstStep(); - // Then the success step carries both identities for protocol-level delegation handling + // 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 + // 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 + // WHEN the caller mutates the original array initialResponse[0] = 'I'; - // Then the request keeps the original payload + // 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 + // 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 + // WHEN the caller mutates the original arrays challengePayload[0] = 'C'; serverData[0] = 'S'; - // Then the SASL steps keep their original payloads + // 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 + // GIVEN an active exchange FixedStepExchange exchange = new FixedStepExchange(new SaslStep.Failure("failure")); - // When the protocol aborts and then closes it + // WHEN the protocol aborts and then closes it exchange.abort(); exchange.close(); - // Then mechanisms can observe both lifecycle events + // THEN mechanisms can observe both lifecycle events assertThat(exchange.aborted).isTrue(); assertThat(exchange.closed).isTrue(); } private static SaslInitialRequest initialRequest(Optional initialResponse) { - return new SaslInitialRequest(SaslProtocol.IMAP, "TEST", initialResponse); + return new SaslInitialRequest("TEST", initialResponse); } private static byte[] bytes(String value) { diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java deleted file mode 100644 index 930cc45ef05..00000000000 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismRegistryTest.java +++ /dev/null @@ -1,175 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import org.junit.jupiter.api.Test; - -import com.google.common.collect.ImmutableList; - -class SaslMechanismRegistryTest { - private static final SaslSessionContext IMAP_CONTEXT = new FakeSaslSessionContext(); - private static final SaslSessionContext SMTP_CONTEXT = new FakeSaslSessionContext(); - - @Test - void findShouldBeCaseInsensitive() { - FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain)); - - assertThat(testee.find("plain", SaslProtocol.IMAP)).contains(plain); - } - - @Test - void findShouldFilterByProtocol() { - FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain)); - - assertThat(testee.find("PLAIN", SaslProtocol.SMTP)).isEmpty(); - } - - @Test - void availableForShouldFilterUnavailableMechanisms() { - FakeSaslMechanism available = new FakeSaslMechanism("AVAILABLE", Set.of(SaslProtocol.IMAP), true); - FakeSaslMechanism unavailable = new FakeSaslMechanism("UNAVAILABLE", Set.of(SaslProtocol.IMAP), false); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(available, unavailable)); - - assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(available); - } - - @Test - void constructorShouldDeduplicateSameProtocolAndMechanismName() { - FakeSaslMechanism firstPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); - FakeSaslMechanism secondPlain = new FakeSaslMechanism("plain", Set.of(SaslProtocol.IMAP), true); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(firstPlain, secondPlain)); - - assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(firstPlain); - assertThat(testee.find("PLAIN", SaslProtocol.IMAP)).contains(firstPlain); - } - - @Test - void constructorShouldDeduplicateIndependentlyPerProtocol() { - FakeSaslMechanism imapPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true); - FakeSaslMechanism smtpPlain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.SMTP), true); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(imapPlain, smtpPlain)); - - assertThat(testee.availableFor(SaslProtocol.IMAP, IMAP_CONTEXT)).containsExactly(imapPlain); - assertThat(testee.availableFor(SaslProtocol.SMTP, SMTP_CONTEXT)).containsExactly(smtpPlain); - } - - @Test - void initializeShouldRegisterRequiredServicesForConfiguredMechanisms() { - PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> new SaslAuthenticationResult.Failure("failed"); - FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true, Set.of(PasswordSaslAuthenticationService.class)); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain), - ImmutableList.of(new FakeSaslAuthenticationServiceFactory<>(SaslProtocol.IMAP, PasswordSaslAuthenticationService.class, service))); - FakeSaslSessionContext context = new FakeSaslSessionContext(); - - testee.initialize(SaslProtocol.IMAP, context); - - assertThat(context.service(PasswordSaslAuthenticationService.class)).contains(service); - } - - @Test - void initializeShouldNotRegisterServicesForOtherProtocols() { - PasswordSaslAuthenticationService service = (authenticationId, authorizationId, password) -> new SaslAuthenticationResult.Failure("failed"); - FakeSaslMechanism plain = new FakeSaslMechanism("PLAIN", Set.of(SaslProtocol.IMAP), true, Set.of(PasswordSaslAuthenticationService.class)); - SaslMechanismRegistry testee = new SaslMechanismRegistry(ImmutableList.of(plain), - ImmutableList.of(new FakeSaslAuthenticationServiceFactory<>(SaslProtocol.SMTP, PasswordSaslAuthenticationService.class, service))); - FakeSaslSessionContext context = new FakeSaslSessionContext(); - - testee.initialize(SaslProtocol.IMAP, context); - - assertThat(context.service(PasswordSaslAuthenticationService.class)).isEmpty(); - } - - private static class FakeSaslMechanism implements SaslMechanism { - private final String name; - private final Set supportedProtocols; - private final Set> requiredServices; - private final boolean available; - - private FakeSaslMechanism(String name, Set supportedProtocols, boolean available) { - this(name, supportedProtocols, available, Set.of()); - } - - private FakeSaslMechanism(String name, Set supportedProtocols, boolean available, Set> requiredServices) { - this.name = name; - this.supportedProtocols = supportedProtocols; - this.requiredServices = requiredServices; - this.available = available; - } - - @Override - public String name() { - return name; - } - - @Override - public boolean supports(SaslProtocol protocol) { - return supportedProtocols.contains(protocol); - } - - @Override - public Set> requiredServices(SaslProtocol protocol) { - return requiredServices; - } - - @Override - public boolean isAvailable(SaslSessionContext context) { - return available; - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - throw new UnsupportedOperationException(); - } - } - - private record FakeSaslAuthenticationServiceFactory(SaslProtocol protocol, Class serviceType, T service) implements SaslAuthenticationServiceFactory { - @Override - public Optional create(SaslSessionContext context) { - return Optional.of(service); - } - } - - private static class FakeSaslSessionContext implements SaslSessionContext { - private final Map, Object> services; - - private FakeSaslSessionContext() { - this.services = new java.util.HashMap<>(); - } - - @Override - public Optional service(Class serviceType) { - return Optional.ofNullable(services.get(serviceType)) - .map(serviceType::cast); - } - - @Override - public void register(Class serviceType, T service) { - services.put(serviceType, service); - } - } -} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java deleted file mode 100644 index a19bce70223..00000000000 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/TestSaslSessionContext.java +++ /dev/null @@ -1,46 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.api.sasl; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -class TestSaslSessionContext implements SaslSessionContext { - private final Map, Object> services; - - TestSaslSessionContext(Optional passwordService, - Optional bearerTokenService) { - this.services = new HashMap<>(); - passwordService.ifPresent(service -> register(PasswordSaslAuthenticationService.class, service)); - bearerTokenService.ifPresent(service -> register(BearerTokenSaslAuthenticationService.class, service)); - } - - @Override - public Optional service(Class serviceType) { - return Optional.ofNullable(services.get(serviceType)) - .map(serviceType::cast); - } - - @Override - public void register(Class serviceType, T service) { - services.put(serviceType, service); - } -} From 5a508bd3414aa91aedc4f32e4fb3d0171fd452e6 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:03:00 +0700 Subject: [PATCH 15/29] JAMES-4210 Add Guice resolver for configured SASL mechanisms Add Guice-backed resolution for configured SASL mechanism class names, with factory lookup for server-specific mechanisms and direct instantiation fallback for stateless mechanisms. --- .../james/modules/CommonServicesModule.java | 8 +- .../james/modules/SaslMechanismFactories.java | 31 +++ ...va => GuiceSaslMechanismInstantiator.java} | 26 +- .../utils/GuiceSaslMechanismResolver.java | 102 +++++++ .../utils/SaslMechanismInstantiator.java | 28 ++ .../TestingDefaultPackageSaslMechanism.java | 28 +- .../utils/ExternalFakeSaslMechanism.java | 31 ++- .../utils/GuiceSaslMechanismLoaderTest.java | 105 ------- .../utils/GuiceSaslMechanismResolverTest.java | 258 ++++++++++++++++++ .../james/utils/GuiceGenericLoader.java | 6 + .../org/apache/james/utils/GuiceLoader.java | 3 + 11 files changed, 479 insertions(+), 147 deletions(-) create mode 100644 server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java rename server/container/guice/common/src/main/java/org/apache/james/utils/{GuiceSaslMechanismLoader.java => GuiceSaslMechanismInstantiator.java} (63%) create mode 100644 server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java create mode 100644 server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java delete mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java index 2eea9948330..6c7a22e4ed8 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java @@ -26,7 +26,6 @@ import org.apache.james.modules.server.DNSServiceModule; import org.apache.james.modules.server.DropWizardMetricsModule; import org.apache.james.onami.lifecycle.PreDestroyModule; -import org.apache.james.protocols.api.sasl.SaslMechanismLoader; import org.apache.james.server.core.configuration.Configuration; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.server.core.configuration.FileConfigurationProvider; @@ -34,8 +33,9 @@ import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.ExtensionModule; import org.apache.james.utils.GuiceProbe; -import org.apache.james.utils.GuiceSaslMechanismLoader; +import org.apache.james.utils.GuiceSaslMechanismInstantiator; import org.apache.james.utils.PropertiesProvider; +import org.apache.james.utils.SaslMechanismInstantiator; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -51,7 +51,7 @@ public CommonServicesModule(Configuration configuration) { this.fileSystem = new FileSystemImpl(configuration.directories()); } - + @Override protected void configure() { install(new ExtensionModule()); @@ -69,7 +69,7 @@ protected void configure() { bind(FileSystem.class).toInstance(fileSystem); bind(Configuration.class).toInstance(configuration); - bind(SaslMechanismLoader.class).to(GuiceSaslMechanismLoader.class); + bind(SaslMechanismInstantiator.class).to(GuiceSaslMechanismInstantiator.class); bind(ConfigurationProvider.class).toInstance(new FileConfigurationProvider(fileSystem, configuration)); diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java new file mode 100644 index 00000000000..ad86c882d70 --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import com.google.inject.BindingAnnotation; + +@BindingAnnotation +@Retention(RUNTIME) +public @interface SaslMechanismFactories { +} diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java similarity index 63% rename from server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java rename to server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java index 8ca1d5e6cdc..92d88d2c8a0 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismLoader.java +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java @@ -19,35 +19,25 @@ package org.apache.james.utils; -import java.util.Collection; +import jakarta.inject.Inject; import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismLoader; -import org.apache.james.protocols.api.sasl.SaslMechanismLoadingException; -import com.google.common.collect.ImmutableList; -import com.google.inject.Inject; - -public class GuiceSaslMechanismLoader implements SaslMechanismLoader { +public class GuiceSaslMechanismInstantiator implements SaslMechanismInstantiator { private final GuiceLoader.InvocationPerformer mechanismLoader; @Inject - public GuiceSaslMechanismLoader(GuiceLoader guiceLoader) { + public GuiceSaslMechanismInstantiator(GuiceLoader guiceLoader) { this.mechanismLoader = guiceLoader.withNamingSheme(DefaultSaslMechanismNamingScheme.asNamingScheme()); } @Override - public ImmutableList load(Collection classNames) { - return classNames.stream() - .map(this::load) - .collect(ImmutableList.toImmutableList()); + public Class locate(ClassName className) throws ClassNotFoundException { + return mechanismLoader.locateClass(className); } - private SaslMechanism load(String className) { - try { - return mechanismLoader.instantiate(new ClassName(className)); - } catch (Exception e) { - throw new SaslMechanismLoadingException("Can not load SASL mechanism " + className, e); - } + @Override + public SaslMechanism instantiate(ClassName className) throws ClassNotFoundException { + return mechanismLoader.instantiate(className); } } diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java new file mode 100644 index 00000000000..f03ae65071c --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java @@ -0,0 +1,102 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; + +public class GuiceSaslMechanismResolver { + private final SaslMechanismInstantiator instantiator; + + @Inject + public GuiceSaslMechanismResolver(SaslMechanismInstantiator instantiator) { + this.instantiator = instantiator; + } + + public ImmutableList resolve(Collection mechanismClassNames, + HierarchicalConfiguration serverConfiguration, + Map, SaslMechanismFactory> factories) throws ConfigurationException { + try { + return mechanismClassNames.stream() + .map(ClassName::new) + .map(Throwing.function(className -> resolve(className, serverConfiguration, factories))) + .collect(Collectors.toMap( + mechanism -> normalize(mechanism.name()), + Function.identity(), + (first, second) -> first, + LinkedHashMap::new)) + .values() + .stream() + .collect(ImmutableList.toImmutableList()); + } catch (RuntimeException e) { + if (e.getCause() instanceof ConfigurationException configurationException) { + throw configurationException; + } + throw e; + } + } + + private SaslMechanism resolve(ClassName mechanismClassName, + HierarchicalConfiguration serverConfiguration, + Map, SaslMechanismFactory> factories) throws ConfigurationException { + Class mechanismClass = locate(mechanismClassName); + SaslMechanismFactory factory = factories.get(mechanismClass); + if (factory != null) { + return factory.create(serverConfiguration); + } + // Fall back to direct instantiation for mechanisms that do not need server-specific configuration. + return instantiate(mechanismClassName); + } + + private Class locate(ClassName mechanismClassName) throws ConfigurationException { + try { + return instantiator.locate(mechanismClassName); + } catch (Exception e) { + throw new ConfigurationException("Can not load SASL mechanism " + mechanismClassName.getName(), e); + } + } + + private SaslMechanism instantiate(ClassName mechanismClassName) throws ConfigurationException { + try { + return instantiator.instantiate(mechanismClassName); + } catch (Exception e) { + throw new ConfigurationException("Can not load SASL mechanism " + mechanismClassName.getName(), e); + } + } + + private String normalize(String mechanismName) { + return mechanismName.toUpperCase(Locale.US); + } +} diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java b/server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java new file mode 100644 index 00000000000..e136623f526 --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java @@ -0,0 +1,28 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.james.protocols.api.sasl.SaslMechanism; + +public interface SaslMechanismInstantiator { + Class locate(ClassName className) throws ClassNotFoundException; + + SaslMechanism instantiate(ClassName className) throws ClassNotFoundException; +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java index aff5e5b2817..c61d691bb72 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java +++ b/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java @@ -26,17 +26,27 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return true; + public SaslExchange start(SaslInitialRequest request) { + return new FixedStepExchange(new SaslStep.Failure("not implemented")); } - @Override - public boolean isAvailable(SaslSessionContext context) { - return true; - } + private record FixedStepExchange(SaslStep step) implements SaslExchange { + @Override + public SaslStep firstStep() { + return step; + } - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - throw new UnsupportedOperationException(); + @Override + public SaslStep onResponse(byte[] clientResponse) { + return step; + } + + @Override + public void abort() { + } + + @Override + public void close() { + } } } diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java index e6c6e718cc1..d86cc32bf26 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java @@ -22,8 +22,7 @@ import org.apache.james.protocols.api.sasl.SaslExchange; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; +import org.apache.james.protocols.api.sasl.SaslStep; public class ExternalFakeSaslMechanism implements SaslMechanism { @Override @@ -32,17 +31,27 @@ public String name() { } @Override - public boolean supports(SaslProtocol protocol) { - return true; + public SaslExchange start(SaslInitialRequest request) { + return new FixedStepExchange(new SaslStep.Failure("not implemented")); } - @Override - public boolean isAvailable(SaslSessionContext context) { - return true; - } + private record FixedStepExchange(SaslStep step) implements SaslExchange { + @Override + public SaslStep firstStep() { + return step; + } - @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - throw new UnsupportedOperationException(); + @Override + public SaslStep onResponse(byte[] clientResponse) { + return step; + } + + @Override + public void abort() { + } + + @Override + public void close() { + } } } diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java deleted file mode 100644 index 3b30416d7f0..00000000000 --- a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismLoaderTest.java +++ /dev/null @@ -1,105 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.utils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.List; - -import org.apache.james.filesystem.api.FileSystem; -import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismLoadingException; -import org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism; -import org.junit.jupiter.api.Test; - -class GuiceSaslMechanismLoaderTest { - private static final FileSystem THROWING_FILE_SYSTEM = new FileSystem() { - @Override - public InputStream getResource(String url) { - throw new UnsupportedOperationException(); - } - - @Override - public File getFile(String fileURL) throws FileNotFoundException { - throw new FileNotFoundException(); - } - - @Override - public File getBasedir() { - throw new UnsupportedOperationException(); - } - }; - - @Test - void loadShouldResolveSimpleNameFromDefaultSaslPackage() { - // GIVEN a loader using James default SASL package as implicit prefix - GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( - GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); - - // WHEN loading a simple class name - List mechanisms = testee.load(List.of("TestingDefaultPackageSaslMechanism")); - - // THEN the mechanism is instantiated from org.apache.james.protocols.api.sasl - assertThat(mechanisms).hasOnlyElementsOfType(TestingDefaultPackageSaslMechanism.class); - } - - @Test - void loadShouldResolveFullyQualifiedClassName() { - // GIVEN a loader that also accepts extension class names - GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( - GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); - - // WHEN loading a fully qualified class name - List mechanisms = testee.load(List.of(ExternalFakeSaslMechanism.class.getCanonicalName())); - - // THEN the mechanism is instantiated without relying on the default package - assertThat(mechanisms).hasOnlyElementsOfType(ExternalFakeSaslMechanism.class); - } - - @Test - void loadShouldFailWhenClassDoesNotExist() { - // GIVEN a loader used for configured SASL mechanism entries - GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( - GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); - - // WHEN loading an unknown class name - // THEN startup wiring can fail fast with the configured entry in the error - assertThatThrownBy(() -> testee.load(List.of("MissingSaslMechanism"))) - .isInstanceOf(SaslMechanismLoadingException.class) - .hasMessageContaining("MissingSaslMechanism"); - } - - @Test - void loadShouldFailWhenClassIsNotASaslMechanism() { - // GIVEN a configured class name that exists but does not implement the SASL SPI - GuiceSaslMechanismLoader testee = new GuiceSaslMechanismLoader( - GuiceGenericLoader.forTesting(new ExtendedClassLoader(THROWING_FILE_SYSTEM))); - - // WHEN loading that class - // THEN the loader rejects it instead of returning an invalid mechanism - assertThatThrownBy(() -> testee.load(List.of(Object.class.getCanonicalName()))) - .isInstanceOf(SaslMechanismLoadingException.class) - .hasMessageContaining(Object.class.getCanonicalName()); - } -} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java new file mode 100644 index 00000000000..48e0487606d --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java @@ -0,0 +1,258 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.Optional; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +class GuiceSaslMechanismResolverTest { + private static final HierarchicalConfiguration EMPTY_CONFIGURATION = new BaseHierarchicalConfiguration(); + + @Test + void resolveShouldResolveSimpleNameFromDefaultSaslPackage() throws Exception { + // GIVEN a resolver using a test instantiator that models James default SASL package resolution + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + + // WHEN resolving a simple class name + ImmutableList mechanisms = testee.resolve(ImmutableList.of("TestingDefaultPackageSaslMechanism"), + EMPTY_CONFIGURATION, ImmutableMap.of()); + + // THEN the mechanism is instantiated from org.apache.james.protocols.api.sasl + assertThat(mechanisms).hasOnlyElementsOfType(TestingDefaultPackageSaslMechanism.class); + } + + @Test + void resolveShouldResolveFullyQualifiedClassName() throws Exception { + // GIVEN a resolver that also accepts extension class names + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + + // WHEN resolving a fully qualified class name + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), + EMPTY_CONFIGURATION, ImmutableMap.of()); + + // THEN the mechanism is instantiated without relying on the default package + assertThat(mechanisms).hasOnlyElementsOfType(ExternalFakeSaslMechanism.class); + } + + @Test + void resolveShouldUseFactoryBindingBeforeDirectInstantiation() throws Exception { + // GIVEN a factory binding for a configured mechanism class + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.example.realm", "example.org"); + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + SaslMechanismFactory factory = serverConfiguration -> + new FactoryBackedSaslMechanism(serverConfiguration.getString("auth.example.realm")); + + // WHEN resolving that class name + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), + configuration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)); + + // THEN the factory creates the server-specific mechanism instance + assertThat(mechanisms) + .singleElement() + .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, + mechanism -> assertThat(mechanism.realm()).isEqualTo("example.org")); + } + + @Test + void resolveShouldCreateFactoryBackedMechanismsFromCurrentServerConfiguration() throws Exception { + // GIVEN two server configurations using the same configured SASL mechanism class + BaseHierarchicalConfiguration firstConfiguration = new BaseHierarchicalConfiguration(); + firstConfiguration.addProperty("auth.example.realm", "first.example.org"); + BaseHierarchicalConfiguration secondConfiguration = new BaseHierarchicalConfiguration(); + secondConfiguration.addProperty("auth.example.realm", "second.example.org"); + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + SaslMechanismFactory factory = serverConfiguration -> + new FactoryBackedSaslMechanism(serverConfiguration.getString("auth.example.realm")); + + // WHEN resolving the same configured mechanism for each server + SaslMechanism firstMechanism = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), + firstConfiguration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)) + .getFirst(); + SaslMechanism secondMechanism = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), + secondConfiguration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)) + .getFirst(); + + // THEN each mechanism is created from that server's configuration, not from a global singleton + assertThat(firstMechanism) + .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, + mechanism -> assertThat(mechanism.realm()).isEqualTo("first.example.org")); + assertThat(secondMechanism) + .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, + mechanism -> assertThat(mechanism.realm()).isEqualTo("second.example.org")); + } + + @Test + void resolveShouldPreserveConfiguredOrderForDistinctMechanisms() throws Exception { + // GIVEN a configured mechanism list with distinct SASL mechanism names + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + + // WHEN resolving the list + ImmutableList mechanisms = testee.resolve(ImmutableList.of( + ExternalFakeSaslMechanism.class.getCanonicalName(), + "TestingDefaultPackageSaslMechanism"), + EMPTY_CONFIGURATION, ImmutableMap.of()); + + // THEN configured order is preserved + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("EXTERNAL-FAKE", "DEFAULT"); + } + + @Test + void resolveShouldDeduplicateMechanismNamesCaseInsensitively() throws Exception { + // GIVEN two configured classes returning the same SASL mechanism name with different case + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + + // WHEN resolving both classes + ImmutableList mechanisms = testee.resolve(ImmutableList.of( + DuplicateUpperCaseSaslMechanism.class.getCanonicalName(), + DuplicateLowerCaseSaslMechanism.class.getCanonicalName()), + EMPTY_CONFIGURATION, ImmutableMap.of()); + + // THEN first occurrence wins and configured order remains stable + assertThat(mechanisms) + .hasSize(1) + .hasOnlyElementsOfType(DuplicateUpperCaseSaslMechanism.class); + } + + @Test + void resolveShouldFailWhenClassDoesNotExist() { + // GIVEN a resolver used for configured SASL mechanism entries + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + + // WHEN resolving an unknown class name + // THEN startup wiring can fail fast with the configured entry in the error + assertThatThrownBy(() -> testee.resolve(ImmutableList.of("MissingSaslMechanism"), EMPTY_CONFIGURATION, ImmutableMap.of())) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("MissingSaslMechanism"); + } + + private static class MapBackedSaslMechanismInstantiator implements SaslMechanismInstantiator { + private final Map> classes = ImmutableMap.>builder() + .put("TestingDefaultPackageSaslMechanism", TestingDefaultPackageSaslMechanism.class) + .put(TestingDefaultPackageSaslMechanism.class.getCanonicalName(), TestingDefaultPackageSaslMechanism.class) + .put(ExternalFakeSaslMechanism.class.getCanonicalName(), ExternalFakeSaslMechanism.class) + .put(FactoryBackedSaslMechanism.class.getCanonicalName(), FactoryBackedSaslMechanism.class) + .put(DuplicateUpperCaseSaslMechanism.class.getCanonicalName(), DuplicateUpperCaseSaslMechanism.class) + .put(DuplicateLowerCaseSaslMechanism.class.getCanonicalName(), DuplicateLowerCaseSaslMechanism.class) + .build(); + + @Override + public Class locate(ClassName className) throws ClassNotFoundException { + return Optional.ofNullable(classes.get(className.getName())) + .orElseThrow(() -> new ClassNotFoundException(className.getName())); + } + + @Override + public SaslMechanism instantiate(ClassName className) throws ClassNotFoundException { + try { + return locate(className).getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new ClassNotFoundException(className.getName(), e); + } + } + } + + public static class FactoryBackedSaslMechanism extends FixedNameSaslMechanism { + private final String realm; + + public FactoryBackedSaslMechanism() { + this("unused"); + } + + private FactoryBackedSaslMechanism(String realm) { + super("FACTORY"); + this.realm = realm; + } + + private String realm() { + return realm; + } + } + + public static class DuplicateUpperCaseSaslMechanism extends FixedNameSaslMechanism { + public DuplicateUpperCaseSaslMechanism() { + super("DUPLICATE"); + } + } + + public static class DuplicateLowerCaseSaslMechanism extends FixedNameSaslMechanism { + public DuplicateLowerCaseSaslMechanism() { + super("duplicate"); + } + } + + private abstract static class FixedNameSaslMechanism implements SaslMechanism { + private final String name; + + private FixedNameSaslMechanism(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public SaslExchange start(SaslInitialRequest request) { + return new FixedStepExchange(); + } + } + + private record FixedStepExchange() implements SaslExchange { + @Override + public SaslStep firstStep() { + return new SaslStep.Failure("not implemented"); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return new SaslStep.Failure("not implemented"); + } + + @Override + public void abort() { + } + + @Override + public void close() { + } + } +} diff --git a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java index 09cf9cb534d..912d84f4593 100644 --- a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java +++ b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java @@ -30,6 +30,7 @@ import com.google.inject.Guice; import com.google.inject.Inject; import com.google.inject.Injector; +import com.google.inject.Key; import com.google.inject.Module; import com.google.inject.util.Modules; @@ -119,6 +120,11 @@ public T instantiate(ClassName className) throws ClassNotFoundException { .instantiate(className); } + @Override + public T getInstance(Key key) { + return injector.getInstance(key); + } + @Override public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { return new InvocationPerformer<>(injector, extendedClassLoader, namingSheme); diff --git a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java index 3da6906b19a..a9c3a937669 100644 --- a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java +++ b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java @@ -19,6 +19,7 @@ package org.apache.james.utils; +import com.google.inject.Key; import com.google.inject.Module; public interface GuiceLoader { @@ -36,6 +37,8 @@ public interface InvocationPerformer { T instantiate(ClassName className) throws ClassNotFoundException; + T getInstance(Key key); + InvocationPerformer withNamingSheme(NamingScheme namingSheme); InvocationPerformer withChildModule(Module childModule); From b038b9dd59722ea9e4e34398bfa3253a5adf9019 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:12:00 +0700 Subject: [PATCH 16/29] JAMES-4210 Adapt IMAP AUTHENTICATE to SASL exchanges Move IMAP AUTHENTICATE to the simplified exchange model, apply parsed credentials in AuthenticateProcessor, support final server data, and harden exchange cleanup. Buffer the initial continuation and let normal request completion flush once to avoid duplicate continuation responses. --- .../imap/processor/AuthenticateProcessor.java | 325 +++++++++++++----- ...pBearerTokenSaslAuthenticationService.java | 101 ------ ...TokenSaslAuthenticationServiceFactory.java | 57 --- ...ImapPasswordSaslAuthenticationService.java | 106 ------ ...swordSaslAuthenticationServiceFactory.java | 57 --- .../imap/processor/sasl/ImapSaslBridge.java | 27 +- .../sasl/ImapSaslSessionContext.java | 106 ------ .../processor/AuthenticateProcessorTest.java | 272 +++++++++++++++ .../processor/sasl/ImapSaslBridgeTest.java | 61 +++- .../sasl/ImapSaslSessionContextTest.java | 51 --- 10 files changed, 601 insertions(+), 562 deletions(-) delete mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java delete mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java delete mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java delete mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java delete mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java create mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java delete mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java index 84138a71312..a6ef354a95e 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java @@ -28,27 +28,23 @@ import org.apache.james.imap.api.display.HumanReadableText; import org.apache.james.imap.api.message.Capability; -import org.apache.james.imap.api.message.request.ImapRequest; import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.main.PathConverter; import org.apache.james.imap.message.request.AuthenticateRequest; import org.apache.james.imap.message.request.IRAuthenticateRequest; import org.apache.james.imap.message.response.AuthenticateResponse; -import org.apache.james.imap.processor.sasl.ImapBearerTokenSaslAuthenticationServiceFactory; -import org.apache.james.imap.processor.sasl.ImapPasswordSaslAuthenticationServiceFactory; import org.apache.james.imap.processor.sasl.ImapSaslBridge; -import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; +import org.apache.james.jwt.OidcJwtTokenVerifier; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; import org.apache.james.protocols.api.sasl.OauthBearerSaslMechanism; import org.apache.james.protocols.api.sasl.PlainSaslMechanism; +import org.apache.james.protocols.api.sasl.SaslCredentials; import org.apache.james.protocols.api.sasl.SaslExchange; import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismRegistry; -import org.apache.james.protocols.api.sasl.SaslProtocol; import org.apache.james.protocols.api.sasl.SaslStep; import org.apache.james.protocols.api.sasl.XOauth2SaslMechanism; import org.apache.james.util.MDCBuilder; @@ -74,14 +70,14 @@ public class AuthenticateProcessor extends AbstractAuthProcessor saslMechanisms; @Inject public AuthenticateProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { super(AuthenticateRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); this.saslBridge = new ImapSaslBridge(); - this.saslMechanisms = defaultSaslMechanisms(); + this.saslMechanisms = DEFAULT_SASL_MECHANISMS; } @Override @@ -91,8 +87,7 @@ public List> acceptableClasses() { @Override protected void processRequest(AuthenticateRequest request, ImapSession session, final Responder responder) { - ImapSaslSessionContext context = buildContext(session); - Optional mechanism = saslMechanisms.find(request.getAuthType(), SaslProtocol.IMAP); + Optional mechanism = findMechanism(request.getAuthType()); if (mechanism.isEmpty()) { LOGGER.debug("Unsupported authentication mechanism '{}'", request.getAuthType()); @@ -100,15 +95,15 @@ protected void processRequest(AuthenticateRequest request, ImapSession session, return; } - if (!mechanism.get().isAvailable(context)) { + if (!isAvailable(mechanism.get(), session)) { rejectUnavailable(request, responder, mechanism.get()); return; } try { SaslInitialRequest initialRequest = saslBridge.initialRequest(request.getAuthType(), initialClientResponse(request)); - SaslExchange exchange = mechanism.get().start(initialRequest, context); - handleFirstStep(exchange, exchange.firstStep(), context, session, request, responder); + SaslExchange exchange = mechanism.get().start(initialRequest); + handleFirstStep(exchange, firstStep(exchange), session, request, responder); } catch (IllegalArgumentException e) { LOGGER.info("Invalid syntax in AUTHENTICATE initial client response", e); authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), @@ -118,8 +113,8 @@ protected void processRequest(AuthenticateRequest request, ImapSession session, @Override public List getImplementedCapabilities(ImapSession session) { - ImapSaslSessionContext context = buildContext(session); - List caps = saslMechanisms.availableFor(SaslProtocol.IMAP, context) + List caps = saslMechanisms.stream() + .filter(mechanism -> isAvailable(mechanism, session)) .map(mechanism -> Capability.of("AUTH=" + mechanism.name())) .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); @@ -135,7 +130,7 @@ protected MDCBuilder mdc(AuthenticateRequest request) { .addToContext("authType", request.getAuthType()); } - public void configureSaslMechanisms(SaslMechanismRegistry saslMechanisms) { + public void configureSaslMechanisms(ImmutableList saslMechanisms) { this.saslMechanisms = saslMechanisms; } @@ -146,21 +141,34 @@ private Optional initialClientResponse(AuthenticateRequest request) { return Optional.empty(); } - private ImapSaslSessionContext buildContext(ImapSession session) { - ImapSaslSessionContext context = new ImapSaslSessionContext(session, withAdminUsers()); - saslMechanisms.initialize(SaslProtocol.IMAP, context); - return context; + private SaslStep firstStep(SaslExchange exchange) { + try { + return exchange.firstStep(); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } } - private SaslMechanismRegistry defaultSaslMechanisms() { - return new SaslMechanismRegistry(DEFAULT_SASL_MECHANISMS, - ImmutableList.of( - new ImapPasswordSaslAuthenticationServiceFactory(getMailboxManager()), - new ImapBearerTokenSaslAuthenticationServiceFactory(getMailboxManager()))); + private Optional findMechanism(String mechanismName) { + String normalizedName = mechanismName.toUpperCase(Locale.US); + return saslMechanisms.stream() + .filter(mechanism -> mechanism.name().toUpperCase(Locale.US).equals(normalizedName)) + .findFirst(); + } + + private boolean isAvailable(SaslMechanism mechanism, ImapSession session) { + if (PlainSaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { + return !session.isPlainAuthDisallowed(); + } + if (OauthBearerSaslMechanism.NAME.equalsIgnoreCase(mechanism.name()) || XOauth2SaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { + return session.supportsOAuth(); + } + return true; } private void rejectUnavailable(AuthenticateRequest request, Responder responder, SaslMechanism mechanism) { - if (PlainSaslMechanism.NAME.equals(mechanism.name())) { + if (PlainSaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel"); no(request, responder, HumanReadableText.DISABLED_LOGIN); } else { @@ -169,89 +177,252 @@ private void rejectUnavailable(AuthenticateRequest request, Responder responder, } } - private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSaslSessionContext context, - ImapSession session, AuthenticateRequest request, Responder responder) { + private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { if (step instanceof SaslStep.Challenge challenge) { - session.executeSafely(() -> { - responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))); - responder.flush(); - session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleContinuationLine(exchange, context, requestSession, request, responder, data)) - .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) - .then()); - }); + handleInitialChallenge(exchange, challenge, session, request, responder); + return; + } + if (step instanceof SaslStep.Success success && success.serverData().isPresent()) { + handleSuccessWithServerData(exchange, success, session, request, responder); return; } - handleTerminalStep(exchange, step, context, session, request, responder); + handleTerminalStep(exchange, step, session, request, responder); } - private void handleContinuationLine(SaslExchange exchange, ImapSaslSessionContext context, - ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { - if (saslBridge.isAbort(data)) { - saslBridge.abort(exchange); - session.popLineHandler(); + private void handleInitialChallenge(SaslExchange exchange, SaslStep.Challenge challenge, + ImapSession session, AuthenticateRequest request, Responder responder) { + pushContinuationHandlerAndRespond(exchange, challenge, session, request, responder); + } + + private void pushContinuationHandlerAndRespond(SaslExchange exchange, SaslStep.Challenge challenge, + ImapSession session, AuthenticateRequest request, Responder responder) { + pushContinuationHandler(exchange, session, request, responder); + respondActiveContinuation(exchange, session, () -> + responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge)))); + } + + private void pushContinuationHandler(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder) { + try { + session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleContinuationLine(exchange, requestSession, request, responder, data)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + .then()); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private void handleContinuationLine(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { + if (isAbort(exchange, session, data)) { + abortActiveContinuation(exchange, session); no(request, responder, HumanReadableText.AUTHENTICATION_FAILED); responder.flush(); return; } + nextStep(exchange, session, request, responder, data) + .ifPresent(step -> handleContinuationStep(exchange, step, session, request, responder)); + } + + private Optional nextStep(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { try { - SaslStep step = saslBridge.onClientResponse(exchange, data); - if (step instanceof SaslStep.Challenge challenge) { + return Optional.of(saslBridge.onClientResponse(exchange, data)); + } catch (IllegalArgumentException e) { + LOGGER.info("Invalid syntax in AUTHENTICATE client response", e); + closeActiveContinuation(exchange, session); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); + responder.flush(); + return Optional.empty(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private void handleContinuationStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { + if (step instanceof SaslStep.Challenge challenge) { + try { responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))); responder.flush(); - } else { - session.popLineHandler(); - handleTerminalStep(exchange, step, context, session, request, responder); - responder.flush(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; } - } catch (IllegalArgumentException e) { - LOGGER.info("Invalid syntax in AUTHENTICATE client response", e); + return; + } + + popActiveContinuation(exchange, session); + if (step instanceof SaslStep.Success success && success.serverData().isPresent()) { + handleSuccessWithServerData(exchange, success, session, request, responder); + return; + } + + handleTerminalStep(exchange, step, session, request, responder); + responder.flush(); + } + + private void respondActiveContinuation(SaslExchange exchange, ImapSession session, Runnable runnable) { + try { + runnable.run(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private boolean isAbort(SaslExchange exchange, ImapSession session, byte[] data) { + try { + return saslBridge.isAbort(data); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private boolean isEmptyClientResponse(SaslExchange exchange, ImapSession session, byte[] data) { + try { + return saslBridge.isEmptyClientResponse(data); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private void closeActiveContinuation(SaslExchange exchange, ImapSession session) { + try { + session.popLineHandler(); + } finally { + saslBridge.close(exchange); + } + } + + private void abortActiveContinuation(SaslExchange exchange, ImapSession session) { + try { + session.popLineHandler(); + } finally { + saslBridge.abort(exchange); + } + } + + private void popActiveContinuation(SaslExchange exchange, ImapSession session) { + try { session.popLineHandler(); + } catch (RuntimeException e) { saslBridge.close(exchange); + throw e; + } + } + + private void handleSuccessWithServerData(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder) { + pushSuccessDataAcknowledgementHandler(exchange, success, session, request, responder); + respondActiveContinuation(exchange, session, () -> { + responder.respond(new AuthenticateResponse(saslBridge.successData(success))); + responder.flush(); + }); + } + + private void pushSuccessDataAcknowledgementHandler(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder) { + try { + session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleSuccessDataAcknowledgement(exchange, success, requestSession, request, responder, data)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + .then()); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private void handleSuccessDataAcknowledgement(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder, byte[] data) { + if (isAbort(exchange, session, data)) { + abortActiveContinuation(exchange, session); + no(request, responder, HumanReadableText.AUTHENTICATION_FAILED); + responder.flush(); + return; + } + if (!isEmptyClientResponse(exchange, session, data)) { + closeActiveContinuation(exchange, session); authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), "Malformed authentication command."); responder.flush(); + return; } + + popActiveContinuation(exchange, session); + handleTerminalStep(exchange, success, session, request, responder); + responder.flush(); } - private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSaslSessionContext context, - ImapSession session, AuthenticateRequest request, Responder responder) { - if (step instanceof SaslStep.Success success) { - handleSuccess(context, session, request, responder, success.identity()); - } else if (step instanceof SaslStep.Failure failure) { - handleFailure(context, session, request, responder, failure.reason()); + private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { + try { + if (step instanceof SaslStep.Credentials credentials) { + handleCredentials(credentials.credentials(), session, request, responder); + } else if (step instanceof SaslStep.Success success) { + handleSuccess(session, request, responder, success.identity()); + } else if (step instanceof SaslStep.Failure failure) { + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), failure.reason()); + } + } finally { + saslBridge.close(exchange); } - saslBridge.close(exchange); } - private void handleSuccess(ImapSaslSessionContext context, ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { - context.mailboxSession() - .ifPresentOrElse(mailboxSession -> authSuccess(session, mailboxSession, request, responder, successLog(request, identity)), - () -> handleMissingMailboxSession(session, request, responder, identity)); + private void handleCredentials(SaslCredentials credentials, ImapSession session, AuthenticateRequest request, Responder responder) { + if (credentials instanceof SaslCredentials.Password password) { + handlePasswordCredentials(password, session, request, responder); + return; + } + if (credentials instanceof SaslCredentials.BearerToken bearerToken) { + handleBearerTokenCredentials(bearerToken, session, request, responder); + } } - private void handleMissingMailboxSession(ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { - LOGGER.error("SASL mechanism {} returned Success without creating a mailbox session for authenticationId={} authorizationId={}", - request.getAuthType(), identity.authenticationId(), identity.authorizationId()); - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.of(identity.authenticationId()), - Optional.of(identity.authorizationId()), "Authentication failed."); + private void handlePasswordCredentials(SaslCredentials.Password password, ImapSession session, AuthenticateRequest request, Responder responder) { + AuthenticationAttempt authenticationAttempt = new AuthenticationAttempt(password.authorizationId(), password.authenticationId(), password.password()); + if (authenticationAttempt.isDelegation()) { + doPasswordAuthWithDelegation(authenticationAttempt, session, request, responder); + } else { + doPasswordAuth(authenticationAttempt, session, request, responder); + } } - private String successLog(AuthenticateRequest request, SaslIdentity identity) { - String authType = request.getAuthType().toUpperCase(Locale.US); - if (!identity.authenticationId().equals(identity.authorizationId())) { - return "Authentication with delegation succeeded using " + authType + "."; - } - return authType + " authentication succeeded."; + private void handleBearerTokenCredentials(SaslCredentials.BearerToken bearerToken, ImapSession session, AuthenticateRequest request, Responder responder) { + session.oidcSaslConfiguration() + .ifPresentOrElse(configuration -> new OidcJwtTokenVerifier(configuration).validateToken(bearerToken.token()) + .ifPresentOrElse(authenticatedUser -> { + if (!bearerToken.authorizationId().equals(authenticatedUser)) { + doAuthWithDelegation(() -> getMailboxManager() + .withExtraAuthorizator(withAdminUsers()) + .authenticate(authenticatedUser) + .as(bearerToken.authorizationId()), + session, request, responder, authenticatedUser, bearerToken.authorizationId()); + } else { + authSuccess(session, getMailboxManager().createSystemSession(authenticatedUser), request, responder, + "OAuth authentication succeeded."); + } + }, () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.of(bearerToken.authorizationId()), "OAuth authentication failed.")), + () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.of(bearerToken.authorizationId()), "OAuth authentication failed.")); } - private void handleFailure(ImapSaslSessionContext context, ImapSession session, ImapRequest request, Responder responder, String reason) { - if (context.hasProcessingFailure()) { - no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); + private void handleSuccess(ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { + if (!identity.authenticationId().equals(identity.authorizationId())) { + doAuthWithDelegation(() -> getMailboxManager() + .withExtraAuthorizator(withAdminUsers()) + .authenticate(identity.authenticationId()) + .as(identity.authorizationId()), + session, request, responder, identity.authenticationId(), identity.authorizationId()); return; } - context.failureDetails() - .ifPresentOrElse(failure -> authFailure(session, request, responder, failure.text(), failure.username(), failure.assumedUser(), failure.reason()), - () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), reason)); + authSuccess(session, getMailboxManager().createSystemSession(identity.authenticationId()), request, responder, successLog(request)); + } + + private String successLog(AuthenticateRequest request) { + String authType = request.getAuthType().toUpperCase(Locale.US); + return authType + " authentication succeeded."; } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java deleted file mode 100644 index 27a19e5f98b..00000000000 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationService.java +++ /dev/null @@ -1,101 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import java.util.Optional; - -import org.apache.james.core.Username; -import org.apache.james.imap.api.display.HumanReadableText; -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.mailbox.MailboxSession; -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.BearerTokenSaslAuthenticationService; -import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; -import org.apache.james.protocols.api.sasl.SaslIdentity; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ImapBearerTokenSaslAuthenticationService implements BearerTokenSaslAuthenticationService { - private static final Logger LOGGER = LoggerFactory.getLogger(ImapBearerTokenSaslAuthenticationService.class); - - private final MailboxManager mailboxManager; - private final ImapSaslSessionContext context; - - public ImapBearerTokenSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context) { - this.mailboxManager = mailboxManager; - this.context = context; - } - - @Override - public SaslAuthenticationResult authenticate(String token, Username authorizationId) { - return context.session().oidcSaslConfiguration() - .flatMap(configuration -> new OidcJwtTokenVerifier(configuration).validateToken(token)) - .map(authenticationId -> authenticateOidcUser(authenticationId, authorizationId)) - .orElseGet(() -> { - String reason = "OAuth authentication failed."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.AUTHENTICATION_FAILED, - Optional.empty(), Optional.of(authorizationId), reason)); - return new SaslAuthenticationResult.Failure(reason); - }); - } - - private SaslAuthenticationResult authenticateOidcUser(Username authenticationId, Username authorizationId) { - if (!authorizationId.equals(authenticationId)) { - return authenticateOidcDelegation(authenticationId, authorizationId); - } - - context.authenticationSucceeded(mailboxManager.createSystemSession(authenticationId)); - return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId)); - } - - private SaslAuthenticationResult authenticateOidcDelegation(Username authenticationId, Username authorizationId) { - try { - MailboxSession mailboxSession = mailboxManager - .withExtraAuthorizator(context.delegationAuthorizator()) - .authenticate(authenticationId) - .as(authorizationId); - context.authenticationSucceeded(mailboxSession); - return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId)); - } catch (BadCredentialsException e) { - String reason = "Password authentication with delegation failed because of bad credentials."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, - Optional.of(authenticationId), Optional.of(authorizationId), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (UserDoesNotExistException e) { - String reason = "Delegation target user does not exist."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.USER_DOES_NOT_EXIST, - Optional.of(authenticationId), Optional.of(authorizationId), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (ForbiddenDelegationException e) { - String reason = "Requested delegation is forbidden."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.DELEGATION_FORBIDDEN, - Optional.of(authenticationId), Optional.of(authorizationId), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (MailboxException e) { - LOGGER.info("Authentication failed", e); - context.processingFailed(); - return new SaslAuthenticationResult.Failure("Authentication failed."); - } - } -} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java deleted file mode 100644 index bf45c6586e9..00000000000 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapBearerTokenSaslAuthenticationServiceFactory.java +++ /dev/null @@ -1,57 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import java.util.Optional; - -import jakarta.inject.Inject; - -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.protocols.api.sasl.BearerTokenSaslAuthenticationService; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; - -public class ImapBearerTokenSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { - private final MailboxManager mailboxManager; - - @Inject - public ImapBearerTokenSaslAuthenticationServiceFactory(MailboxManager mailboxManager) { - this.mailboxManager = mailboxManager; - } - - @Override - public SaslProtocol protocol() { - return SaslProtocol.IMAP; - } - - @Override - public Class serviceType() { - return BearerTokenSaslAuthenticationService.class; - } - - @Override - public Optional create(SaslSessionContext context) { - if (context instanceof ImapSaslSessionContext imapContext && imapContext.supportsOAuth()) { - return Optional.of(new ImapBearerTokenSaslAuthenticationService(mailboxManager, imapContext)); - } - return Optional.empty(); - } -} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java deleted file mode 100644 index 29be46248ab..00000000000 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationService.java +++ /dev/null @@ -1,106 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import java.util.Optional; - -import org.apache.james.core.Username; -import org.apache.james.imap.api.display.HumanReadableText; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.mailbox.MailboxSession; -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.PasswordSaslAuthenticationService; -import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; -import org.apache.james.protocols.api.sasl.SaslIdentity; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ImapPasswordSaslAuthenticationService implements PasswordSaslAuthenticationService { - private static final Logger LOGGER = LoggerFactory.getLogger(ImapPasswordSaslAuthenticationService.class); - - private final MailboxManager mailboxManager; - private final ImapSaslSessionContext context; - - public ImapPasswordSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context) { - this.mailboxManager = mailboxManager; - this.context = context; - } - - @Override - public SaslAuthenticationResult authenticate(Username authenticationId, Optional authorizationId, String password) { - Username authorizedUser = authorizationId.orElse(authenticationId); - if (authorizedUser.equals(authenticationId)) { - return authenticateWithoutDelegation(authenticationId, password); - } - return authenticateWithDelegation(authenticationId, password, authorizedUser); - } - - private SaslAuthenticationResult authenticateWithoutDelegation(Username authenticationId, String password) { - try { - MailboxSession mailboxSession = mailboxManager - .authenticate(authenticationId, password) - .withoutDelegation(); - context.authenticationSucceeded(mailboxSession); - return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authenticationId)); - } catch (BadCredentialsException e) { - String reason = "Password authentication failed because of bad credentials."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, - Optional.of(authenticationId), Optional.empty(), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (MailboxException e) { - LOGGER.error("Authentication failed", e); - context.processingFailed(); - return new SaslAuthenticationResult.Failure("Authentication failed."); - } - } - - private SaslAuthenticationResult authenticateWithDelegation(Username authenticationId, String password, Username authorizedUser) { - try { - MailboxSession mailboxSession = mailboxManager - .withExtraAuthorizator(context.delegationAuthorizator()) - .authenticate(authenticationId, password) - .as(authorizedUser); - context.authenticationSucceeded(mailboxSession); - return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizedUser)); - } catch (BadCredentialsException e) { - String reason = "Password authentication with delegation failed because of bad credentials."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.INVALID_CREDENTIALS, - Optional.of(authenticationId), Optional.of(authorizedUser), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (UserDoesNotExistException e) { - String reason = "Delegation target user does not exist."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.USER_DOES_NOT_EXIST, - Optional.of(authenticationId), Optional.of(authorizedUser), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (ForbiddenDelegationException e) { - String reason = "Requested delegation is forbidden."; - context.recordFailureDetails(new ImapSaslSessionContext.FailureDetails(HumanReadableText.DELEGATION_FORBIDDEN, - Optional.of(authenticationId), Optional.of(authorizedUser), reason)); - return new SaslAuthenticationResult.Failure(reason); - } catch (MailboxException e) { - LOGGER.info("Authentication failed", e); - context.processingFailed(); - return new SaslAuthenticationResult.Failure("Authentication failed."); - } - } -} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java deleted file mode 100644 index d0129e1eb46..00000000000 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapPasswordSaslAuthenticationServiceFactory.java +++ /dev/null @@ -1,57 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import java.util.Optional; - -import jakarta.inject.Inject; - -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.protocols.api.sasl.PasswordSaslAuthenticationService; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; - -public class ImapPasswordSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { - private final MailboxManager mailboxManager; - - @Inject - public ImapPasswordSaslAuthenticationServiceFactory(MailboxManager mailboxManager) { - this.mailboxManager = mailboxManager; - } - - @Override - public SaslProtocol protocol() { - return SaslProtocol.IMAP; - } - - @Override - public Class serviceType() { - return PasswordSaslAuthenticationService.class; - } - - @Override - public Optional create(SaslSessionContext context) { - if (context instanceof ImapSaslSessionContext imapContext && !imapContext.isPlainAuthDisallowed()) { - return Optional.of(new ImapPasswordSaslAuthenticationService(mailboxManager, imapContext)); - } - return Optional.empty(); - } -} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java index 4e6a5a31eea..ec6962917c3 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java @@ -26,7 +26,6 @@ import org.apache.commons.lang3.StringUtils; import org.apache.james.protocols.api.sasl.SaslExchange; import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslProtocol; import org.apache.james.protocols.api.sasl.SaslStep; public class ImapSaslBridge { @@ -34,8 +33,7 @@ public class ImapSaslBridge { * Converts an IMAP AUTHENTICATE request into a protocol-neutral SASL initial request. */ public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { - return new SaslInitialRequest(SaslProtocol.IMAP, mechanismName, - initialClientResponse.map(this::decodeInitialClientResponse)); + return new SaslInitialRequest(mechanismName, initialClientResponse.map(this::decodeInitialClientResponse)); } /** @@ -47,6 +45,15 @@ public String continuation(SaslStep.Challenge challenge) { .orElse(""); } + /** + * Encodes final SASL server data as an IMAP continuation payload. + */ + public String successData(SaslStep.Success success) { + return success.serverData() + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + } + /** * Decodes an IMAP client continuation line and forwards it to the SASL exchange. */ @@ -58,12 +65,22 @@ public boolean isAbort(byte[] line) { return "*".equals(stripTrailingCrlf(line)); } + /** + * Detects the empty IMAP client response used to acknowledge final SASL server data. + */ + public boolean isEmptyClientResponse(byte[] line) { + return stripTrailingCrlf(line).isEmpty(); + } + /** * Aborts and closes an active SASL exchange. */ public void abort(SaslExchange exchange) { - exchange.abort(); - exchange.close(); + try { + exchange.abort(); + } finally { + exchange.close(); + } } /** diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java deleted file mode 100644 index 2dc1dfeec72..00000000000 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContext.java +++ /dev/null @@ -1,106 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import org.apache.james.core.Username; -import org.apache.james.imap.api.display.HumanReadableText; -import org.apache.james.imap.api.process.ImapSession; -import org.apache.james.mailbox.Authorizator; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.protocols.api.sasl.SaslSessionContext; - -public class ImapSaslSessionContext implements SaslSessionContext { - public record FailureDetails(HumanReadableText text, Optional username, Optional assumedUser, String reason) { - } - - private final ImapSession session; - private final Authorizator delegationAuthorizator; - private final Map, Object> services; - private Optional mailboxSession; - private Optional failureDetails; - private boolean processingFailure; - - public ImapSaslSessionContext(ImapSession session) { - this(session, (userId, otherUserId) -> Authorizator.AuthorizationState.FORBIDDEN); - } - - public ImapSaslSessionContext(ImapSession session, Authorizator delegationAuthorizator) { - this.session = session; - this.delegationAuthorizator = delegationAuthorizator; - this.services = new HashMap<>(); - this.mailboxSession = Optional.empty(); - this.failureDetails = Optional.empty(); - } - - @Override - public Optional service(Class serviceType) { - return Optional.ofNullable(services.get(serviceType)) - .map(serviceType::cast); - } - - @Override - public void register(Class serviceType, T service) { - services.put(serviceType, service); - } - - public boolean isPlainAuthDisallowed() { - return session.isPlainAuthDisallowed(); - } - - public boolean supportsOAuth() { - return session.supportsOAuth(); - } - - public Authorizator delegationAuthorizator() { - return delegationAuthorizator; - } - - public ImapSession session() { - return session; - } - - public void authenticationSucceeded(MailboxSession mailboxSession) { - this.mailboxSession = Optional.of(mailboxSession); - } - - public void recordFailureDetails(FailureDetails failureDetails) { - this.failureDetails = Optional.of(failureDetails); - } - - public void processingFailed() { - this.processingFailure = true; - } - - public Optional mailboxSession() { - return mailboxSession; - } - - public Optional failureDetails() { - return failureDetails; - } - - public boolean hasProcessingFailure() { - return processingFailure; - } -} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java new file mode 100644 index 00000000000..1bd1634d7a3 --- /dev/null +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java @@ -0,0 +1,272 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor; + +import static org.apache.james.imap.ImapFixture.TAG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.imap.api.message.response.ImapResponseMessage; +import org.apache.james.imap.api.process.ImapLineHandler; +import org.apache.james.imap.api.process.ImapProcessor; +import org.apache.james.imap.encode.FakeImapSession; +import org.apache.james.imap.main.PathConverter; +import org.apache.james.imap.message.request.AuthenticateRequest; +import org.apache.james.imap.message.response.UnpooledStatusResponseFactory; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Mono; + +class AuthenticateProcessorTest { + private static final String BROKEN_MECHANISM = "BROKEN"; + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private static class TestSaslMechanism implements SaslMechanism { + private final SaslExchange exchange; + + private TestSaslMechanism(SaslExchange exchange) { + this.exchange = exchange; + } + + @Override + public String name() { + return BROKEN_MECHANISM; + } + + @Override + public SaslExchange start(SaslInitialRequest request) { + return exchange; + } + } + + private static class RecordingLineHandlerImapSession extends FakeImapSession { + private ImapLineHandler lineHandler; + private boolean throwOnPop; + private int popCount; + + @Override + public void pushLineHandler(ImapLineHandler lineHandler) { + this.lineHandler = lineHandler; + } + + @Override + public void popLineHandler() { + popCount++; + if (throwOnPop) { + throw new IllegalStateException("boom"); + } + lineHandler = null; + } + } + + private static class ThrowingResponder implements ImapProcessor.Responder { + @Override + public void respond(ImapResponseMessage message) { + throw new IllegalStateException("boom"); + } + + @Override + public void flush() { + } + } + + private static class ThrowingFirstStepExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + throw new IllegalArgumentException("boom"); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new UnsupportedOperationException(); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private static class ThrowingContinuationExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new IllegalStateException("boom"); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private static class SuccessDataExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + return new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new UnsupportedOperationException(); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private final AuthenticateProcessor testee = new AuthenticateProcessor( + mock(MailboxManager.class), + new UnpooledStatusResponseFactory(), + new RecordingMetricFactory(), + PathConverter.Factory.DEFAULT); + + @Test + void processRequestShouldCloseExchangeWhenFirstStepThrows() { + // GIVEN a mechanism whose exchange fails while computing the first SASL step + ThrowingFirstStepExchange exchange = new ThrowingFirstStepExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the malformed exchange + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), mock(ImapProcessor.Responder.class)); + + // THEN it closes the exchange even though the terminal step handling was not reached + assertThat(exchange.closed).isTrue(); + } + + @Test + void processRequestShouldCloseExchangeWhenInitialChallengeWriteThrows() { + // GIVEN a first challenge that fails while being written to the client + ThrowingContinuationExchange exchange = new ThrowingContinuationExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the initial challenge + assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder())) + .isInstanceOf(IllegalStateException.class); + + // THEN it closes the exchange even though no continuation handler was installed + assertThat(exchange.closed).isTrue(); + } + + @Test + void processRequestShouldCloseExchangeWhenInitialSuccessDataWriteThrows() { + // GIVEN a first successful SASL step with final server data that fails while being written to the client + SuccessDataExchange exchange = new SuccessDataExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the initial success data + assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder())) + .isInstanceOf(IllegalStateException.class); + + // THEN it closes the exchange because terminal handling did not own it yet + assertThat(exchange.closed).isTrue(); + } + + @Test + void continuationShouldCloseExchangeWhenOnResponseThrows() { + // GIVEN an active continuation whose mechanism fails unexpectedly while processing the client response + ThrowingContinuationExchange exchange = new ThrowingContinuationExchange(); + RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class)); + ImapLineHandler lineHandler = session.lineHandler; + assertThat(lineHandler).isNotNull(); + + // WHEN the continuation line is processed + assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, imapLine("response"))).block()) + .isInstanceOf(IllegalStateException.class); + + // THEN the active line handler is removed and the exchange is closed before rethrowing + assertThat(session.popCount).isEqualTo(1); + assertThat(exchange.closed).isTrue(); + } + + @Test + void successDataAcknowledgementShouldCloseExchangeWhenPopLineHandlerThrows() { + // GIVEN an active final server-data acknowledgement handler + SuccessDataExchange exchange = new SuccessDataExchange(); + RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class)); + ImapLineHandler lineHandler = session.lineHandler; + assertThat(lineHandler).isNotNull(); + session.throwOnPop = true; + + // WHEN the acknowledgement line reaches a failing line-handler cleanup + assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, emptyLine())).block()) + .isInstanceOf(IllegalStateException.class); + + // THEN the exchange is still closed before the failure is rethrown + assertThat(session.popCount).isEqualTo(1); + assertThat(exchange.closed).isTrue(); + } + + private static byte[] imapLine(String value) { + return (Base64.getEncoder().encodeToString(bytes(value)) + "\r\n").getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] emptyLine() { + return "\r\n".getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java index 6a14b449eee..d80469be8a2 100644 --- a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java @@ -20,6 +20,7 @@ package org.apache.james.imap.processor.sasl; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -31,7 +32,6 @@ import org.apache.james.protocols.api.sasl.SaslExchange; import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslProtocol; import org.apache.james.protocols.api.sasl.SaslStep; import org.junit.jupiter.api.Test; @@ -71,13 +71,41 @@ public void close() { } } + private static class ThrowingAbortExchange implements SaslExchange { + private final List lifecycleEvents; + + private ThrowingAbortExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public void abort() { + lifecycleEvents.add("abort"); + throw new IllegalStateException("boom"); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + @Test void initialRequestShouldDecodeInitialClientResponse() { String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); - assertThat(request.protocol()).isEqualTo(SaslProtocol.IMAP); assertThat(request.mechanismName()).isEqualTo("PLAIN"); assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); } @@ -107,6 +135,25 @@ void continuationShouldReturnEmptyStringWhenChallengeHasNoPayload() { assertThat(continuation).isEmpty(); } + @Test + void successDataShouldBase64EncodePayload() { + SaslStep.Success success = new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))); + + String successData = testee.successData(success); + + assertThat(successData).isEqualTo(Base64.getEncoder().encodeToString(bytes("server-data"))); + } + + @Test + void isEmptyClientResponseShouldDetectEmptyLine() { + assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isEmptyClientResponseShouldRejectNonEmptyLine() { + assertThat(testee.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse(); + } + @Test void onClientResponseShouldDecodeLineAndContinueExchange() { RecordingExchange exchange = new RecordingExchange(); @@ -155,6 +202,16 @@ void abortShouldAbortThenCloseExchange() { assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); } + @Test + void abortShouldCloseExchangeWhenAbortThrows() { + ThrowingAbortExchange exchange = new ThrowingAbortExchange(); + + assertThatThrownBy(() -> testee.abort(exchange)) + .isInstanceOf(IllegalStateException.class); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + @Test void closeShouldCloseExchange() { RecordingExchange exchange = new RecordingExchange(); diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java deleted file mode 100644 index 53c849c6f04..00000000000 --- a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslSessionContextTest.java +++ /dev/null @@ -1,51 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.imap.processor.sasl; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import org.apache.james.imap.api.process.ImapSession; -import org.junit.jupiter.api.Test; - -class ImapSaslSessionContextTest { - private interface ExtensionSaslService { - } - - private static class FakeExtensionSaslService implements ExtensionSaslService { - } - - @Test - void serviceShouldExposeRegisteredProtocolService() { - ImapSaslSessionContext testee = new ImapSaslSessionContext(mock(ImapSession.class)); - ExtensionSaslService service = new FakeExtensionSaslService(); - - testee.register(ExtensionSaslService.class, service); - - assertThat(testee.service(ExtensionSaslService.class)).contains(service); - } - - @Test - void serviceShouldReturnEmptyWhenProtocolServiceIsNotRegistered() { - ImapSaslSessionContext testee = new ImapSaslSessionContext(mock(ImapSession.class)); - - assertThat(testee.service(ExtensionSaslService.class)).isEmpty(); - } -} From cd163dc087cf8467d3937ba010b3efa79d5d49ac Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:14:44 +0700 Subject: [PATCH 17/29] JAMES-4210 Wire per-server IMAP SASL mechanism configuration Resolve IMAP SASL mechanisms per IMAP server configuration, preserve defaults when auth.saslMechanisms is absent, and keep capability/enable processors tied to the same IMAP suite instance. --- .../james/imap/processor/EnableProcessor.java | 12 +- .../modules/protocols/IMAPServerModule.java | 130 +++++++--------- ...lAuthenticationServiceFactoryProvider.java | 31 ---- .../protocols/IMAPServerModuleTest.java | 141 ++++-------------- 4 files changed, 90 insertions(+), 224 deletions(-) delete mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java index 551bac70cf2..b3318e9b891 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java @@ -52,15 +52,15 @@ public class EnableProcessor extends AbstractMailboxProcessor implements CapabilityImplementingProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(EnableProcessor.class); - private static final List capabilities = new ArrayList<>(); public static final String ENABLED_CAPABILITIES = "ENABLED_CAPABILITIES"; private static final List CAPS = ImmutableList.of(SUPPORTS_ENABLE); - private final CapabilityProcessor capabilityProcessor; + private final List capabilities = new ArrayList<>(); + private CapabilityProcessor capabilityProcessor; public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, List capabilities, MetricFactory metricFactory, CapabilityProcessor capabilityProcessor) { this(mailboxManager, factory, metricFactory, capabilityProcessor); - EnableProcessor.capabilities.addAll(capabilities); + this.capabilities.addAll(capabilities); } @Inject @@ -70,6 +70,12 @@ public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory fact this.capabilityProcessor = capabilityProcessor; } + /** + * Use the capability processor from the same IMAP suite. + */ + public void configureCapabilityProcessor(CapabilityProcessor capabilityProcessor) { + this.capabilityProcessor = capabilityProcessor; + } @Override protected Mono processRequestReactive(EnableRequest request, ImapSession session, Responder responder) { diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 929db10d6a0..45e9baeb0fe 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -19,8 +19,8 @@ package org.apache.james.modules.protocols; import java.util.Arrays; +import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -69,24 +69,21 @@ import org.apache.james.imap.processor.base.AbstractProcessor; import org.apache.james.imap.processor.base.UnknownRequestProcessor; import org.apache.james.imap.processor.fetch.FetchProcessor; -import org.apache.james.imap.processor.sasl.ImapBearerTokenSaslAuthenticationServiceFactory; -import org.apache.james.imap.processor.sasl.ImapPasswordSaslAuthenticationServiceFactory; import org.apache.james.imapserver.netty.IMAPHealthCheck; import org.apache.james.imapserver.netty.IMAPServerFactory; import org.apache.james.lifecycle.api.ConfigurationSanitizer; -import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.GaugeRegistry; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; +import org.apache.james.modules.SaslMechanismFactories; import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismLoader; -import org.apache.james.protocols.api.sasl.SaslMechanismRegistry; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; import org.apache.james.protocols.lib.netty.CertificateReloadable; import org.apache.james.protocols.netty.Encryption; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.utils.ClassName; import org.apache.james.utils.GuiceLoader; import org.apache.james.utils.GuiceProbe; +import org.apache.james.utils.GuiceSaslMechanismResolver; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.KeystoreCreator; @@ -96,14 +93,19 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.inject.AbstractModule; +import com.google.inject.Key; +import com.google.inject.Provider; import com.google.inject.Provides; import com.google.inject.Scopes; import com.google.inject.Singleton; +import com.google.inject.TypeLiteral; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; public class IMAPServerModule extends AbstractModule { - private static final String SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS = "auth.saslAuthenticationServiceFactoryProviderExtensions"; + private static final Key, Provider>> SASL_MECHANISM_FACTORY_PROVIDERS = + Key.get(new TypeLiteral<>() { + }, SaslMechanismFactories.class); private static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() @@ -116,11 +118,13 @@ protected void configure() { bind(UnpooledStatusResponseFactory.class).in(Scopes.SINGLETON); bind(StatusResponseFactory.class).to(UnpooledStatusResponseFactory.class); - bind(CapabilityProcessor.class).in(Scopes.SINGLETON); - bind(AuthenticateProcessor.class).in(Scopes.SINGLETON); + // Keep CapabilityProcessor, AuthenticateProcessor and EnableProcessor unscoped: IMAP suite loading configures + // their SASL mechanisms and capability links from each server configuration. + bind(CapabilityProcessor.class); + bind(AuthenticateProcessor.class); + bind(EnableProcessor.class); bind(SelectProcessor.class).in(Scopes.SINGLETON); bind(StatusProcessor.class).in(Scopes.SINGLETON); - bind(EnableProcessor.class).in(Scopes.SINGLETON); bind(DefaultImapSaslMechanismClassNamesProvider.class).to(JamesDefaultImapSaslMechanismClassNamesProvider.class); bind(NamespaceSupplier.class).to(NamespaceSupplier.Default.class).in(Scopes.SINGLETON); bind(PathConverter.Factory.class).to(PathConverter.Factory.Default.class).in(Scopes.SINGLETON); @@ -141,22 +145,39 @@ protected void configure() { @Singleton IMAPServerFactory provideServerFactory(FileSystem fileSystem, GuiceLoader guiceLoader, - SaslMechanismLoader saslMechanismLoader, - Set saslAuthenticationServiceFactoryProviders, + GuiceSaslMechanismResolver saslMechanismResolver, DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, StatusResponseFactory statusResponseFactory, MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory, Encryption.Factory encryptionFactory) { - IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismLoader, - saslAuthenticationServiceFactoryProviders, defaultImapSaslMechanismClassNamesProvider, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); + Map, SaslMechanismFactory> saslMechanismFactories = retrieveSaslMechanismFactories(guiceLoader); + IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismResolver, + saslMechanismFactories, defaultImapSaslMechanismClassNamesProvider, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); factory.setEncryptionFactory(encryptionFactory); return factory; } + private Map, SaslMechanismFactory> retrieveSaslMechanismFactories(GuiceLoader guiceLoader) { + return retrieveSaslMechanismFactoryProviders(guiceLoader) + .entrySet() + .stream() + .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, entry -> entry.getValue().get())); + } + + private Map, Provider> retrieveSaslMechanismFactoryProviders(GuiceLoader guiceLoader) { + try { + // Optional extension point: custom modules can contribute SASL mechanism factories through this annotated map. + return guiceLoader.getInstance(SASL_MECHANISM_FACTORY_PROVIDERS); + } catch (com.google.inject.ConfigurationException e) { + // No custom factory map was bound; resolver will fall back to direct SASL mechanism instantiation. + return ImmutableMap.of(); + } + } + DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, - SaslMechanismRegistry saslMechanisms, StatusResponseFactory statusResponseFactory) { + ImmutableList saslMechanisms, StatusResponseFactory statusResponseFactory) { ImmutableMap processors = imapPackage.processors() .stream() .map(Throwing.function(guiceLoader::instantiate)) @@ -185,7 +206,7 @@ DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader return new DefaultProcessor(processors, new UnknownRequestProcessor(statusResponseFactory)); } - private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, SaslMechanismRegistry saslMechanisms) { + private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, ImmutableList saslMechanisms) { if (processor instanceof AuthenticateProcessor authenticateProcessor) { authenticateProcessor.configureSaslMechanisms(saslMechanisms); } @@ -209,58 +230,12 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig return ImapPackage.and(packages); } - private SaslMechanismRegistry retrieveSaslMechanisms(SaslMechanismLoader saslMechanismLoader, - GuiceLoader guiceLoader, - Set saslAuthenticationServiceFactoryProviders, - DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, - HierarchicalConfiguration configuration) throws ConfigurationException { + private ImmutableList retrieveSaslMechanisms(GuiceSaslMechanismResolver saslMechanismResolver, + Map, SaslMechanismFactory> saslMechanismFactories, + DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, + HierarchicalConfiguration configuration) throws ConfigurationException { ImmutableList mechanismClassNames = retrieveSaslMechanismClassNames(configuration, defaultImapSaslMechanismClassNamesProvider); - ImmutableList mechanisms = saslMechanismLoader.load(mechanismClassNames); - ImmutableList> saslAuthenticationServiceFactories = - retrieveSaslAuthenticationServiceFactories(configuration, guiceLoader, saslAuthenticationServiceFactoryProviders); - return new SaslMechanismRegistry(mechanisms, saslAuthenticationServiceFactories); - } - - ImmutableList> retrieveSaslAuthenticationServiceFactories(HierarchicalConfiguration configuration, - GuiceLoader guiceLoader, - Set providers) throws ConfigurationException { - ImmutableList.Builder> factories = ImmutableList.builder(); - ImmutableList allProviders = ImmutableList.builder() - .addAll(providers) - .addAll(retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, guiceLoader)) - .build(); - - for (ImapSaslAuthenticationServiceFactoryProvider provider : allProviders) { - factories.addAll(provider.provide(configuration)); - } - return factories.build(); - } - - ImmutableList retrieveConfiguredSaslAuthenticationServiceFactoryProviders(HierarchicalConfiguration configuration, - GuiceLoader guiceLoader) throws ConfigurationException { - if (!configuration.containsKey(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS)) { - return ImmutableList.of(); - } - - ImmutableList providerClassNames = Arrays.stream(configuration.getStringArray(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS)) - .flatMap(value -> Arrays.stream(value.split(","))) - .map(String::trim) - .collect(ImmutableList.toImmutableList()); - - if (providerClassNames.isEmpty() || providerClassNames.stream().anyMatch(StringUtils::isBlank)) { - throw new ConfigurationException(SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS + " must not be blank when configured"); - } - - ImmutableList.Builder providers = ImmutableList.builder(); - for (String providerClassName : providerClassNames) { - try { - ImapSaslAuthenticationServiceFactoryProvider provider = guiceLoader.instantiate(new ClassName(providerClassName)); - providers.add(provider); - } catch (ClassNotFoundException e) { - throw new ConfigurationException("Failed to load " + SASL_AUTHENTICATION_SERVICE_FACTORY_PROVIDER_EXTENSIONS + " class " + providerClassName, e); - } - } - return providers.build(); + return saslMechanismResolver.resolve(mechanismClassNames, configuration, saslMechanismFactories); } ImmutableList retrieveSaslMechanismClassNames(HierarchicalConfiguration configuration, @@ -281,13 +256,13 @@ ImmutableList retrieveSaslMechanismClassNames(HierarchicalConfiguration< } private ThrowingFunction, ImapSuite> imapSuiteLoader(GuiceLoader guiceLoader, - SaslMechanismLoader saslMechanismLoader, - Set saslAuthenticationServiceFactoryProviders, + GuiceSaslMechanismResolver saslMechanismResolver, + Map, SaslMechanismFactory> saslMechanismFactories, DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, StatusResponseFactory statusResponseFactory) { return configuration -> { ImapPackage imapPackage = retrievePackages(guiceLoader, configuration); - SaslMechanismRegistry saslMechanisms = retrieveSaslMechanisms(saslMechanismLoader, guiceLoader, saslAuthenticationServiceFactoryProviders, + ImmutableList saslMechanisms = retrieveSaslMechanisms(saslMechanismResolver, saslMechanismFactories, defaultImapSaslMechanismClassNamesProvider, configuration); DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory); ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader); @@ -315,13 +290,6 @@ ImapEncoder provideImapEncoder(ImapPackage imapPackage, GuiceLoader guiceLoader) return new DefaultImapEncoderFactory.DefaultImapEncoder(encoders, new EndImapEncoder()); } - @ProvidesIntoSet - ImapSaslAuthenticationServiceFactoryProvider provideDefaultImapSaslAuthenticationServiceFactoryProvider(MailboxManager mailboxManager) { - return configuration -> ImmutableList.of( - new ImapPasswordSaslAuthenticationServiceFactory(mailboxManager), - new ImapBearerTokenSaslAuthenticationServiceFactory(mailboxManager)); - } - @ProvidesIntoSet InitializationOperation configureImap(ConfigurationProvider configurationProvider, IMAPServerFactory imapServerFactory) { return InitilizationOperationBuilder @@ -338,6 +306,12 @@ FetchProcessor.LocalCacheConfiguration provideFetchLocalCacheConfiguration(Confi } private void configureEnable(EnableProcessor enableProcessor, ImmutableMap processorMap) { + processorMap.values().stream() + .filter(CapabilityProcessor.class::isInstance) + .map(CapabilityProcessor.class::cast) + .findFirst() + .ifPresent(enableProcessor::configureCapabilityProcessor); + processorMap.values().stream() .filter(PermitEnableCapabilityProcessor.class::isInstance) .map(PermitEnableCapabilityProcessor.class::cast) diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java deleted file mode 100644 index 57e3a9f1faa..00000000000 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapSaslAuthenticationServiceFactoryProvider.java +++ /dev/null @@ -1,31 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.modules.protocols; - -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.SaslAuthenticationServiceFactory; - -import com.google.common.collect.ImmutableList; - -public interface ImapSaslAuthenticationServiceFactoryProvider { - ImmutableList> provide(HierarchicalConfiguration configuration) throws ConfigurationException; -} diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java index 5949875f584..1b18bf76695 100644 --- a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java +++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java @@ -22,165 +22,82 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.Optional; - import org.apache.commons.configuration2.BaseHierarchicalConfiguration; -import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; -import org.apache.james.utils.ClassName; -import org.apache.james.utils.GuiceLoader; -import org.apache.james.utils.NamingScheme; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.inject.Module; class IMAPServerModuleTest { private static final JamesDefaultImapSaslMechanismClassNamesProvider JAMES_DEFAULT_PROVIDER = new JamesDefaultImapSaslMechanismClassNamesProvider(); - private static final GuiceLoader GUICE_LOADER = new GuiceLoader() { - @Override - public T instantiate(ClassName className) throws ClassNotFoundException { - if (className.getName().equals(CustomAuthenticationServiceFactoryProvider.class.getName())) { - return (T) new CustomAuthenticationServiceFactoryProvider(); - } - throw new ClassNotFoundException(className.getName()); - } - - @Override - public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { - throw new UnsupportedOperationException(); - } - - @Override - public InvocationPerformer withChildModule(Module childModule) { - throw new UnsupportedOperationException(); - } - }; - - private record CustomAuthenticationServiceFactoryProvider() implements ImapSaslAuthenticationServiceFactoryProvider { - @Override - public ImmutableList> provide(HierarchicalConfiguration configuration) { - return ImmutableList.of(new CustomAuthenticationServiceFactory(configuration.getString("auth.custom.realm"))); - } - } - - private record CustomAuthenticationServiceFactory(String realm) implements SaslAuthenticationServiceFactory { - @Override - public SaslProtocol protocol() { - return SaslProtocol.IMAP; - } - - @Override - public Class serviceType() { - return CustomAuthenticationService.class; - } - - @Override - public Optional create(SaslSessionContext context) { - return Optional.of(new CustomAuthenticationService(realm)); - } - } - - private record CustomAuthenticationService(String realm) { - } private final IMAPServerModule testee = new IMAPServerModule(); @Test void retrieveSaslMechanismClassNamesShouldReturnDefaultsWhenAbsent() throws Exception { + // GIVEN no auth.saslMechanisms configuration BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - assertThat(testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + // WHEN IMAP resolves its SASL mechanism class names + ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER); + + // THEN existing James IMAP defaults are preserved + assertThat(mechanismClassNames) .containsExactly("PlainSaslMechanism", "OauthBearerSaslMechanism", "XOauth2SaslMechanism"); } @Test - void retrieveSaslMechanismClassNamesShouldUseDefaultProviderWhenAbsent() throws Exception { + void retrieveSaslMechanismClassNamesShouldUseConfiguredDefaultProviderOverJamesDefaultProviderWhenAbsent() throws Exception { + // GIVEN a non-James default provider configured by Guice. + // This allows community custom IMAP packages with custom authentication to provide + // their own default SASL list and avoid breaking changes when auth.saslMechanisms is absent. BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - DefaultImapSaslMechanismClassNamesProvider defaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); + DefaultImapSaslMechanismClassNamesProvider communityDefaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); - assertThat(testee.retrieveSaslMechanismClassNames(configuration, defaultProvider)) + // WHEN auth.saslMechanisms is absent + ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, communityDefaultProvider); + + // THEN IMAP uses the configured community default provider instead of James default mechanisms + assertThat(mechanismClassNames) .containsExactly("com.example.CustomSaslMechanism"); } @Test - void retrieveSaslMechanismClassNamesShouldReturnConfiguredList() throws Exception { + void retrieveSaslMechanismClassNamesShouldReturnConfiguredSaslMechanismList() throws Exception { + // GIVEN an explicit server-specific SASL mechanism list BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism,com.example.CustomSaslMechanism,PlainSaslMechanism"); - assertThat(testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) - .containsExactly("PlainSaslMechanism", "com.example.CustomSaslMechanism", "PlainSaslMechanism"); - } - - @Test - void retrieveSaslMechanismClassNamesShouldIgnoreDefaultProviderWhenConfigured() throws Exception { - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism"); - DefaultImapSaslMechanismClassNamesProvider defaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); + // WHEN IMAP resolves configured class names + ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER); - assertThat(testee.retrieveSaslMechanismClassNames(configuration, defaultProvider)) - .containsExactly("PlainSaslMechanism"); + // THEN the exact configured order is passed to the resolver + assertThat(mechanismClassNames) + .containsExactly("PlainSaslMechanism", "com.example.CustomSaslMechanism", "PlainSaslMechanism"); } @Test void retrieveSaslMechanismClassNamesShouldRejectBlankConfiguredList() { + // GIVEN auth.saslMechanisms is present but blank BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.saslMechanisms", " "); + // WHEN resolving class names + // THEN startup fails instead of silently disabling all mechanisms assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) .isInstanceOf(ConfigurationException.class); } @Test void retrieveSaslMechanismClassNamesShouldRejectBlankEntry() { + // GIVEN auth.saslMechanisms contains a blank entry BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism,,XOauth2SaslMechanism"); + // WHEN resolving class names + // THEN startup fails with an invalid configured list assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) .isInstanceOf(ConfigurationException.class); } - - @Test - void extensionSaslMechanismShouldLoadItsOwnAuthConfigurationFromBoundProvider() throws Exception { - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.custom.realm", "james.example"); - ImapSaslAuthenticationServiceFactoryProvider provider = new CustomAuthenticationServiceFactoryProvider(); - - assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, GUICE_LOADER, ImmutableSet.of(provider))) - .containsExactly(new CustomAuthenticationServiceFactory("james.example")); - } - - @Test - void extensionSaslMechanismShouldLoadItsOwnAuthConfigurationFromConfiguredProvider() throws Exception { - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.custom.realm", "james.example"); - configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", CustomAuthenticationServiceFactoryProvider.class.getName()); - - assertThat(testee.retrieveSaslAuthenticationServiceFactories(configuration, GUICE_LOADER, ImmutableSet.of())) - .containsExactly(new CustomAuthenticationServiceFactory("james.example")); - } - - @Test - void retrieveConfiguredSaslAuthenticationServiceFactoryProvidersShouldRejectBlankConfiguredList() { - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", " "); - - assertThatThrownBy(() -> testee.retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, GUICE_LOADER)) - .isInstanceOf(ConfigurationException.class); - } - - @Test - void retrieveConfiguredSaslAuthenticationServiceFactoryProvidersShouldFailWhenClassIsUnknown() { - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.saslAuthenticationServiceFactoryProviderExtensions", "com.example.MissingProvider"); - - assertThatThrownBy(() -> testee.retrieveConfiguredSaslAuthenticationServiceFactoryProviders(configuration, GUICE_LOADER)) - .isInstanceOf(ConfigurationException.class); - } } From c889aa9b17cd89685b23bde7f44ef3e51ed59d9e Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:17:25 +0700 Subject: [PATCH 18/29] JAMES-4210 Drop obsolete SMTP SASL bridge skeleton Remove the staged SMTP bridge experiment from this IMAP-focused simplification series. SMTP adoption remains a later step. --- .../smtp/core/esmtp/SmtpSaslBridge.java | 101 ---------- .../smtp/core/esmtp/SmtpSaslBridgeTest.java | 174 ------------------ 2 files changed, 275 deletions(-) delete mode 100644 protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java delete mode 100644 protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java deleted file mode 100644 index f0c355178af..00000000000 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java +++ /dev/null @@ -1,101 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.smtp.core.esmtp; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Objects; -import java.util.Optional; - -import org.apache.commons.lang3.StringUtils; -import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslStep; -import org.apache.james.protocols.smtp.SMTPResponse; -import org.apache.james.protocols.smtp.SMTPRetCode; - -public class SmtpSaslBridge { - private final SaslProtocol protocol; - - public SmtpSaslBridge() { - this(SaslProtocol.SMTP); - } - - protected SmtpSaslBridge(SaslProtocol protocol) { - this.protocol = Objects.requireNonNull(protocol); - } - - /** - * Converts an SMTP AUTH request into a protocol-neutral SASL initial request. - */ - public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { - return new SaslInitialRequest(protocol, mechanismName, - Objects.requireNonNull(initialClientResponse).map(this::decodeInitialClientResponse)); - } - - /** - * Encodes a SASL challenge payload as an SMTP AUTH 334 response. - */ - public SMTPResponse challenge(SaslStep.Challenge challenge) { - return new SMTPResponse(SMTPRetCode.AUTH_READY, - challenge.payload() - .map(Base64.getEncoder()::encodeToString) - .orElse("")); - } - - /** - * Decodes an SMTP client continuation line and forwards it to the SASL exchange. - */ - public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { - return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); - } - - /** - * Aborts and closes an active SASL exchange. - */ - public void abort(SaslExchange exchange) { - exchange.abort(); - exchange.close(); - } - - /** - * Closes an active SASL exchange. - */ - public void close(SaslExchange exchange) { - exchange.close(); - } - - private byte[] decodeBase64(String value) { - return Base64.getDecoder().decode(value); - } - - private byte[] decodeInitialClientResponse(String value) { - if (value.equals("=")) { - return new byte[0]; - } - return decodeBase64(value); - } - - private String stripTrailingCrlf(byte[] line) { - String value = new String(line, StandardCharsets.US_ASCII); - return StringUtils.stripEnd(value, "\r\n"); - } -} diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java deleted file mode 100644 index 93a97d8f2f7..00000000000 --- a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java +++ /dev/null @@ -1,174 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.smtp.core.esmtp; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Optional; - -import org.apache.james.core.Username; -import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslIdentity; -import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslStep; -import org.apache.james.protocols.smtp.SMTPResponse; -import org.apache.james.protocols.smtp.SMTPRetCode; -import org.junit.jupiter.api.Test; - -class SmtpSaslBridgeTest { - private static final Username USER = Username.of("user@example.com"); - private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); - - private static class LmtpSaslBridge extends SmtpSaslBridge { - private LmtpSaslBridge() { - super(SaslProtocol.LMTP); - } - } - - private static class RecordingExchange implements SaslExchange { - private final List lifecycleEvents; - private byte[] lastClientResponse; - - private RecordingExchange() { - this.lifecycleEvents = new ArrayList<>(); - } - - @Override - public SaslStep firstStep() { - return new SaslStep.Challenge(Optional.of(bytes("challenge"))); - } - - @Override - public SaslStep onResponse(byte[] clientResponse) { - lastClientResponse = clientResponse.clone(); - return new SaslStep.Success(IDENTITY, Optional.empty()); - } - - @Override - public void abort() { - lifecycleEvents.add("abort"); - } - - @Override - public void close() { - lifecycleEvents.add("close"); - } - } - - private final SmtpSaslBridge testee = new SmtpSaslBridge(); - - @Test - void initialRequestShouldDecodeInitialClientResponse() { - String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); - - SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); - - assertThat(request.protocol()).isEqualTo(SaslProtocol.SMTP); - assertThat(request.mechanismName()).isEqualTo("PLAIN"); - assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); - } - - @Test - void initialRequestShouldUseConfiguredProtocol() { - String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); - - SaslInitialRequest request = new LmtpSaslBridge().initialRequest("PLAIN", Optional.of(encodedInitialResponse)); - - assertThat(request.protocol()).isEqualTo(SaslProtocol.LMTP); - assertThat(request.mechanismName()).isEqualTo("PLAIN"); - assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); - } - - @Test - void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { - SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); - - assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); - } - - @Test - void challengeShouldReturnAuthReadyWithBase64EncodedChallengePayload() { - SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("challenge"))); - - SMTPResponse response = testee.challenge(challenge); - - assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); - assertThat(response.getLines()).containsExactly(SMTPRetCode.AUTH_READY + " " + Base64.getEncoder().encodeToString(bytes("challenge"))); - } - - @Test - void challengeShouldReturnAuthReadyWithEmptyDescriptionWhenChallengeHasNoPayload() { - SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.empty()); - - SMTPResponse response = testee.challenge(challenge); - - assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); - assertThat(response.getLines()).containsExactly(SMTPRetCode.AUTH_READY + " "); - } - - @Test - void onClientResponseShouldDecodeLineAndContinueExchange() { - RecordingExchange exchange = new RecordingExchange(); - byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); - - SaslStep step = testee.onClientResponse(exchange, line); - - assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); - assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); - } - - @Test - void onClientResponseShouldDecodeLineWithLfOnly() { - RecordingExchange exchange = new RecordingExchange(); - byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\n").getBytes(StandardCharsets.US_ASCII); - - SaslStep step = testee.onClientResponse(exchange, line); - - assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); - assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); - } - - @Test - void abortShouldAbortThenCloseExchange() { - RecordingExchange exchange = new RecordingExchange(); - - testee.abort(exchange); - - assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); - } - - @Test - void closeShouldCloseExchange() { - RecordingExchange exchange = new RecordingExchange(); - - testee.close(exchange); - - assertThat(exchange.lifecycleEvents).containsExactly("close"); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } -} From 9cf926ee780768075e9ffdaafd420f9388b0e5f7 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 12 Jun 2026 16:20:21 +0700 Subject: [PATCH 19/29] JAMES-4210 Update custom IMAP SASL example to the new SPI Update the custom IMAP extension example to contribute a SASL mechanism factory through Guice, keep auth.saslMechanisms configuration, and cover continuation plus final server-data behavior. --- examples/custom-imap/README.md | 34 ++++++++--- .../extensions.properties | 19 ++++++ .../sample-configuration/imapserver.xml | 1 - ...ExampleTokenSaslAuthenticationService.java | 48 --------------- ...TokenSaslAuthenticationServiceFactory.java | 56 ----------------- .../imap/sasl/ExampleTokenSaslMechanism.java | 60 ++++++++----------- ... => ExampleTokenSaslMechanismFactory.java} | 21 ++----- .../imap/sasl/ExampleTokenSaslModule.java | 43 +++++++++++++ .../src/main/resources/extensions.properties | 19 ++++++ .../src/main/resources/imapserver.xml | 2 - .../imap/CustomSaslMechanismTest.java | 26 ++++++++ 11 files changed, 164 insertions(+), 165 deletions(-) create mode 100644 examples/custom-imap/sample-configuration/extensions.properties delete mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java delete mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java rename examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/{ExampleTokenSaslAuthenticationServiceFactoryProvider.java => ExampleTokenSaslMechanismFactory.java} (60%) create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java create mode 100644 examples/custom-imap/src/main/resources/extensions.properties diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md index 5845aaa9c13..5727c349a9b 100644 --- a/examples/custom-imap/README.md +++ b/examples/custom-imap/README.md @@ -18,14 +18,11 @@ Note that when `imapPackages` is not provided, James will implicit use This example also demonstrates how to add a custom IMAP SASL mechanism. The `EXAMPLE-TOKEN` mechanism is declared through `auth.saslMechanisms`, -its authentication service factory provider is declared through -`auth.saslAuthenticationServiceFactoryProviderExtensions`, while `auth.exampleToken` -is a custom configuration block owned by the extension: +while `auth.exampleToken` is a custom configuration block owned by the extension: ```xml PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism - org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider secret-token bob@domain.tld @@ -33,9 +30,15 @@ is a custom configuration block owned by the extension: ``` -James loads the provider through the extension classloader and instantiates it -with Guice, so the provider can use James services and parse its own -configuration block. +The extension module is declared in `extensions.properties`: + +```properties +guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule +``` + +The module binds a `SaslMechanismFactory` for `ExampleTokenSaslMechanism`. +James still uses `auth.saslMechanisms` to select the mechanism for one IMAP +server, and the factory reads that server's `auth.exampleToken` block. ## Running the example @@ -118,3 +121,20 @@ A02 PING * PONG A02 OK PING completed. ``` + +The mechanism can also return final server data on success. The client acknowledges +that final data with an empty line before James sends the tagged `OK`: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 AUTHENTICATE EXAMPLE-TOKEN ++ R28gYWhlYWQ +c2VjcmV0LXRva2VuOnNlcnZlci1kYXRh ++ VG9rZW4gYWNjZXB0ZWQ= + +A01 OK AUTHENTICATE completed. +``` diff --git a/examples/custom-imap/sample-configuration/extensions.properties b/examples/custom-imap/sample-configuration/extensions.properties new file mode 100644 index 00000000000..1d40a0f64c2 --- /dev/null +++ b/examples/custom-imap/sample-configuration/extensions.properties @@ -0,0 +1,19 @@ +##################################################################### +# * As a subpart of Twake Mail, this file is edited by Linagora. * +# * * +# * https://twake-mail.com/ * +# * https://linagora.com * +# * * +# * This file is subject to The Affero Gnu Public License * +# * version 3. * +# * * +# * https://www.gnu.org/licenses/agpl-3.0.en.html * +# * * +# * This program is distributed in the hope that it will be * +# * useful, but WITHOUT ANY WARRANTY; without even the implied * +# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR * +# * PURPOSE. See the GNU Affero General Public License for * +# * more details. * +##################################################################### + +guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule diff --git a/examples/custom-imap/sample-configuration/imapserver.xml b/examples/custom-imap/sample-configuration/imapserver.xml index 4d344790d9f..8b3b450580d 100644 --- a/examples/custom-imap/sample-configuration/imapserver.xml +++ b/examples/custom-imap/sample-configuration/imapserver.xml @@ -35,7 +35,6 @@ under the License. false PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism - org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider secret-token bob@domain.tld diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java deleted file mode 100644 index 59c970448fa..00000000000 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationService.java +++ /dev/null @@ -1,48 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.examples.imap.sasl; - -import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; -import org.apache.james.protocols.api.sasl.SaslIdentity; - -public class ExampleTokenSaslAuthenticationService { - private final MailboxManager mailboxManager; - private final ImapSaslSessionContext context; - private final ExampleTokenSaslConfiguration configuration; - - public ExampleTokenSaslAuthenticationService(MailboxManager mailboxManager, ImapSaslSessionContext context, ExampleTokenSaslConfiguration configuration) { - this.mailboxManager = mailboxManager; - this.context = context; - this.configuration = configuration; - } - - public SaslAuthenticationResult authenticate(String token) { - if (!configuration.expectedToken().equals(token)) { - return new SaslAuthenticationResult.Failure("EXAMPLE-TOKEN authentication failed."); - } - - MailboxSession mailboxSession = mailboxManager.createSystemSession(configuration.authorizedUser()); - context.authenticationSucceeded(mailboxSession); - return new SaslAuthenticationResult.Success(new SaslIdentity(configuration.authorizedUser(), configuration.authorizedUser())); - } -} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java deleted file mode 100644 index d279e0ec1f2..00000000000 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactory.java +++ /dev/null @@ -1,56 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.examples.imap.sasl; - -import java.util.Optional; - -import org.apache.james.imap.processor.sasl.ImapSaslSessionContext; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; - -public class ExampleTokenSaslAuthenticationServiceFactory implements SaslAuthenticationServiceFactory { - private final MailboxManager mailboxManager; - private final ExampleTokenSaslConfiguration configuration; - - public ExampleTokenSaslAuthenticationServiceFactory(MailboxManager mailboxManager, ExampleTokenSaslConfiguration configuration) { - this.mailboxManager = mailboxManager; - this.configuration = configuration; - } - - @Override - public SaslProtocol protocol() { - return SaslProtocol.IMAP; - } - - @Override - public Class serviceType() { - return ExampleTokenSaslAuthenticationService.class; - } - - @Override - public Optional create(SaslSessionContext context) { - if (context instanceof ImapSaslSessionContext imapContext) { - return Optional.of(new ExampleTokenSaslAuthenticationService(mailboxManager, imapContext, configuration)); - } - return Optional.empty(); - } -} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java index e4f6e4517d8..d0e002e0467 100644 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java @@ -21,55 +21,43 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; -import java.util.Set; -import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslProtocol; -import org.apache.james.protocols.api.sasl.SaslSessionContext; import org.apache.james.protocols.api.sasl.SaslStep; public class ExampleTokenSaslMechanism implements SaslMechanism { public static final String NAME = "EXAMPLE-TOKEN"; public static final String CONTINUATION_PROMPT = "Go ahead"; + public static final String SUCCESS_DATA_TOKEN_SUFFIX = ":server-data"; + public static final String SUCCESS_DATA = "Token accepted"; - @Override - public String name() { - return NAME; - } - - @Override - public boolean supports(SaslProtocol protocol) { - return protocol == SaslProtocol.IMAP; - } + private final ExampleTokenSaslConfiguration configuration; - @Override - public Set> requiredServices(SaslProtocol protocol) { - if (supports(protocol)) { - return Set.of(ExampleTokenSaslAuthenticationService.class); - } - return Set.of(); + public ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration configuration) { + this.configuration = configuration; } @Override - public boolean isAvailable(SaslSessionContext context) { - return context.service(ExampleTokenSaslAuthenticationService.class).isPresent(); + public String name() { + return NAME; } @Override - public SaslExchange start(SaslInitialRequest request, SaslSessionContext context) { - return new ExampleTokenSaslExchange(request.initialResponse(), context); + public SaslExchange start(SaslInitialRequest request) { + Optional initialResponse = request.initialResponse(); + return new ExampleTokenSaslExchange(initialResponse, configuration); } private static class ExampleTokenSaslExchange implements SaslExchange { private final Optional initialResponse; - private final SaslSessionContext context; + private final ExampleTokenSaslConfiguration configuration; - private ExampleTokenSaslExchange(Optional initialResponse, SaslSessionContext context) { + private ExampleTokenSaslExchange(Optional initialResponse, ExampleTokenSaslConfiguration configuration) { this.initialResponse = initialResponse; - this.context = context; + this.configuration = configuration; } @Override @@ -94,17 +82,19 @@ public void close() { } private SaslStep authenticate(byte[] clientResponse) { - return context.service(ExampleTokenSaslAuthenticationService.class) - .map(service -> service.authenticate(new String(clientResponse, StandardCharsets.UTF_8))) - .map(this::toStep) - .orElseGet(() -> new SaslStep.Failure("EXAMPLE-TOKEN authentication is not available.")); + String token = new String(clientResponse, StandardCharsets.UTF_8); + if (configuration.expectedToken().equals(token)) { + return success(Optional.empty()); + } + // allow client to request server to return data on success message, which may be used by Kerberos auth + if ((configuration.expectedToken() + SUCCESS_DATA_TOKEN_SUFFIX).equals(token)) { + return success(Optional.of(SUCCESS_DATA.getBytes(StandardCharsets.UTF_8))); + } + return new SaslStep.Failure("EXAMPLE-TOKEN authentication failed."); } - private SaslStep toStep(SaslAuthenticationResult result) { - if (result instanceof SaslAuthenticationResult.Success success) { - return new SaslStep.Success(success.identity(), Optional.empty()); - } - return new SaslStep.Failure(((SaslAuthenticationResult.Failure) result).reason()); + private SaslStep success(Optional serverData) { + return new SaslStep.Success(new SaslIdentity(configuration.authorizedUser(), configuration.authorizedUser()), serverData); } } } diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java similarity index 60% rename from examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java rename to examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java index f7a282bdef9..57895b789de 100644 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslAuthenticationServiceFactoryProvider.java +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java @@ -22,23 +22,12 @@ import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.modules.protocols.ImapSaslAuthenticationServiceFactoryProvider; -import org.apache.james.protocols.api.sasl.SaslAuthenticationServiceFactory; - -import com.google.common.collect.ImmutableList; -import com.google.inject.Inject; - -public class ExampleTokenSaslAuthenticationServiceFactoryProvider implements ImapSaslAuthenticationServiceFactoryProvider { - private final MailboxManager mailboxManager; - - @Inject - public ExampleTokenSaslAuthenticationServiceFactoryProvider(MailboxManager mailboxManager) { - this.mailboxManager = mailboxManager; - } +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +public class ExampleTokenSaslMechanismFactory implements SaslMechanismFactory { @Override - public ImmutableList> provide(HierarchicalConfiguration configuration) throws ConfigurationException { - return ImmutableList.of(new ExampleTokenSaslAuthenticationServiceFactory(mailboxManager, ExampleTokenSaslConfiguration.from(configuration))); + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + return new ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration.from(serverConfiguration)); } } diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java new file mode 100644 index 00000000000..889fafe4834 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import org.apache.james.modules.SaslMechanismFactories; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +import com.google.inject.AbstractModule; +import com.google.inject.TypeLiteral; +import com.google.inject.multibindings.MapBinder; + +public class ExampleTokenSaslModule extends AbstractModule { + @Override + protected void configure() { + MapBinder, SaslMechanismFactory> factories = MapBinder.newMapBinder(binder(), + new TypeLiteral<>() { + }, + new TypeLiteral<>() { + }, + SaslMechanismFactories.class); + + factories.addBinding(ExampleTokenSaslMechanism.class) + .to(ExampleTokenSaslMechanismFactory.class); + } +} diff --git a/examples/custom-imap/src/main/resources/extensions.properties b/examples/custom-imap/src/main/resources/extensions.properties new file mode 100644 index 00000000000..1d40a0f64c2 --- /dev/null +++ b/examples/custom-imap/src/main/resources/extensions.properties @@ -0,0 +1,19 @@ +##################################################################### +# * As a subpart of Twake Mail, this file is edited by Linagora. * +# * * +# * https://twake-mail.com/ * +# * https://linagora.com * +# * * +# * This file is subject to The Affero Gnu Public License * +# * version 3. * +# * * +# * https://www.gnu.org/licenses/agpl-3.0.en.html * +# * * +# * This program is distributed in the hope that it will be * +# * useful, but WITHOUT ANY WARRANTY; without even the implied * +# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR * +# * PURPOSE. See the GNU Affero General Public License for * +# * more details. * +##################################################################### + +guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule diff --git a/examples/custom-imap/src/main/resources/imapserver.xml b/examples/custom-imap/src/main/resources/imapserver.xml index a39e54b7935..cf02bd2d1c0 100644 --- a/examples/custom-imap/src/main/resources/imapserver.xml +++ b/examples/custom-imap/src/main/resources/imapserver.xml @@ -38,7 +38,6 @@ under the License. false PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism - org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider secret-token bob@domain.tld @@ -65,7 +64,6 @@ under the License. false PlainSaslMechanism,OauthBearerSaslMechanism,XOauth2SaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism - org.apache.james.examples.imap.sasl.ExampleTokenSaslAuthenticationServiceFactoryProvider secret-token bob@domain.tld diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java index 6c7a6d0ca75..41fb770b2ca 100644 --- a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java +++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java @@ -148,6 +148,32 @@ void imapServerShouldAuthenticateCustomSaslMechanismUsingContinuation(GuiceJames } } + @Test + void imapServerShouldAuthenticateCustomSaslMechanismReturningServerDataOnSuccess(GuiceJamesServer server) throws IOException { + try (ClientConnection client = clientConnection(server)) { + // GIVEN a custom SASL exchange started without SASL-IR + client.readUntil(line -> line.startsWith("* OK")); + client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN"); + assertThat(client.readUntil(line -> line.startsWith("+"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + // WHEN the mechanism succeeds with final server data, as GSSAPI/Kerberos-like SASL mechanisms may require + client.writeLine(encode(EXPECTED_TOKEN + ExampleTokenSaslMechanism.SUCCESS_DATA_TOKEN_SUFFIX)); + assertThat(client.readUntil(line -> line.startsWith("+") || line.startsWith("A01"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.SUCCESS_DATA)); + + // THEN the client acknowledges the final server data before IMAP completes authentication + client.writeLine(""); + assertThat(client.readUntil(line -> line.startsWith("A01"))) + .contains("OK AUTHENTICATE completed."); + + // THEN the authenticated IMAP session remains usable + client.writeLine("A02 PING"); + assertThat(client.readUntil(line -> line.startsWith("A02"))) + .contains("PONG"); + } + } + @Test void plainSaslAuthenticationShouldStillWork(GuiceJamesServer server) throws IOException { TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); From 8eb8150bbfef14b22d30f14d2094ffad1367a671 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:15:09 +0700 Subject: [PATCH 20/29] JAMES-4210 Introduce protocol-neutral SASL authentication SPI Replace the credential-returning SASL SPI with exchange-driven authentication results. Add protocol-neutral success/failure/authenticator contracts and make abort close exchanges by default. --- .../api/sasl/SaslAuthenticationResult.java | 32 ++++++ .../protocols/api/sasl/SaslAuthenticator.java | 43 +++++++ .../protocols/api/sasl/SaslExchange.java | 6 +- .../james/protocols/api/sasl/SaslFailure.java | 66 +++++++++++ .../protocols/api/sasl/SaslMechanism.java | 11 +- .../api/sasl/SaslMechanismNames.java | 29 +++++ .../james/protocols/api/sasl/SaslStep.java | 10 +- .../api/sasl/SaslMechanismContractTest.java | 105 ++++-------------- 8 files changed, 207 insertions(+), 95 deletions(-) create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java new file mode 100644 index 00000000000..f3c2c10a690 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +/** + * Result of protocol-neutral James authentication or authorization performed for a SASL mechanism. + */ +public sealed interface SaslAuthenticationResult permits SaslAuthenticationResult.Success, SaslAuthenticationResult.Failure { + + record Success(SaslIdentity identity) implements SaslAuthenticationResult { + } + + record Failure(SaslFailure failure) implements SaslAuthenticationResult { + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java new file mode 100644 index 00000000000..18b21d78108 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; + +/** + * Protocol-neutral authentication service available to SASL mechanisms. + */ +public interface SaslAuthenticator { + /** + * Verifies password credentials and, when present, validates that the authenticated user may act as the + * requested authorization identity. + */ + SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional authorizationId, + String password); + + /** + * Validates an already-authenticated identity, typically for token or Kerberos mechanisms that verified + * credentials inside the SASL exchange. + */ + SaslAuthenticationResult authorize(SaslIdentity identity); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java index 47c491d1ed8..49921941d21 100644 --- 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 @@ -34,9 +34,11 @@ public interface SaslExchange extends AutoCloseable { SaslStep onResponse(byte[] clientResponse); /** - * Aborts the exchange after a client cancellation or protocol-level failure. + * Aborts the exchange after a client cancellation or protocol-level failure, and releases associated resources. */ - void abort(); + default void abort() { + close(); + } @Override void close(); diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java new file mode 100644 index 00000000000..385ef2140b6 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; + +/** + * Protocol-neutral SASL failure with enough metadata for protocol-specific response mapping and audit logging. + */ +public record SaslFailure(Type type, + Optional authenticationId, + Optional authorizationId, + String reason, + Optional cause) { + public enum Type { + MALFORMED, + INVALID_CREDENTIALS, + AUTHENTICATION_FAILED, + USER_DOES_NOT_EXIST, + DELEGATION_FORBIDDEN, + SERVER_ERROR + } + + public static SaslFailure malformed(String reason) { + return new SaslFailure(Type.MALFORMED, Optional.empty(), Optional.empty(), reason, Optional.empty()); + } + + public static SaslFailure invalidCredentials(Username authenticationId, Optional authorizationId, String reason) { + return new SaslFailure(Type.INVALID_CREDENTIALS, Optional.of(authenticationId), authorizationId, reason, Optional.empty()); + } + + public static SaslFailure authenticationFailed(Optional authenticationId, Optional authorizationId, String reason) { + return new SaslFailure(Type.AUTHENTICATION_FAILED, authenticationId, authorizationId, reason, Optional.empty()); + } + + public static SaslFailure userDoesNotExist(Username authenticationId, Username authorizationId, String reason) { + return new SaslFailure(Type.USER_DOES_NOT_EXIST, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty()); + } + + public static SaslFailure delegationForbidden(Username authenticationId, Username authorizationId, String reason) { + return new SaslFailure(Type.DELEGATION_FORBIDDEN, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty()); + } + + public static SaslFailure serverError(Optional authenticationId, Optional authorizationId, String reason, Throwable cause) { + return new SaslFailure(Type.SERVER_ERROR, authenticationId, authorizationId, reason, Optional.ofNullable(cause)); + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java index ae856b518ff..3be6de623bd 100644 --- 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 @@ -31,5 +31,14 @@ public interface SaslMechanism { /** * Starts a new SASL exchange for one client authentication attempt. */ - SaslExchange start(SaslInitialRequest request); + 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/SaslMechanismNames.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java new file mode 100644 index 00000000000..70e76d05039 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java @@ -0,0 +1,29 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +public final class SaslMechanismNames { + public static final String PLAIN = "PLAIN"; + public static final String OAUTHBEARER = "OAUTHBEARER"; + public static final String XOAUTH2 = "XOAUTH2"; + + private SaslMechanismNames() { + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java index 6d5fa844335..37608f30d5a 100644 --- 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 @@ -24,7 +24,7 @@ /** * Server step produced by a SASL exchange. */ -public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Credentials, SaslStep.Success, SaslStep.Failure { +public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Success, SaslStep.Failure { /** * Server challenge to send back to the client. */ @@ -41,12 +41,6 @@ public Optional payload() { } } - /** - * Parsed credentials to be applied by the protocol handler. - */ - record Credentials(SaslCredentials credentials) implements SaslStep { - } - /** * Successful SASL exchange result. */ @@ -67,6 +61,6 @@ public Optional serverData() { /** * Failed SASL exchange result. */ - record Failure(String reason) implements SaslStep { + 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 index 3836fc00222..2b4054284a2 100644 --- 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 @@ -33,10 +33,19 @@ class SaslMechanismContractTest { private static final Username AUTHENTICATION_ID = Username.of("authentication@example.com"); private static final Username AUTHORIZATION_ID = Username.of("authorization@example.com"); - private static final String PASSWORD = "secret"; - private static final String TOKEN = "access-token"; private static final SaslIdentity SAME_USER_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID); private static final SaslIdentity DELEGATED_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID); + private static final SaslAuthenticator NOOP_AUTHENTICATOR = new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; /** * Models one-step mechanisms that can immediately succeed or fail on the first server step. @@ -54,7 +63,7 @@ public String name() { } @Override - public SaslExchange start(SaslInitialRequest request) { + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { return new FixedStepExchange(firstStep); } } @@ -99,7 +108,7 @@ public String name() { } @Override - public SaslExchange start(SaslInitialRequest request) { + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { return new TwoStepExchange(); } } @@ -116,12 +125,12 @@ public SaslStep firstStep() { @Override public SaslStep onResponse(byte[] clientResponse) { if (!challenged) { - return new SaslStep.Failure("response received before challenge"); + 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("rejected"); + return new SaslStep.Failure(SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected")); } @Override @@ -133,36 +142,11 @@ public void close() { } } - /** - * Models generic password mechanisms that parse SASL payloads but leave verification to the protocol. - */ - private static class PasswordLikeMechanism implements SaslMechanism { - @Override - public String name() { - return "PASSWORD_LIKE"; - } - - @Override - public SaslExchange start(SaslInitialRequest request) { - return new FixedStepExchange(request.initialResponse() - .map(this::credentials) - .orElseGet(() -> new SaslStep.Challenge(Optional.empty()))); - } - - private SaslStep credentials(byte[] payload) { - String[] parts = new String(payload, StandardCharsets.UTF_8).split("\u0000", -1); - return new SaslStep.Credentials(new SaslCredentials.Password( - Username.of(parts[1]), - Optional.of(parts[0]).filter(value -> !value.isEmpty()).map(Username::of), - parts[2])); - } - } - @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())); + SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); // WHEN the exchange starts SaslStep firstStep = exchange.firstStep(); @@ -174,8 +158,8 @@ void oneStepMechanismShouldReturnSuccess() { @Test void oneStepMechanismShouldReturnFailure() { // GIVEN a one-step mechanism configured to immediately fail - SaslStep.Failure failure = new SaslStep.Failure("failure"); - SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty())); + 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(); @@ -187,7 +171,7 @@ void oneStepMechanismShouldReturnFailure() { @Test void multiStepMechanismShouldKeepStateAcrossResponses() { // GIVEN a mechanism that requires one challenge before accepting a response - SaslExchange exchange = new TwoStepMechanism().start(initialRequest(Optional.empty())); + 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(); @@ -198,58 +182,11 @@ void multiStepMechanismShouldKeepStateAcrossResponses() { assertThat(((SaslStep.Success) success).identity()).isEqualTo(SAME_USER_IDENTITY); } - @Test - void passwordLikeMechanismShouldReturnProtocolNeutralCredentials() { - // GIVEN a password-like mechanism and a PLAIN-like initial response - SaslExchange exchange = new PasswordLikeMechanism() - .start(initialRequest(Optional.of(bytes("\u0000" + AUTHENTICATION_ID.asString() + "\u0000" + PASSWORD)))); - - // WHEN the generic mechanism consumes the initial response - SaslStep firstStep = exchange.firstStep(); - - // THEN it returns credentials without depending on IMAP or SMTP authentication services - assertThat(firstStep).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( - AUTHENTICATION_ID, Optional.empty(), PASSWORD))); - } - - @Test - void passwordLikeMechanismShouldPreserveDelegatedIdentityInCredentials() { - // GIVEN a PLAIN-like initial response with distinct authorization and authentication identities - SaslExchange exchange = new PasswordLikeMechanism() - .start(initialRequest(Optional.of(bytes(AUTHORIZATION_ID.asString() + "\u0000" + AUTHENTICATION_ID.asString() + "\u0000" + PASSWORD)))); - - // WHEN the generic mechanism consumes the initial response - SaslStep firstStep = exchange.firstStep(); - - // THEN the credentials carry both identities for protocol-level delegation handling - assertThat(firstStep).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( - AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD))); - } - - @Test - void credentialsToStringShouldRedactSecrets() { - // GIVEN credentials carrying sensitive password and bearer token values - SaslCredentials.Password password = new SaslCredentials.Password(AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD); - SaslCredentials.BearerToken bearerToken = new SaslCredentials.BearerToken(TOKEN, AUTHORIZATION_ID); - - // WHEN credentials are converted to strings, for example by accidental logging - String passwordString = password.toString(); - String bearerTokenString = bearerToken.toString(); - - // THEN the sensitive fields are redacted while identity fields remain useful for diagnostics - assertThat(passwordString) - .contains("password=******", AUTHENTICATION_ID.asString(), AUTHORIZATION_ID.asString()) - .doesNotContain(PASSWORD); - assertThat(bearerTokenString) - .contains("token=******", AUTHORIZATION_ID.asString()) - .doesNotContain(TOKEN); - } - @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())); + .start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); // WHEN the exchange starts SaslStep firstStep = exchange.firstStep(); @@ -291,7 +228,7 @@ void saslStepsShouldDefensivelyCopyPayloads() { @Test void exchangeShouldExposeAbortAndCloseLifecycle() { // GIVEN an active exchange - FixedStepExchange exchange = new FixedStepExchange(new SaslStep.Failure("failure")); + FixedStepExchange exchange = new FixedStepExchange(new SaslStep.Failure(SaslFailure.malformed("failure"))); // WHEN the protocol aborts and then closes it exchange.abort(); From 6e4e54e55972eb688c7ce98383af45846bde6b20 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:15:45 +0700 Subject: [PATCH 21/29] JAMES-4210 Move built-in SASL mechanisms to shared protocol module Add protocols/sasl for reusable James SASL implementations, factories, transport policy, and James auth/authz integration. Move PLAIN and OIDC mechanisms out of protocols/api. --- pom.xml | 5 + .../api/sasl/OidcSaslMechanismTest.java | 88 -------- .../api/sasl/PlainSaslMechanismTest.java | 107 ---------- protocols/pom.xml | 1 + protocols/sasl/pom.xml | 66 ++++++ .../sasl/BuiltInSaslMechanismFactories.java | 44 ++-- .../sasl/JamesSaslAuthenticator.java | 93 +++++++++ .../sasl/OauthBearerSaslMechanismFactory.java | 19 +- .../sasl/OidcSaslMechanismFactory.java} | 32 +-- .../sasl/PlainSaslMechanismFactory.java | 32 +-- .../sasl/XOauth2SaslMechanismFactory.java} | 19 +- .../sasl/oidc/OauthBearerSaslMechanism.java | 28 +-- .../sasl/oidc}/OidcSaslMechanisms.java | 46 +++-- .../sasl/oidc}/XOauth2SaslMechanism.java | 21 +- .../sasl/plain}/PlainSaslMechanism.java | 65 ++++-- .../sasl/oidc/OidcSaslMechanismTest.java | 174 ++++++++++++++++ .../sasl/plain/PlainSaslMechanismTest.java | 194 ++++++++++++++++++ 17 files changed, 722 insertions(+), 312 deletions(-) delete mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java delete mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java create mode 100644 protocols/sasl/pom.xml rename server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java => protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java (51%) create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java rename server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java => protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java (65%) rename protocols/{api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java => sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java} (53%) rename examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java => protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java (54%) rename protocols/{api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java => sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java} (65%) rename server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java => protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java (60%) rename protocols/{api/src/main/java/org/apache/james/protocols/api/sasl => sasl/src/main/java/org/apache/james/protocols/sasl/oidc}/OidcSaslMechanisms.java (51%) rename protocols/{api/src/main/java/org/apache/james/protocols/api/sasl => sasl/src/main/java/org/apache/james/protocols/sasl/oidc}/XOauth2SaslMechanism.java (65%) rename protocols/{api/src/main/java/org/apache/james/protocols/api/sasl => sasl/src/main/java/org/apache/james/protocols/sasl/plain}/PlainSaslMechanism.java (59%) create mode 100644 protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java create mode 100644 protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java diff --git a/pom.xml b/pom.xml index a8abd2fef04..f033892b0d6 100644 --- a/pom.xml +++ b/pom.xml @@ -2151,6 +2151,11 @@ protocols-pop3 ${project.version} + + ${james.protocols.groupId} + protocols-sasl + ${project.version} + ${james.protocols.groupId} protocols-smtp diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java deleted file mode 100644 index cf317966e6c..00000000000 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/OidcSaslMechanismTest.java +++ /dev/null @@ -1,88 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.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; - -class OidcSaslMechanismTest { - private static final Username USER = Username.of("user@example.com"); - private static final String TOKEN = "token"; - - @Test - void oauthBearerShouldReturnBearerTokenCredentialsFromDecodedInitialResponse() { - // GIVEN a decoded OAUTHBEARER initial response - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, - Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); - - // WHEN the mechanism consumes the response - SaslStep step = new OauthBearerSaslMechanism().start(request).firstStep(); - - // THEN it returns protocol-neutral bearer token credentials - assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.BearerToken(TOKEN, USER))); - } - - @Test - void xOauth2ShouldReturnBearerTokenCredentialsFromDecodedInitialResponse() { - // GIVEN a decoded XOAUTH2 initial response - SaslInitialRequest request = new SaslInitialRequest(XOauth2SaslMechanism.NAME, - Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); - - // WHEN the mechanism consumes the response - SaslStep step = new XOauth2SaslMechanism().start(request).firstStep(); - - // THEN it exposes the same generic bearer-token credential shape - assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.BearerToken(TOKEN, USER))); - } - - @Test - void shouldChallengeWhenNoInitialResponse() { - // GIVEN an OIDC SASL exchange without SASL-IR - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, Optional.empty()); - - // WHEN the mechanism starts - SaslStep firstStep = new OauthBearerSaslMechanism().start(request).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(OauthBearerSaslMechanism.NAME, - Optional.of(bytes("invalid"))); - - // WHEN the mechanism consumes the response - SaslStep step = new OauthBearerSaslMechanism().start(request).firstStep(); - - // THEN it fails before any protocol-specific token validation - assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.US_ASCII); - } -} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java deleted file mode 100644 index 71a2544e445..00000000000 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/PlainSaslMechanismTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.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; - -class PlainSaslMechanismTest { - private static final Username AUTHENTICATION_ID = Username.of("user@example.com"); - private static final Username AUTHORIZATION_ID = Username.of("delegated@example.com"); - private static final String PASSWORD = "secret"; - - private final PlainSaslMechanism testee = new PlainSaslMechanism(); - - @Test - void 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).firstStep(); - - // THEN the server asks for one client response - assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); - } - - @Test - void shouldReturnPasswordCredentialsForInitialResponseWithoutDelegation() { - // 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).firstStep(); - - // THEN it returns protocol-neutral password credentials - assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( - AUTHENTICATION_ID, Optional.empty(), PASSWORD))); - } - - @Test - void shouldReturnPasswordCredentialsForContinuationResponseWithDelegation() { - // GIVEN a PLAIN exchange waiting for the client response - SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); - SaslExchange exchange = testee.start(request); - - // 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 for protocol-level authentication and delegation - assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( - AUTHENTICATION_ID, Optional.of(AUTHORIZATION_ID), PASSWORD))); - } - - @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).firstStep(); - - // THEN it treats the response as non-delegated password credentials - assertThat(step).isEqualTo(new SaslStep.Credentials(new SaslCredentials.Password( - AUTHENTICATION_ID, Optional.empty(), PASSWORD))); - } - - @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).firstStep(); - - // THEN it fails before any protocol-specific authentication side effect - assertThat(step).isEqualTo(new SaslStep.Failure("Malformed authentication command.")); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } -} diff --git a/protocols/pom.xml b/protocols/pom.xml index e3745907832..a643b050a31 100644 --- a/protocols/pom.xml +++ b/protocols/pom.xml @@ -42,6 +42,7 @@ managesieve netty pop3 + sasl smtp diff --git a/protocols/sasl/pom.xml b/protocols/sasl/pom.xml new file mode 100644 index 00000000000..ddb94437af0 --- /dev/null +++ b/protocols/sasl/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + org.apache.james.protocols + protocols + 3.10.0-SNAPSHOT + ../pom.xml + + + protocols-sasl + jar + + Apache James :: Protocols :: SASL implementations + + + + ${james.groupId} + apache-james-mailbox-api + + + ${james.groupId} + james-server-jwt + + + ${james.groupId} + testing-base + test + + + ${james.protocols.groupId} + protocols-api + + + com.google.guava + guava + + + jakarta.inject + jakarta.inject-api + + + org.apache.commons + commons-configuration2 + + + diff --git a/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java similarity index 51% rename from server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java index c61d691bb72..859895f0304 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/protocols/api/sasl/TestingDefaultPackageSaslMechanism.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java @@ -17,36 +17,30 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl; -public class TestingDefaultPackageSaslMechanism implements SaslMechanism { - @Override - public String name() { - return "DEFAULT"; - } - - @Override - public SaslExchange start(SaslInitialRequest request) { - return new FixedStepExchange(new SaslStep.Failure("not implemented")); - } +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; - private record FixedStepExchange(SaslStep step) implements SaslExchange { - @Override - public SaslStep firstStep() { - return step; - } +import com.google.common.collect.ImmutableList; - @Override - public SaslStep onResponse(byte[] clientResponse) { - return step; - } +public final class BuiltInSaslMechanismFactories { + public static ImmutableList enabledForServer(ImmutableList defaultFactories, + HierarchicalConfiguration serverConfiguration) { + return defaultFactories.stream() + .filter(factory -> isEnabledByDefault(factory, serverConfiguration)) + .collect(ImmutableList.toImmutableList()); + } - @Override - public void abort() { + private static boolean isEnabledByDefault(SaslMechanismFactory factory, + HierarchicalConfiguration serverConfiguration) { + if (factory instanceof OidcSaslMechanismFactory) { + return !serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty(); } + return true; + } - @Override - public void close() { - } + private BuiltInSaslMechanismFactories() { } } diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java new file mode 100644 index 00000000000..5ca22c99856 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java @@ -0,0 +1,93 @@ +/**************************************************************** + * 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.exception.MailboxException; +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 { + private final Authenticator authenticator; + private final Authorizator authorizator; + + @Inject + public JamesSaslAuthenticator(Authenticator authenticator, Authorizator authorizator) { + this.authenticator = authenticator; + this.authorizator = authorizator; + } + + public JamesSaslAuthenticator withExtraAuthorizator(Authorizator extraAuthorizator) { + return new JamesSaslAuthenticator(authenticator, Authorizator.combine(authorizator, extraAuthorizator)); + } + + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional authorizationId, + String password) { + try { + Optional authenticatedUser = authenticator.isAuthentic(authenticationId, password); + if (authenticatedUser.isEmpty()) { + return failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, + "Password authentication failed because of bad credentials.")); + } + Username targetUser = authorizationId.orElse(authenticatedUser.get()); + return authorize(new SaslIdentity(authenticatedUser.get(), targetUser)); + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(authenticationId), authorizationId, "Authentication failed.", e)); + } + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + if (identity.authenticationId().equals(identity.authorizationId())) { + return success(identity); + } + + try { + return switch (authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId())) { + case ALLOWED -> success(identity); + case UNKNOWN_USER -> failure(SaslFailure.userDoesNotExist(identity.authenticationId(), identity.authorizationId(), + "Delegation target user does not exist.")); + case FORBIDDEN -> failure(SaslFailure.delegationForbidden(identity.authenticationId(), identity.authorizationId(), + "Requested delegation is forbidden.")); + }; + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(identity.authenticationId()), Optional.of(identity.authorizationId()), + "Authentication failed.", e)); + } + } + + private SaslAuthenticationResult success(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + + private SaslAuthenticationResult failure(SaslFailure failure) { + return new SaslAuthenticationResult.Failure(failure); + } +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java similarity index 65% rename from server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java index 6a4988d593b..a0c52a5a8ea 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/JamesDefaultImapSaslMechanismClassNamesProvider.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java @@ -17,22 +17,17 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules.protocols; +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.OauthBearerSaslMechanism; -import org.apache.james.protocols.api.sasl.PlainSaslMechanism; -import org.apache.james.protocols.api.sasl.XOauth2SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.sasl.oidc.OauthBearerSaslMechanism; -import com.google.common.collect.ImmutableList; - -public class JamesDefaultImapSaslMechanismClassNamesProvider implements DefaultImapSaslMechanismClassNamesProvider { +public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory { @Override - public ImmutableList resolve(HierarchicalConfiguration configuration) { - return ImmutableList.of( - PlainSaslMechanism.class.getSimpleName(), - OauthBearerSaslMechanism.class.getSimpleName(), - XOauth2SaslMechanism.class.getSimpleName()); + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + return new OauthBearerSaslMechanism(parseVerifier(serverConfiguration)); } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java similarity index 53% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java index b2c0a5433d5..3cf55ed170b 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslCredentials.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java @@ -17,25 +17,27 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl; -import java.util.Optional; +import java.net.MalformedURLException; +import java.net.URISyntaxException; -import org.apache.james.core.Username; +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; -/** - * Credentials parsed by SASL mechanisms and applied by protocol handlers. - */ -public sealed interface SaslCredentials permits SaslCredentials.Password, SaslCredentials.BearerToken { - record Password(Username authenticationId, Optional authorizationId, String password) implements SaslCredentials { - public String toString() { - return "Password[authenticationId=" + authenticationId.asString() + ", authorizationId=" + authorizationId.map(Username::asString) + ", password=******]"; +abstract class OidcSaslMechanismFactory implements SaslMechanismFactory { + protected OidcJwtTokenVerifier parseVerifier(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + if (serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty()) { + throw new ConfigurationException("OAuth SASL mechanisms require an auth.oidc configuration"); } - } - - record BearerToken(String token, Username authorizationId) implements SaslCredentials { - public String toString() { - return "BearerToken[token=******, authorizationId=" + authorizationId.asString() + "]"; + 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/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java similarity index 54% rename from examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java index 889fafe4834..9a3537a772f 100644 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslModule.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java @@ -17,27 +17,29 @@ * under the License. * ****************************************************************/ -package org.apache.james.examples.imap.sasl; +package org.apache.james.protocols.sasl; -import org.apache.james.modules.SaslMechanismFactories; +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; -import com.google.inject.AbstractModule; -import com.google.inject.TypeLiteral; -import com.google.inject.multibindings.MapBinder; +public class PlainSaslMechanismFactory implements SaslMechanismFactory { + private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true; + private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true; -public class ExampleTokenSaslModule extends AbstractModule { @Override - protected void configure() { - MapBinder, SaslMechanismFactory> factories = MapBinder.newMapBinder(binder(), - new TypeLiteral<>() { - }, - new TypeLiteral<>() { - }, - SaslMechanismFactories.class); + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new PlainSaslMechanism(plainAuthEnabled(serverConfiguration), requiresSsl(serverConfiguration)); + } + + protected boolean plainAuthEnabled(HierarchicalConfiguration serverConfiguration) { + return serverConfiguration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT); + } - factories.addBinding(ExampleTokenSaslMechanism.class) - .to(ExampleTokenSaslMechanismFactory.class); + protected boolean requiresSsl(HierarchicalConfiguration serverConfiguration) { + return serverConfiguration.getBoolean("auth.requireSSL", + serverConfiguration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT)); } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java similarity index 65% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java index 26a1572d816..931801eb102 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OauthBearerSaslMechanism.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java @@ -17,18 +17,17 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl; -public class OauthBearerSaslMechanism implements SaslMechanism { - public static final String NAME = "OAUTHBEARER"; +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.sasl.oidc.XOauth2SaslMechanism; +public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory { @Override - public String name() { - return NAME; - } - - @Override - public SaslExchange start(SaslInitialRequest request) { - return OidcSaslMechanisms.start(request.initialResponse()); + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + return new XOauth2SaslMechanism(parseVerifier(serverConfiguration)); } } diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java similarity index 60% rename from server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java index 92d88d2c8a0..17ed0d9cd25 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismInstantiator.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java @@ -17,27 +17,31 @@ * under the License. * ****************************************************************/ -package org.apache.james.utils; - -import jakarta.inject.Inject; +package org.apache.james.protocols.sasl.oidc; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; + +public class OauthBearerSaslMechanism implements SaslMechanism { + public static final String NAME = SaslMechanismNames.OAUTHBEARER; -public class GuiceSaslMechanismInstantiator implements SaslMechanismInstantiator { - private final GuiceLoader.InvocationPerformer mechanismLoader; + private final OidcJwtTokenVerifier verifier; - @Inject - public GuiceSaslMechanismInstantiator(GuiceLoader guiceLoader) { - this.mechanismLoader = guiceLoader.withNamingSheme(DefaultSaslMechanismNamingScheme.asNamingScheme()); + public OauthBearerSaslMechanism(OidcJwtTokenVerifier verifier) { + this.verifier = verifier; } @Override - public Class locate(ClassName className) throws ClassNotFoundException { - return mechanismLoader.locateClass(className); + public String name() { + return NAME; } @Override - public SaslMechanism instantiate(ClassName className) throws ClassNotFoundException { - return mechanismLoader.instantiate(className); + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return OidcSaslMechanisms.start(request.initialResponse(), verifier, authenticator); } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java similarity index 51% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java index 2f584070f4d..90fef3d40de 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/OidcSaslMechanisms.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java @@ -17,17 +17,24 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +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.SaslStep; -public final class OidcSaslMechanisms { - static SaslExchange start(Optional initialResponse) { - return new OidcSaslExchange(initialResponse); +final class OidcSaslMechanisms { + static SaslExchange start(Optional initialResponse, OidcJwtTokenVerifier verifier, SaslAuthenticator authenticator) { + return new OidcSaslExchange(initialResponse, verifier, authenticator); } private OidcSaslMechanisms() { @@ -35,9 +42,15 @@ private OidcSaslMechanisms() { private static class OidcSaslExchange implements SaslExchange { private final Optional initialResponse; + private final OidcJwtTokenVerifier verifier; + private final SaslAuthenticator authenticator; - private OidcSaslExchange(Optional initialResponse) { + private OidcSaslExchange(Optional initialResponse, + OidcJwtTokenVerifier verifier, + SaslAuthenticator authenticator) { this.initialResponse = initialResponse; + this.verifier = verifier; + this.authenticator = authenticator; } @Override @@ -52,19 +65,28 @@ public SaslStep onResponse(byte[] clientResponse) { return authenticate(clientResponse); } - @Override - public void abort() { - } - @Override public void close() { } private SaslStep authenticate(byte[] clientResponse) { return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII)) - .map(response -> (SaslStep) new SaslStep.Credentials(new SaslCredentials.BearerToken( - response.getToken(), Username.of(response.getAssociatedUser())))) - .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); + .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/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java similarity index 65% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java index 0c05e7476f8..cb59f6d112b 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/XOauth2SaslMechanism.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java @@ -17,10 +17,23 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl.oidc; + +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; public class XOauth2SaslMechanism implements SaslMechanism { - public static final String NAME = "XOAUTH2"; + public static final String NAME = SaslMechanismNames.XOAUTH2; + + private final OidcJwtTokenVerifier verifier; + + public XOauth2SaslMechanism(OidcJwtTokenVerifier verifier) { + this.verifier = verifier; + } @Override public String name() { @@ -28,7 +41,7 @@ public String name() { } @Override - public SaslExchange start(SaslInitialRequest request) { - return OidcSaslMechanisms.start(request.initialResponse()); + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return OidcSaslMechanisms.start(request.initialResponse(), verifier, authenticator); } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java similarity index 59% rename from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java index 5f01ea9e98c..96aa8212c12 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/PlainSaslMechanism.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl.plain; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -25,11 +25,19 @@ 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 = "PLAIN"; + public static final String NAME = SaslMechanismNames.PLAIN; protected record PlainCredentials(Optional authorizationId, Username authenticationId, String password) { } @@ -38,23 +46,52 @@ protected static PlainCredentials credentials(Optional authorizationId 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 SaslExchange start(SaslInitialRequest request) { - return new PlainSaslExchange(request.initialResponse(), this::parse); + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return enabled && (!requiresSsl || channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new PlainSaslExchange(request.initialResponse(), this::parse, authenticator); + } + + /** + * Verifies cleartext credentials directly for protocols whose command already exposes username/password, + * for example IMAP LOGIN. + */ + public SaslStep authenticate(Username authenticationId, String password, SaslAuthenticator authenticator) { + return verify(credentials(Optional.empty(), authenticationId, password), authenticator); } private static class PlainSaslExchange implements SaslExchange { private final Optional initialResponse; private final Function> credentialsParser; + private final SaslAuthenticator authenticator; - private PlainSaslExchange(Optional initialResponse, Function> credentialsParser) { + private PlainSaslExchange(Optional initialResponse, + Function> credentialsParser, + SaslAuthenticator authenticator) { this.initialResponse = initialResponse; this.credentialsParser = credentialsParser; + this.authenticator = authenticator; } @Override @@ -69,22 +106,26 @@ public SaslStep onResponse(byte[] clientResponse) { return authenticate(clientResponse); } - @Override - public void abort() { - } - @Override public void close() { } private SaslStep authenticate(byte[] clientResponse) { return credentialsParser.apply(clientResponse) - .map(credentials -> (SaslStep) new SaslStep.Credentials(new SaslCredentials.Password( - credentials.authenticationId(), credentials.authorizationId(), credentials.password()))) - .orElseGet(() -> new SaslStep.Failure("Malformed authentication command.")); + .map(credentials -> verify(credentials, authenticator)) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); } } + protected static SaslStep verify(PlainCredentials credentials, SaslAuthenticator authenticator) { + SaslAuthenticationResult result = authenticator.authenticatePassword( + credentials.authenticationId(), credentials.authorizationId(), credentials.password()); + return switch (result) { + case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty()); + case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure()); + }; + } + protected Optional parse(byte[] clientResponse) { ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) .collect(ImmutableList.toImmutableList()); diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java new file mode 100644 index 00000000000..d15694c2719 --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java @@ -0,0 +1,174 @@ +/**************************************************************** + * 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.SaslStep; +import org.junit.jupiter.api.Test; + +class OidcSaslMechanismTest { + private static final Username USER = Username.of("user@example.com"); + private static final Username TOKEN_SUBJECT = Username.of("token-subject@example.com"); + private static final String TOKEN = "token"; + + @Test + void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { + // GIVEN a decoded OAUTHBEARER initial response + SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = new OauthBearerSaslMechanism(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(XOauth2SaslMechanism.NAME, + Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = new XOauth2SaslMechanism(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(OauthBearerSaslMechanism.NAME, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = new OauthBearerSaslMechanism(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(OauthBearerSaslMechanism.NAME, + Optional.of(bytes("invalid"))); + + // WHEN the mechanism consumes the response + SaslStep step = new OauthBearerSaslMechanism(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(OauthBearerSaslMechanism.NAME, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN token validation rejects the token + SaslStep step = new OauthBearerSaslMechanism(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(OauthBearerSaslMechanism.NAME, + 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 OauthBearerSaslMechanism(verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + + // THEN the failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + private static OidcJwtTokenVerifier verifyingToken() { + return new TestOidcJwtTokenVerifier(Optional.of(TOKEN_SUBJECT)); + } + + private static OidcJwtTokenVerifier rejectingToken() { + return new TestOidcJwtTokenVerifier(Optional.empty()); + } + + private static SaslAuthenticator authorizing() { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejectingAuthorization(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Failure(failure); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static class TestOidcJwtTokenVerifier extends OidcJwtTokenVerifier { + private final Optional validateTokenResult; + + private TestOidcJwtTokenVerifier(Optional validateTokenResult) { + super(null); + this.validateTokenResult = validateTokenResult; + } + + @Override + public Optional validateToken(String token) { + return validateTokenResult; + } + } +} diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java new file mode 100644 index 00000000000..066c47168b3 --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java @@ -0,0 +1,194 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.plain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class PlainSaslMechanismTest { + private static final Username AUTHENTICATION_ID = Username.of("user@example.com"); + private static final Username AUTHORIZATION_ID = Username.of("delegated@example.com"); + private static final String PASSWORD = "secret"; + + private final PlainSaslMechanism testee = new PlainSaslMechanism(); + + @Test + void shouldBeAvailableOnClearTransportByDefault() { + assertThat(testee.isAvailableOnTransport(false)).isTrue(); + } + + @Test + void shouldNotBeAvailableOnClearTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(false)).isFalse(); + } + + @Test + void shouldBeAvailableOnEncryptedTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(true)).isTrue(); + } + + @Test + void shouldNotBeAvailableWhenDisabled() { + assertThat(new PlainSaslMechanism(false, false).isAvailableOnTransport(true)).isFalse(); + } + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN a PLAIN exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = testee.start(request, authenticating()).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldAuthenticateInitialResponseWithoutDelegation() { + // GIVEN a valid PLAIN initial response without an authorization identity + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the initial response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it authenticates through the shared SASL authenticator and returns the authenticated identity + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldAuthenticateContinuationResponseWithDelegation() { + // GIVEN a PLAIN exchange waiting for the client response + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + SaslExchange exchange = testee.start(request, authenticating()); + + // WHEN the client sends a response with an authorization identity + SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)); + + // THEN both identities are preserved after mechanism-owned authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID), Optional.empty())); + } + + @Test + void shouldAcceptTwoPartResponseWithoutAuthorizationIdentity() { + // GIVEN a PLAIN response encoded as authcid/password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes(AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it treats the response as non-delegated authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldPassPasswordUnmodifiedToAuthenticator() { + // GIVEN a PLAIN response whose password is made of whitespace only + AtomicReference capturedPassword = new AtomicReference<>(); + String whitespacePassword = " "; + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + whitespacePassword))); + + // WHEN the mechanism consumes the response + testee.start(request, authenticating(capturedPassword)).firstStep(); + + // THEN the password is kept unchanged instead of being filtered as blank + assertThat(capturedPassword).hasValue(whitespacePassword); + } + + @Test + void shouldReturnAuthenticatorFailure() { + // GIVEN a valid PLAIN response but an authenticator rejecting the password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + SaslFailure failure = SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected"); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, rejecting(failure)).firstStep(); + + // THEN the typed failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + @Test + void shouldFailMalformedResponse() { + // GIVEN a PLAIN response without the expected separators + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("missing-separators"))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it fails before calling protocol-neutral authentication + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private static SaslAuthenticator authenticating() { + return authenticating(new AtomicReference<>()); + } + + private static SaslAuthenticator authenticating(AtomicReference capturedPassword) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + capturedPassword.set(password); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId.orElse(authenticationId))); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejecting(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(failure); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From 6bd025c266bce58d1b481fc0fa9ffe579823be51 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:16:09 +0700 Subject: [PATCH 22/29] JAMES-4210 Add Guice SASL mechanism factory resolution Resolve configured SASL mechanism factories through Guice, support built-in simple names, preserve configured order, and de-duplicate mechanism names case-insensitively. --- .../james/modules/CommonServicesModule.java | 3 - .../utils/GuiceSaslMechanismResolver.java | 55 ++-- ...ngDefaultPackageSaslMechanismFactory.java} | 15 +- ...ConfigurableFakeSaslMechanismFactory.java} | 18 +- .../DefaultSaslMechanismNamingSchemeTest.java | 35 --- .../ExternalFakeSaslMechanismFactory.java} | 13 +- ...anism.java => FixedNameSaslMechanism.java} | 26 +- .../utils/GuiceSaslMechanismResolverTest.java | 278 +++++++++--------- .../james/utils/GuiceGenericLoader.java | 6 - .../org/apache/james/utils/GuiceLoader.java | 3 - 10 files changed, 196 insertions(+), 256 deletions(-) rename server/container/guice/common/src/{main/java/org/apache/james/utils/SaslMechanismInstantiator.java => test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java} (69%) rename server/container/guice/common/src/{main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java => test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java} (72%) delete mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java rename server/container/guice/{protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java => common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java} (76%) rename server/container/guice/common/src/test/java/org/apache/james/utils/{ExternalFakeSaslMechanism.java => FixedNameSaslMechanism.java} (71%) diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java index 6c7a22e4ed8..63959aecb54 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java @@ -33,9 +33,7 @@ import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.ExtensionModule; import org.apache.james.utils.GuiceProbe; -import org.apache.james.utils.GuiceSaslMechanismInstantiator; import org.apache.james.utils.PropertiesProvider; -import org.apache.james.utils.SaslMechanismInstantiator; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -69,7 +67,6 @@ protected void configure() { bind(FileSystem.class).toInstance(fileSystem); bind(Configuration.class).toInstance(configuration); - bind(SaslMechanismInstantiator.class).to(GuiceSaslMechanismInstantiator.class); bind(ConfigurationProvider.class).toInstance(new FileConfigurationProvider(fileSystem, configuration)); diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java index f03ae65071c..c34d2e5804b 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java @@ -22,7 +22,6 @@ import java.util.Collection; import java.util.LinkedHashMap; import java.util.Locale; -import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @@ -38,20 +37,28 @@ import com.google.common.collect.ImmutableList; public class GuiceSaslMechanismResolver { - private final SaslMechanismInstantiator instantiator; + private static final NamingScheme SASL_FACTORY_NAMING_SCHEME = + new NamingScheme.OptionalPackagePrefix(PackageName.of("org.apache.james.protocols.sasl")); + + private final GuiceLoader.InvocationPerformer factoryLoader; @Inject - public GuiceSaslMechanismResolver(SaslMechanismInstantiator instantiator) { - this.instantiator = instantiator; + public GuiceSaslMechanismResolver(GuiceLoader guiceLoader) { + this.factoryLoader = guiceLoader.withNamingSheme(SASL_FACTORY_NAMING_SCHEME); } - public ImmutableList resolve(Collection mechanismClassNames, - HierarchicalConfiguration serverConfiguration, - Map, SaslMechanismFactory> factories) throws ConfigurationException { + public ImmutableList resolve(Collection configuredFactoryClassNames, + ImmutableList enabledDefaultFactories, + HierarchicalConfiguration serverConfiguration) throws ConfigurationException { try { - return mechanismClassNames.stream() - .map(ClassName::new) - .map(Throwing.function(className -> resolve(className, serverConfiguration, factories))) + ImmutableList factories = configuredFactoryClassNames.isEmpty() + ? enabledDefaultFactories + : configuredFactoryClassNames.stream() + .map(Throwing.function(this::instantiateFactory)) + .collect(ImmutableList.toImmutableList()); + + return factories.stream() + .map(Throwing.function(factory -> factory.create(serverConfiguration))) .collect(Collectors.toMap( mechanism -> normalize(mechanism.name()), Function.identity(), @@ -68,31 +75,11 @@ public ImmutableList resolve(Collection mechanismClassNam } } - private SaslMechanism resolve(ClassName mechanismClassName, - HierarchicalConfiguration serverConfiguration, - Map, SaslMechanismFactory> factories) throws ConfigurationException { - Class mechanismClass = locate(mechanismClassName); - SaslMechanismFactory factory = factories.get(mechanismClass); - if (factory != null) { - return factory.create(serverConfiguration); - } - // Fall back to direct instantiation for mechanisms that do not need server-specific configuration. - return instantiate(mechanismClassName); - } - - private Class locate(ClassName mechanismClassName) throws ConfigurationException { - try { - return instantiator.locate(mechanismClassName); - } catch (Exception e) { - throw new ConfigurationException("Can not load SASL mechanism " + mechanismClassName.getName(), e); - } - } - - private SaslMechanism instantiate(ClassName mechanismClassName) throws ConfigurationException { + private SaslMechanismFactory instantiateFactory(String className) throws ConfigurationException { try { - return instantiator.instantiate(mechanismClassName); - } catch (Exception e) { - throw new ConfigurationException("Can not load SASL mechanism " + mechanismClassName.getName(), e); + return factoryLoader.instantiate(new ClassName(className)); + } catch (ClassNotFoundException | RuntimeException e) { + throw new ConfigurationException("Can not load SASL mechanism factory " + className, e); } } diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java similarity index 69% rename from server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java rename to server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java index e136623f526..850ed4c1ce1 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/SaslMechanismInstantiator.java +++ b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java @@ -17,12 +17,17 @@ * under the License. * ****************************************************************/ -package org.apache.james.utils; +package org.apache.james.protocols.sasl; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.utils.FixedNameSaslMechanism; -public interface SaslMechanismInstantiator { - Class locate(ClassName className) throws ClassNotFoundException; - - SaslMechanism instantiate(ClassName className) throws ClassNotFoundException; +public class TestingDefaultPackageSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism("DEFAULT"); + } } diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java similarity index 72% rename from server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java rename to server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java index bd6c92f8fc1..c1f59a2eef1 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/DefaultSaslMechanismNamingScheme.java +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java @@ -19,18 +19,14 @@ package org.apache.james.utils; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; -/** - * Resolves simple SASL mechanism class names against James' default SASL SPI package. - */ -public final class DefaultSaslMechanismNamingScheme { - private static final PackageName DEFAULT_SASL_PACKAGE = PackageName.of(SaslMechanism.class.getPackageName()); - - public static NamingScheme asNamingScheme() { - return new NamingScheme.OptionalPackagePrefix(DEFAULT_SASL_PACKAGE); - } - - private DefaultSaslMechanismNamingScheme() { +public class ConfigurableFakeSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism(serverConfiguration.getString("auth.example.realm")); } } diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java deleted file mode 100644 index 3932214dfbf..00000000000 --- a/server/container/guice/common/src/test/java/org/apache/james/utils/DefaultSaslMechanismNamingSchemeTest.java +++ /dev/null @@ -1,35 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.utils; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class DefaultSaslMechanismNamingSchemeTest { - @Test - void asNamingSchemeShouldResolveSimpleNameAgainstDefaultSaslPackage() { - // avoid breaking changes for default SASL package - assertThat(DefaultSaslMechanismNamingScheme.asNamingScheme() - .toFullyQualifiedClassNames(new ClassName("TestingDefaultPackageSaslMechanism")) - .map(FullyQualifiedClassName::getName)) - .contains("org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism"); - } -} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java similarity index 76% rename from server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java rename to server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java index db9abd0427c..d8607ac7a21 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/DefaultImapSaslMechanismClassNamesProvider.java +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java @@ -17,13 +17,16 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules.protocols; +package org.apache.james.utils; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; -import com.google.common.collect.ImmutableList; - -public interface DefaultImapSaslMechanismClassNamesProvider { - ImmutableList resolve(HierarchicalConfiguration configuration); +public class ExternalFakeSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism("EXTERNAL-FAKE"); + } } diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java similarity index 71% rename from server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java rename to server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java index d86cc32bf26..9b82df2cb2d 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanism.java +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java @@ -19,31 +19,39 @@ package org.apache.james.utils; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.api.sasl.SaslStep; -public class ExternalFakeSaslMechanism implements SaslMechanism { +public class FixedNameSaslMechanism implements SaslMechanism { + private final String name; + + public FixedNameSaslMechanism(String name) { + this.name = name; + } + @Override public String name() { - return "EXTERNAL-FAKE"; + return name; } @Override - public SaslExchange start(SaslInitialRequest request) { - return new FixedStepExchange(new SaslStep.Failure("not implemented")); + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new FixedStepExchange(); } - private record FixedStepExchange(SaslStep step) implements SaslExchange { + private record FixedStepExchange() implements SaslExchange { @Override public SaslStep firstStep() { - return step; + return failure(); } @Override public SaslStep onResponse(byte[] clientResponse) { - return step; + return failure(); } @Override @@ -53,5 +61,9 @@ public void abort() { @Override public void close() { } + + private SaslStep failure() { + return new SaslStep.Failure(SaslFailure.malformed("not implemented")); + } } } diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java index 48e0487606d..e3551ee400f 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java @@ -22,237 +22,221 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.Map; +import java.lang.reflect.InvocationTargetException; import java.util.Optional; import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.api.sasl.SaslMechanismFactory; -import org.apache.james.protocols.api.sasl.SaslStep; -import org.apache.james.protocols.api.sasl.TestingDefaultPackageSaslMechanism; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; +import com.google.inject.Module; class GuiceSaslMechanismResolverTest { private static final HierarchicalConfiguration EMPTY_CONFIGURATION = new BaseHierarchicalConfiguration(); @Test - void resolveShouldResolveSimpleNameFromDefaultSaslPackage() throws Exception { - // GIVEN a resolver using a test instantiator that models James default SASL package resolution - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + void resolveShouldUseEnabledDefaultFactoriesWhenNoFactoryClassIsConfigured() throws Exception { + // GIVEN an absent auth.saslMechanisms configuration and an ordered default factory list + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); - // WHEN resolving a simple class name - ImmutableList mechanisms = testee.resolve(ImmutableList.of("TestingDefaultPackageSaslMechanism"), - EMPTY_CONFIGURATION, ImmutableMap.of()); + // WHEN resolving mechanisms for this server + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("PLAIN"), factory("OAUTHBEARER")), + EMPTY_CONFIGURATION); - // THEN the mechanism is instantiated from org.apache.james.protocols.api.sasl - assertThat(mechanisms).hasOnlyElementsOfType(TestingDefaultPackageSaslMechanism.class); + // THEN defaults are used in their declared order + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("PLAIN", "OAUTHBEARER"); } @Test - void resolveShouldResolveFullyQualifiedClassName() throws Exception { - // GIVEN a resolver that also accepts extension class names - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + void resolveShouldResolveSimpleFactoryNameFromDefaultSaslPackage() throws Exception { + // GIVEN a configured built-in SASL factory simple name + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving it + ImmutableList mechanisms = testee.resolve(ImmutableList.of("TestingDefaultPackageSaslMechanismFactory"), + ImmutableList.of(), + EMPTY_CONFIGURATION); - // WHEN resolving a fully qualified class name - ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), - EMPTY_CONFIGURATION, ImmutableMap.of()); + // THEN the factory is loaded from org.apache.james.protocols.sasl + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("DEFAULT"); + } - // THEN the mechanism is instantiated without relying on the default package - assertThat(mechanisms).hasOnlyElementsOfType(ExternalFakeSaslMechanism.class); + @Test + void resolveShouldResolveFullyQualifiedFactoryName() throws Exception { + // GIVEN a configured extension factory FQCN + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving it + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + EMPTY_CONFIGURATION); + + // THEN the extension factory is loaded directly + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("EXTERNAL-FAKE"); } @Test - void resolveShouldUseFactoryBindingBeforeDirectInstantiation() throws Exception { - // GIVEN a factory binding for a configured mechanism class - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.example.realm", "example.org"); - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); - SaslMechanismFactory factory = serverConfiguration -> - new FactoryBackedSaslMechanism(serverConfiguration.getString("auth.example.realm")); - - // WHEN resolving that class name - ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), - configuration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)); - - // THEN the factory creates the server-specific mechanism instance + void resolveShouldUseConfiguredFactoriesInsteadOfDefaults() throws Exception { + // GIVEN both defaults and an explicit auth.saslMechanisms configuration + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving mechanisms + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(factory("DEFAULT")), + EMPTY_CONFIGURATION); + + // THEN the configured list replaces defaults assertThat(mechanisms) - .singleElement() - .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, - mechanism -> assertThat(mechanism.realm()).isEqualTo("example.org")); + .extracting(SaslMechanism::name) + .containsExactly("EXTERNAL-FAKE"); } @Test - void resolveShouldCreateFactoryBackedMechanismsFromCurrentServerConfiguration() throws Exception { - // GIVEN two server configurations using the same configured SASL mechanism class + void resolveShouldCreateConfiguredFactoriesFromCurrentServerConfiguration() throws Exception { + // GIVEN two server configurations using the same configured SASL factory BaseHierarchicalConfiguration firstConfiguration = new BaseHierarchicalConfiguration(); - firstConfiguration.addProperty("auth.example.realm", "first.example.org"); + firstConfiguration.addProperty("auth.example.realm", "FIRST"); BaseHierarchicalConfiguration secondConfiguration = new BaseHierarchicalConfiguration(); - secondConfiguration.addProperty("auth.example.realm", "second.example.org"); - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); - SaslMechanismFactory factory = serverConfiguration -> - new FactoryBackedSaslMechanism(serverConfiguration.getString("auth.example.realm")); - - // WHEN resolving the same configured mechanism for each server - SaslMechanism firstMechanism = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), - firstConfiguration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)) + secondConfiguration.addProperty("auth.example.realm", "SECOND"); + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving the same configured factory for each server + SaslMechanism firstMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + firstConfiguration) .getFirst(); - SaslMechanism secondMechanism = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanism.class.getCanonicalName()), - secondConfiguration, ImmutableMap.of(ExternalFakeSaslMechanism.class, factory)) + SaslMechanism secondMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + secondConfiguration) .getFirst(); // THEN each mechanism is created from that server's configuration, not from a global singleton - assertThat(firstMechanism) - .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, - mechanism -> assertThat(mechanism.realm()).isEqualTo("first.example.org")); - assertThat(secondMechanism) - .isInstanceOfSatisfying(FactoryBackedSaslMechanism.class, - mechanism -> assertThat(mechanism.realm()).isEqualTo("second.example.org")); + assertThat(firstMechanism.name()).isEqualTo("FIRST"); + assertThat(secondMechanism.name()).isEqualTo("SECOND"); } @Test void resolveShouldPreserveConfiguredOrderForDistinctMechanisms() throws Exception { - // GIVEN a configured mechanism list with distinct SASL mechanism names - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + // GIVEN several distinct SASL mechanism factories in a configured order + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); - // WHEN resolving the list - ImmutableList mechanisms = testee.resolve(ImmutableList.of( - ExternalFakeSaslMechanism.class.getCanonicalName(), - "TestingDefaultPackageSaslMechanism"), - EMPTY_CONFIGURATION, ImmutableMap.of()); + // WHEN resolving them + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("FIRST"), factory("SECOND"), factory("THIRD")), + EMPTY_CONFIGURATION); - // THEN configured order is preserved + // THEN the resolved mechanisms keep the configured order assertThat(mechanisms) .extracting(SaslMechanism::name) - .containsExactly("EXTERNAL-FAKE", "DEFAULT"); + .containsExactly("FIRST", "SECOND", "THIRD"); } @Test void resolveShouldDeduplicateMechanismNamesCaseInsensitively() throws Exception { - // GIVEN two configured classes returning the same SASL mechanism name with different case - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + // GIVEN two factories returning the same SASL mechanism name with different case + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); - // WHEN resolving both classes - ImmutableList mechanisms = testee.resolve(ImmutableList.of( - DuplicateUpperCaseSaslMechanism.class.getCanonicalName(), - DuplicateLowerCaseSaslMechanism.class.getCanonicalName()), - EMPTY_CONFIGURATION, ImmutableMap.of()); + // WHEN resolving both factories + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("DUPLICATE"), factory("duplicate")), + EMPTY_CONFIGURATION); - // THEN first occurrence wins and configured order remains stable + // THEN first occurrence wins and order remains stable assertThat(mechanisms) - .hasSize(1) - .hasOnlyElementsOfType(DuplicateUpperCaseSaslMechanism.class); + .extracting(SaslMechanism::name) + .containsExactly("DUPLICATE"); } @Test - void resolveShouldFailWhenClassDoesNotExist() { - // GIVEN a resolver used for configured SASL mechanism entries - GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new MapBackedSaslMechanismInstantiator()); + void resolveShouldFailWhenConfiguredFactoryClassDoesNotExist() { + // GIVEN a resolver used for configured SASL mechanism factory entries + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); - // WHEN resolving an unknown class name + // WHEN resolving an unknown factory class name // THEN startup wiring can fail fast with the configured entry in the error - assertThatThrownBy(() -> testee.resolve(ImmutableList.of("MissingSaslMechanism"), EMPTY_CONFIGURATION, ImmutableMap.of())) + assertThatThrownBy(() -> testee.resolve(ImmutableList.of("MissingSaslMechanismFactory"), + ImmutableList.of(), + EMPTY_CONFIGURATION)) .isInstanceOf(ConfigurationException.class) - .hasMessageContaining("MissingSaslMechanism"); + .hasMessageContaining("MissingSaslMechanismFactory"); } - private static class MapBackedSaslMechanismInstantiator implements SaslMechanismInstantiator { - private final Map> classes = ImmutableMap.>builder() - .put("TestingDefaultPackageSaslMechanism", TestingDefaultPackageSaslMechanism.class) - .put(TestingDefaultPackageSaslMechanism.class.getCanonicalName(), TestingDefaultPackageSaslMechanism.class) - .put(ExternalFakeSaslMechanism.class.getCanonicalName(), ExternalFakeSaslMechanism.class) - .put(FactoryBackedSaslMechanism.class.getCanonicalName(), FactoryBackedSaslMechanism.class) - .put(DuplicateUpperCaseSaslMechanism.class.getCanonicalName(), DuplicateUpperCaseSaslMechanism.class) - .put(DuplicateLowerCaseSaslMechanism.class.getCanonicalName(), DuplicateLowerCaseSaslMechanism.class) - .build(); + private static SaslMechanismFactory factory(String mechanismName) { + return serverConfiguration -> new FixedNameSaslMechanism(mechanismName); + } + private static class ReflectionGuiceLoader implements GuiceLoader { @Override - public Class locate(ClassName className) throws ClassNotFoundException { - return Optional.ofNullable(classes.get(className.getName())) - .orElseThrow(() -> new ClassNotFoundException(className.getName())); + public T instantiate(ClassName className) throws ClassNotFoundException { + return this.withNamingSheme(NamingScheme.IDENTITY).instantiate(className); } @Override - public SaslMechanism instantiate(ClassName className) throws ClassNotFoundException { - try { - return locate(className).getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new ClassNotFoundException(className.getName(), e); - } + public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { + return new ReflectionInvocationPerformer<>(namingSheme); } - } - - public static class FactoryBackedSaslMechanism extends FixedNameSaslMechanism { - private final String realm; - public FactoryBackedSaslMechanism() { - this("unused"); - } - - private FactoryBackedSaslMechanism(String realm) { - super("FACTORY"); - this.realm = realm; - } - - private String realm() { - return realm; - } - } - - public static class DuplicateUpperCaseSaslMechanism extends FixedNameSaslMechanism { - public DuplicateUpperCaseSaslMechanism() { - super("DUPLICATE"); - } - } - - public static class DuplicateLowerCaseSaslMechanism extends FixedNameSaslMechanism { - public DuplicateLowerCaseSaslMechanism() { - super("duplicate"); + @Override + public InvocationPerformer withChildModule(Module childModule) { + return new ReflectionInvocationPerformer<>(NamingScheme.IDENTITY); } } - private abstract static class FixedNameSaslMechanism implements SaslMechanism { - private final String name; + private static class ReflectionInvocationPerformer implements GuiceLoader.InvocationPerformer { + private final NamingScheme namingScheme; - private FixedNameSaslMechanism(String name) { - this.name = name; + private ReflectionInvocationPerformer(NamingScheme namingScheme) { + this.namingScheme = namingScheme; } @Override - public String name() { - return name; - } - - @Override - public SaslExchange start(SaslInitialRequest request) { - return new FixedStepExchange(); + public T instantiate(ClassName className) throws ClassNotFoundException { + try { + return locateClass(className).getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new ClassNotFoundException(className.getName(), e); + } } - } - private record FixedStepExchange() implements SaslExchange { @Override - public SaslStep firstStep() { - return new SaslStep.Failure("not implemented"); + public Class locateClass(ClassName className) throws ClassNotFoundException { + Optional> locatedClass = namingScheme.toFullyQualifiedClassNames(className) + .map(FullyQualifiedClassName::getName) + .map(this::tryLocateClass) + .flatMap(Optional::stream) + .findFirst(); + return locatedClass.orElseThrow(() -> new ClassNotFoundException(className.getName())); } @Override - public SaslStep onResponse(byte[] clientResponse) { - return new SaslStep.Failure("not implemented"); + public GuiceLoader.InvocationPerformer withChildModule(Module childModule) { + return new ReflectionInvocationPerformer<>(namingScheme); } @Override - public void abort() { + public GuiceLoader.InvocationPerformer withNamingSheme(NamingScheme namingSheme) { + return new ReflectionInvocationPerformer<>(namingSheme); } - @Override - public void close() { + @SuppressWarnings("unchecked") + private Optional> tryLocateClass(String className) { + try { + return Optional.of((Class) Class.forName(className)); + } catch (ClassNotFoundException e) { + return Optional.empty(); + } } } } diff --git a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java index 912d84f4593..09cf9cb534d 100644 --- a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java +++ b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceGenericLoader.java @@ -30,7 +30,6 @@ import com.google.inject.Guice; import com.google.inject.Inject; import com.google.inject.Injector; -import com.google.inject.Key; import com.google.inject.Module; import com.google.inject.util.Modules; @@ -120,11 +119,6 @@ public T instantiate(ClassName className) throws ClassNotFoundException { .instantiate(className); } - @Override - public T getInstance(Key key) { - return injector.getInstance(key); - } - @Override public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { return new InvocationPerformer<>(injector, extendedClassLoader, namingSheme); diff --git a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java index a9c3a937669..3da6906b19a 100644 --- a/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java +++ b/server/container/guice/utils/src/main/java/org/apache/james/utils/GuiceLoader.java @@ -19,7 +19,6 @@ package org.apache.james.utils; -import com.google.inject.Key; import com.google.inject.Module; public interface GuiceLoader { @@ -37,8 +36,6 @@ public interface InvocationPerformer { T instantiate(ClassName className) throws ClassNotFoundException; - T getInstance(Key key); - InvocationPerformer withNamingSheme(NamingScheme namingSheme); InvocationPerformer withChildModule(Module childModule); From ce48137520964c70402c1edb21416001773b0625 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:16:33 +0700 Subject: [PATCH 23/29] JAMES-4210 Adapt IMAP authentication to SASL-owned authentication Make IMAP LOGIN and AUTHENTICATE drive SASL exchanges, apply authenticated identities to IMAP sessions, support final server data, and keep SASL cleanup robust. --- protocols/imap/pom.xml | 4 + .../imap/processor/AbstractAuthProcessor.java | 105 +++++++++--------- .../imap/processor/AuthenticateProcessor.java | 99 +++-------------- .../imap/processor/DefaultProcessor.java | 74 +++++++++++- .../james/imap/processor/LoginProcessor.java | 39 ++++++- .../main/DefaultImapProcessorFactory.java | 70 +++++++++++- .../imap/processor/sasl/ImapSaslBridge.java | 8 +- .../processor/AuthenticateProcessorTest.java | 9 +- .../processor/sasl/ImapSaslBridgeTest.java | 27 +++-- 9 files changed, 276 insertions(+), 159 deletions(-) diff --git a/protocols/imap/pom.xml b/protocols/imap/pom.xml index 1d4d6642d07..e1ec4819fb4 100644 --- a/protocols/imap/pom.xml +++ b/protocols/imap/pom.xml @@ -91,6 +91,10 @@ ${james.protocols.groupId} protocols-api + + ${james.protocols.groupId} + protocols-sasl + com.beetstra.jutf7 jutf7 diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java index 7ce94a317f4..4de92480fa5 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java @@ -39,11 +39,13 @@ import org.apache.james.mailbox.model.MailboxConstants; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslStep; import org.apache.james.util.AuditTrail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import reactor.core.publisher.Mono; @@ -77,54 +79,6 @@ public void configure(ImapConfiguration imapConfiguration) { this.imapConfiguration = imapConfiguration; } - protected void doPasswordAuth(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) { - Preconditions.checkArgument(!authenticationAttempt.isDelegation()); - - if (authenticationAttempt.getAuthenticationId() == null || authenticationAttempt.getPassword() == null) { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), - "Malformed authentication command." - ); - } else { - try { - final MailboxSession mailboxSession = getMailboxManager().authenticate( - authenticationAttempt.getAuthenticationId(), - authenticationAttempt.getPassword() - ).withoutDelegation(); - authSuccess(session, mailboxSession, request, responder, "Password authentication succeeded."); - } catch (BadCredentialsException e) { - authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, - Optional.of(authenticationAttempt.getAuthenticationId()), - Optional.empty(), - "Password authentication failed because of bad credentials." - ); - } catch (MailboxException e) { - // This is probably not a user error, so we do not increase the failure count or add the - // event to the audit log. - LOGGER.error("Authentication failed", e); - no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); - } - } - } - - protected void doPasswordAuthWithDelegation(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) { - Preconditions.checkArgument(authenticationAttempt.isDelegation()); - Username otherUser = authenticationAttempt.getDelegateUserName().orElseThrow(); - - Username givenUser = authenticationAttempt.getAuthenticationId(); - if (givenUser == null) { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, - Optional.empty(), Optional.of(otherUser), "Malformed authentication command."); - } else { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(givenUser, authenticationAttempt.getPassword()) - .as(otherUser), - session, - request, responder, - givenUser, otherUser); - } - } - protected Authorizator withAdminUsers() { return (userId, otherUserId) -> { if (imapConfiguration.getAdminUsers().contains(userId.asString())) { @@ -154,6 +108,50 @@ protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mai } } + protected void handleSaslStep(SaslStep step, ImapSession session, ImapRequest request, Responder responder, String successLog) { + switch (step) { + case SaslStep.Success success -> handleSaslSuccess(success, session, request, responder, successLog); + case SaslStep.Failure failure -> handleSaslFailure(failure.failure(), session, request, responder); + case SaslStep.Challenge ignored -> throw new IllegalStateException("Challenge SASL step cannot be applied as authentication result"); + } + } + + protected void handleSaslSuccess(SaslStep.Success success, ImapSession session, ImapRequest request, Responder responder, String successLog) { + SaslIdentity identity = success.identity(); + if (!identity.authenticationId().equals(identity.authorizationId())) { + doAuthWithDelegation(() -> getMailboxManager() + .withExtraAuthorizator(withAdminUsers()) + .authenticate(identity.authenticationId()) + .as(identity.authorizationId()), + session, request, responder, identity.authenticationId(), identity.authorizationId()); + return; + } + + authSuccess(session, getMailboxManager().createSystemSession(identity.authenticationId()), request, responder, successLog); + } + + protected void handleSaslFailure(SaslFailure failure, ImapSession session, ImapRequest request, Responder responder) { + switch (failure.type()) { + case MALFORMED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case AUTHENTICATION_FAILED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case INVALID_CREDENTIALS -> authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case USER_DOES_NOT_EXIST -> authFailure(session, request, responder, HumanReadableText.USER_DOES_NOT_EXIST, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case DELEGATION_FORBIDDEN -> authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case SERVER_ERROR -> { + failure.cause() + .ifPresentOrElse( + cause -> LOGGER.error("Authentication failed: {}", failure.reason(), cause), + () -> LOGGER.error("Authentication failed: {}", failure.reason())); + no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); + } + } + } + protected void provisionInbox(ImapSession session, MailboxManager mailboxManager, MailboxSession mailboxSession) throws MailboxException { MailboxPath inboxPath = pathConverterFactory.forSession(session).buildFullPath(MailboxConstants.INBOX); if (Mono.from(mailboxManager.mailboxExists(inboxPath, mailboxSession)).block()) { @@ -212,7 +210,7 @@ protected static AuthenticationAttempt noDelegation(Username authenticationId, S return new AuthenticationAttempt(Optional.empty(), authenticationId, password); } - protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String log) { + protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String successLog) { session.authenticated(); session.setMailboxSession(mailboxSession); try { @@ -231,12 +229,13 @@ protected void authSuccess(ImapSession session, MailboxSession mailboxSession, I if (assumedUser.isPresent()) { entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString())); } - entry.log(log); + entry.log(successLog); okComplete(request, responder); session.stopDetectingCommandInjection(); } - protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username, Optional assumedUser, String log) { + protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username, + Optional assumedUser, String failureReason) { AuditTrail.Entry entry = AuditTrail.entry() .username(() -> username.map(name -> name.asString()).orElse(null)) .sessionId(() -> session.sessionId().asString()) @@ -246,7 +245,7 @@ protected void authFailure(ImapSession session, ImapRequest request, Responder r if (assumedUser.isPresent()) { entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString())); } - entry.log(log); + entry.log(failureReason); manageFailureCount(session, request, responder, failed); } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java index a6ef354a95e..8a9d87fbd30 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java @@ -35,18 +35,15 @@ import org.apache.james.imap.message.request.IRAuthenticateRequest; import org.apache.james.imap.message.response.AuthenticateResponse; import org.apache.james.imap.processor.sasl.ImapSaslBridge; -import org.apache.james.jwt.OidcJwtTokenVerifier; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.sasl.OauthBearerSaslMechanism; -import org.apache.james.protocols.api.sasl.PlainSaslMechanism; -import org.apache.james.protocols.api.sasl.SaslCredentials; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; import org.apache.james.protocols.api.sasl.SaslStep; -import org.apache.james.protocols.api.sasl.XOauth2SaslMechanism; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; import org.apache.james.util.MDCBuilder; import org.apache.james.util.ReactorUtils; import org.slf4j.Logger; @@ -64,20 +61,19 @@ public class AuthenticateProcessor extends AbstractAuthProcessor DEFAULT_SASL_MECHANISMS = ImmutableList.of( - new PlainSaslMechanism(), - new OauthBearerSaslMechanism(), - new XOauth2SaslMechanism()); private final ImapSaslBridge saslBridge; + private final JamesSaslAuthenticator jamesSaslAuthenticator; private ImmutableList saslMechanisms; @Inject public AuthenticateProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, - MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { + MetricFactory metricFactory, PathConverter.Factory pathConverterFactory, + JamesSaslAuthenticator jamesSaslAuthenticator) { super(AuthenticateRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); this.saslBridge = new ImapSaslBridge(); - this.saslMechanisms = DEFAULT_SASL_MECHANISMS; + this.jamesSaslAuthenticator = jamesSaslAuthenticator; + this.saslMechanisms = ImmutableList.of(); } @Override @@ -102,7 +98,8 @@ protected void processRequest(AuthenticateRequest request, ImapSession session, try { SaslInitialRequest initialRequest = saslBridge.initialRequest(request.getAuthType(), initialClientResponse(request)); - SaslExchange exchange = mechanism.get().start(initialRequest); + SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers()); + SaslExchange exchange = mechanism.get().start(initialRequest, authenticator); handleFirstStep(exchange, firstStep(exchange), session, request, responder); } catch (IllegalArgumentException e) { LOGGER.info("Invalid syntax in AUTHENTICATE initial client response", e); @@ -158,23 +155,16 @@ private Optional findMechanism(String mechanismName) { } private boolean isAvailable(SaslMechanism mechanism, ImapSession session) { - if (PlainSaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { - return !session.isPlainAuthDisallowed(); - } - if (OauthBearerSaslMechanism.NAME.equalsIgnoreCase(mechanism.name()) || XOauth2SaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { - return session.supportsOAuth(); - } - return true; + return mechanism.isAvailableOnTransport(session.isTLSActive()); } private void rejectUnavailable(AuthenticateRequest request, Responder responder, SaslMechanism mechanism) { - if (PlainSaslMechanism.NAME.equalsIgnoreCase(mechanism.name())) { - LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel"); + LOGGER.warn("{} authentication rejected because it is not allowed over current transport", mechanism.name()); + if (SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) { no(request, responder, HumanReadableText.DISABLED_LOGIN); - } else { - LOGGER.warn("{} authentication rejected because it is disabled", mechanism.name()); - no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); + return; } + no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); } private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { @@ -358,69 +348,12 @@ private void handleSuccessDataAcknowledgement(SaslExchange exchange, SaslStep.Su private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { try { - if (step instanceof SaslStep.Credentials credentials) { - handleCredentials(credentials.credentials(), session, request, responder); - } else if (step instanceof SaslStep.Success success) { - handleSuccess(session, request, responder, success.identity()); - } else if (step instanceof SaslStep.Failure failure) { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), failure.reason()); - } + handleSaslStep(step, session, request, responder, successLog(request)); } finally { saslBridge.close(exchange); } } - private void handleCredentials(SaslCredentials credentials, ImapSession session, AuthenticateRequest request, Responder responder) { - if (credentials instanceof SaslCredentials.Password password) { - handlePasswordCredentials(password, session, request, responder); - return; - } - if (credentials instanceof SaslCredentials.BearerToken bearerToken) { - handleBearerTokenCredentials(bearerToken, session, request, responder); - } - } - - private void handlePasswordCredentials(SaslCredentials.Password password, ImapSession session, AuthenticateRequest request, Responder responder) { - AuthenticationAttempt authenticationAttempt = new AuthenticationAttempt(password.authorizationId(), password.authenticationId(), password.password()); - if (authenticationAttempt.isDelegation()) { - doPasswordAuthWithDelegation(authenticationAttempt, session, request, responder); - } else { - doPasswordAuth(authenticationAttempt, session, request, responder); - } - } - - private void handleBearerTokenCredentials(SaslCredentials.BearerToken bearerToken, ImapSession session, AuthenticateRequest request, Responder responder) { - session.oidcSaslConfiguration() - .ifPresentOrElse(configuration -> new OidcJwtTokenVerifier(configuration).validateToken(bearerToken.token()) - .ifPresentOrElse(authenticatedUser -> { - if (!bearerToken.authorizationId().equals(authenticatedUser)) { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(authenticatedUser) - .as(bearerToken.authorizationId()), - session, request, responder, authenticatedUser, bearerToken.authorizationId()); - } else { - authSuccess(session, getMailboxManager().createSystemSession(authenticatedUser), request, responder, - "OAuth authentication succeeded."); - } - }, () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.of(bearerToken.authorizationId()), "OAuth authentication failed.")), - () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.of(bearerToken.authorizationId()), "OAuth authentication failed.")); - } - - private void handleSuccess(ImapSession session, AuthenticateRequest request, Responder responder, SaslIdentity identity) { - if (!identity.authenticationId().equals(identity.authorizationId())) { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(identity.authenticationId()) - .as(identity.authorizationId()), - session, request, responder, identity.authenticationId(), identity.authorizationId()); - return; - } - authSuccess(session, getMailboxManager().createSystemSession(identity.authenticationId()), request, responder, successLog(request)); - } - private String successLog(AuthenticateRequest request) { String authType = request.getAuthType().toUpperCase(Locale.US); return authType + " authentication succeeded."; diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java index 794954c85d9..7e0d49d820c 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java @@ -20,6 +20,7 @@ package org.apache.james.imap.processor; import java.util.Map; +import java.util.Optional; import java.util.stream.Stream; import org.apache.commons.lang3.tuple.Pair; @@ -34,12 +35,20 @@ import org.apache.james.imap.processor.base.AbstractProcessor; import org.apache.james.imap.processor.base.ImapResponseMessageProcessor; import org.apache.james.imap.processor.fetch.FetchProcessor; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.MailboxCounterCorrector; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.exception.BadCredentialsException; +import org.apache.james.mailbox.exception.ForbiddenDelegationException; +import org.apache.james.mailbox.exception.UserDoesNotExistException; import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -59,16 +68,56 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess MailboxCounterCorrector mailboxCounterCorrector, MetricFactory metricFactory, FetchProcessor.LocalCacheConfiguration localCacheConfiguration) { + return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager, + statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector, + metricFactory, localCacheConfiguration, ImmutableList.of(new PlainSaslMechanism())); + } + + public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor, + MailboxManager mailboxManager, + EventBus eventBus, + SubscriptionManager subscriptionManager, + StatusResponseFactory statusResponseFactory, + MailboxTyper mailboxTyper, + QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, + MailboxCounterCorrector mailboxCounterCorrector, + MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + ImmutableList defaultSaslMechanisms) { + return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager, + statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector, + metricFactory, localCacheConfiguration, defaultSaslMechanisms, jamesSaslAuthenticator(mailboxManager)); + } + + public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor, + MailboxManager mailboxManager, + EventBus eventBus, + SubscriptionManager subscriptionManager, + StatusResponseFactory statusResponseFactory, + MailboxTyper mailboxTyper, + QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, + MailboxCounterCorrector mailboxCounterCorrector, + MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + ImmutableList defaultSaslMechanisms, + JamesSaslAuthenticator saslAuthenticator) { PathConverter.Factory pathConverterFactory = PathConverter.Factory.DEFAULT; ImmutableList.Builder builder = ImmutableList.builder(); CapabilityProcessor capabilityProcessor = new CapabilityProcessor(mailboxManager, statusResponseFactory, metricFactory); + LoginProcessor loginProcessor = new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator); + loginProcessor.configureSaslMechanisms(defaultSaslMechanisms); + AuthenticateProcessor authenticateProcessor = new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator); + authenticateProcessor.configureSaslMechanisms(defaultSaslMechanisms); + builder.add(new SystemMessageProcessor()); builder.add(new LogoutProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(capabilityProcessor); builder.add(new IdProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(new CheckProcessor(mailboxManager, statusResponseFactory, metricFactory)); - builder.add(new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); + builder.add(loginProcessor); builder.add(new RenameProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new DeleteProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new CreateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); @@ -76,7 +125,7 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess builder.add(new UnsubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new SubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new CopyProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); - builder.add(new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); + builder.add(authenticateProcessor); builder.add(new ExpungeProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(new ReplaceProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new ExamineProcessor(mailboxManager, eventBus, statusResponseFactory, metricFactory, pathConverterFactory, mailboxCounterCorrector)); @@ -136,6 +185,27 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess return new DefaultProcessor(processorMap, chainEndProcessor); } + private 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 static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java index c7c2a845b33..2f2ba5d168d 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.List; +import java.util.Optional; import jakarta.inject.Inject; @@ -32,6 +33,11 @@ import org.apache.james.imap.message.request.LoginRequest; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import org.apache.james.util.MDCBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,9 +51,15 @@ public class LoginProcessor extends AbstractAuthProcessor implemen private static final List LOGINDISABLED_CAPS = ImmutableList.of(Capability.of("LOGINDISABLED")); private static final Logger LOGGER = LoggerFactory.getLogger(LoginProcessor.class); + private final JamesSaslAuthenticator jamesSaslAuthenticator; + private Optional plainSaslMechanism; + @Inject - public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { + public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory, + JamesSaslAuthenticator jamesSaslAuthenticator) { super(LoginRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); + this.jamesSaslAuthenticator = jamesSaslAuthenticator; + this.plainSaslMechanism = Optional.empty(); } /** @@ -55,12 +67,16 @@ public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory facto */ @Override protected void processRequest(LoginRequest request, ImapSession session, Responder responder) { + Optional plainSaslMechanism = availablePlainSaslMechanism(session); + // check if the login is allowed with LOGIN command. See IMAP-304 - if (session.isPlainAuthDisallowed()) { - LOGGER.warn("Login rejected because it is disabled or not allowed over insecure channel"); + if (plainSaslMechanism.isEmpty()) { + LOGGER.warn("Login rejected because PLAIN SASL mechanism is disabled"); no(request, responder, HumanReadableText.DISABLED_LOGIN); } else { - doPasswordAuth(noDelegation(request.getUserid(), request.getPassword()), session, request, responder); + SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers()); + handleSaslStep(plainSaslMechanism.orElseThrow().authenticate(request.getUserid(), request.getPassword(), authenticator), + session, request, responder, "Password authentication succeeded."); } } @@ -68,12 +84,25 @@ protected void processRequest(LoginRequest request, ImapSession session, Respond public List getImplementedCapabilities(ImapSession session) { // Announce LOGINDISABLED if plain auth / login is deactivated and the session is not using // TLS. See IMAP-304 - if (session.isPlainAuthDisallowed()) { + if (availablePlainSaslMechanism(session).isEmpty()) { return LOGINDISABLED_CAPS; } return Collections.emptyList(); } + public void configureSaslMechanisms(ImmutableList saslMechanisms) { + this.plainSaslMechanism = saslMechanisms.stream() + .filter(mechanism -> SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) + .filter(PlainSaslMechanism.class::isInstance) + .map(PlainSaslMechanism.class::cast) + .findFirst(); + } + + private Optional availablePlainSaslMechanism(ImapSession session) { + return plainSaslMechanism + .filter(mechanism -> mechanism.isAvailableOnTransport(session.isTLSActive())); + } + @Override protected MDCBuilder mdc(LoginRequest request) { return MDCBuilder.create() diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java index 31415b99cf8..6edd5724db2 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java @@ -19,6 +19,9 @@ package org.apache.james.imap.processor.main; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.events.EventBus; import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.DefaultMailboxTyper; @@ -34,6 +37,17 @@ import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; public class DefaultImapProcessorFactory { @@ -59,6 +73,7 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo } public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + JamesSaslAuthenticator saslAuthenticator, EventBus eventBus, SubscriptionManager subscriptionManager, MailboxTyper mailboxTyper, QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, MetricFactory metricFactory) { @@ -69,7 +84,60 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, MailboxCounterCorrector.DEFAULT, metricFactory, - FetchProcessor.LocalCacheConfiguration.DEFAULT); + FetchProcessor.LocalCacheConfiguration.DEFAULT, ImmutableList.of(new PlainSaslMechanism()), saslAuthenticator); + } + + public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + EventBus eventBus, SubscriptionManager subscriptionManager, + MailboxTyper mailboxTyper, QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + + StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory(); + UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory); + + return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, + eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, + MailboxCounterCorrector.DEFAULT, metricFactory, + localCacheConfiguration, defaultSaslMechanisms(serverConfiguration)); + } + + public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + EventBus eventBus, SubscriptionManager subscriptionManager, + MailboxTyper mailboxTyper, QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + HierarchicalConfiguration serverConfiguration, + JamesSaslAuthenticator saslAuthenticator) throws ConfigurationException { + + StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory(); + UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory); + + return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, + eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, + MailboxCounterCorrector.DEFAULT, metricFactory, + localCacheConfiguration, defaultSaslMechanisms(serverConfiguration), saslAuthenticator); + } + + private static ImmutableList defaultSaslMechanisms(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + ImmutableList factories = BuiltInSaslMechanismFactories.enabledForServer( + ImmutableList.of( + new PlainSaslMechanismFactory(), + new OauthBearerSaslMechanismFactory(), + new XOauth2SaslMechanismFactory()), + serverConfiguration); + + try { + return factories.stream() + .map(Throwing.function(factory -> factory.create(serverConfiguration))) + .collect(ImmutableList.toImmutableList()); + } catch (RuntimeException e) { + if (e.getCause() instanceof ConfigurationException configurationException) { + throw configurationException; + } + throw e; + } } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java index ec6962917c3..7e5a8217fe9 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java @@ -73,14 +73,10 @@ public boolean isEmptyClientResponse(byte[] line) { } /** - * Aborts and closes an active SASL exchange. + * Aborts an active SASL exchange. */ public void abort(SaslExchange exchange) { - try { - exchange.abort(); - } finally { - exchange.close(); - } + exchange.abort(); } /** diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java index 1bd1634d7a3..1d66db34375 100644 --- a/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java @@ -36,13 +36,17 @@ import org.apache.james.imap.main.PathConverter; import org.apache.james.imap.message.request.AuthenticateRequest; import org.apache.james.imap.message.response.UnpooledStatusResponseFactory; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; import org.apache.james.protocols.api.sasl.SaslExchange; import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; @@ -67,7 +71,7 @@ public String name() { } @Override - public SaslExchange start(SaslInitialRequest request) { + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { return exchange; } } @@ -176,7 +180,8 @@ public void close() { mock(MailboxManager.class), new UnpooledStatusResponseFactory(), new RecordingMetricFactory(), - PathConverter.Factory.DEFAULT); + PathConverter.Factory.DEFAULT, + new JamesSaslAuthenticator(mock(Authenticator.class), mock(Authorizator.class))); @Test void processRequestShouldCloseExchangeWhenFirstStepThrows() { diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java index d80469be8a2..63053ee11c3 100644 --- a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java @@ -42,7 +42,7 @@ class ImapSaslBridgeTest { private final ImapSaslBridge testee = new ImapSaslBridge(); private static class RecordingExchange implements SaslExchange { - private final List lifecycleEvents; + protected final List lifecycleEvents; private byte[] lastClientResponse; private RecordingExchange() { @@ -61,13 +61,16 @@ public SaslStep onResponse(byte[] clientResponse) { } @Override - public void abort() { - lifecycleEvents.add("abort"); + public void close() { + lifecycleEvents.add("close"); } + } + private static class RecordingAbortExchange extends RecordingExchange { @Override - public void close() { - lifecycleEvents.add("close"); + public void abort() { + lifecycleEvents.add("abort"); + close(); } } @@ -91,6 +94,7 @@ public SaslStep onResponse(byte[] clientResponse) { @Override public void abort() { lifecycleEvents.add("abort"); + close(); throw new IllegalStateException("boom"); } @@ -194,16 +198,25 @@ void isAbortShouldRejectRegularClientResponse() { } @Test - void abortShouldAbortThenCloseExchange() { + void abortShouldCloseExchangeByDefault() { RecordingExchange exchange = new RecordingExchange(); testee.abort(exchange); + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + @Test + void abortShouldUseExchangeSpecificAbortWhenOverridden() { + RecordingAbortExchange exchange = new RecordingAbortExchange(); + + testee.abort(exchange); + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); } @Test - void abortShouldCloseExchangeWhenAbortThrows() { + void abortShouldPropagateExchangeSpecificAbortFailure() { ThrowingAbortExchange exchange = new ThrowingAbortExchange(); assertThatThrownBy(() -> testee.abort(exchange)) From e580849f74e96eadc16c8be7ae8c7da8f6b59bfa Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:16:51 +0700 Subject: [PATCH 24/29] JAMES-4210 Wire per-server IMAP SASL mechanism configuration Build each Guice IMAP suite with server-specific SASL mechanisms and defaults, while keeping capability and enable processors scoped to the same suite. --- server/container/guice/protocols/imap/pom.xml | 4 ++ .../modules/protocols/IMAPServerModule.java | 71 ++++++++----------- .../ImapDefaultSaslMechanismFactories.java | 31 ++++++++ .../protocols/IMAPServerModuleTest.java | 71 ++++++++++--------- 4 files changed, 101 insertions(+), 76 deletions(-) create mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java diff --git a/server/container/guice/protocols/imap/pom.xml b/server/container/guice/protocols/imap/pom.xml index 11a22740b50..dc99029ba77 100644 --- a/server/container/guice/protocols/imap/pom.xml +++ b/server/container/guice/protocols/imap/pom.xml @@ -53,6 +53,10 @@ testing-base test + + ${james.protocols.groupId} + protocols-sasl + com.google.inject guice diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 45e9baeb0fe..5733cea9cd6 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -19,7 +19,6 @@ package org.apache.james.modules.protocols; import java.util.Arrays; -import java.util.Map; import java.util.Optional; import java.util.function.Function; import java.util.stream.Stream; @@ -62,6 +61,7 @@ import org.apache.james.imap.processor.CapabilityProcessor; import org.apache.james.imap.processor.DefaultProcessor; import org.apache.james.imap.processor.EnableProcessor; +import org.apache.james.imap.processor.LoginProcessor; import org.apache.james.imap.processor.NamespaceSupplier; import org.apache.james.imap.processor.PermitEnableCapabilityProcessor; import org.apache.james.imap.processor.SelectProcessor; @@ -74,11 +74,14 @@ import org.apache.james.lifecycle.api.ConfigurationSanitizer; import org.apache.james.metrics.api.GaugeRegistry; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.modules.SaslMechanismFactories; import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.api.sasl.SaslMechanismFactory; import org.apache.james.protocols.lib.netty.CertificateReloadable; import org.apache.james.protocols.netty.Encryption; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.utils.ClassName; import org.apache.james.utils.GuiceLoader; @@ -93,20 +96,13 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.inject.AbstractModule; -import com.google.inject.Key; -import com.google.inject.Provider; import com.google.inject.Provides; import com.google.inject.Scopes; import com.google.inject.Singleton; -import com.google.inject.TypeLiteral; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; public class IMAPServerModule extends AbstractModule { - private static final Key, Provider>> SASL_MECHANISM_FACTORY_PROVIDERS = - Key.get(new TypeLiteral<>() { - }, SaslMechanismFactories.class); - private static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); @@ -125,7 +121,6 @@ protected void configure() { bind(EnableProcessor.class); bind(SelectProcessor.class).in(Scopes.SINGLETON); bind(StatusProcessor.class).in(Scopes.SINGLETON); - bind(DefaultImapSaslMechanismClassNamesProvider.class).to(JamesDefaultImapSaslMechanismClassNamesProvider.class); bind(NamespaceSupplier.class).to(NamespaceSupplier.Default.class).in(Scopes.SINGLETON); bind(PathConverter.Factory.class).to(PathConverter.Factory.Default.class).in(Scopes.SINGLETON); bind(MailboxTyper.class).to(DefaultMailboxTyper.class).in(Scopes.SINGLETON); @@ -146,34 +141,25 @@ protected void configure() { IMAPServerFactory provideServerFactory(FileSystem fileSystem, GuiceLoader guiceLoader, GuiceSaslMechanismResolver saslMechanismResolver, - DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, + @ImapDefaultSaslMechanismFactories ImmutableList defaultSaslMechanismFactories, StatusResponseFactory statusResponseFactory, MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory, Encryption.Factory encryptionFactory) { - Map, SaslMechanismFactory> saslMechanismFactories = retrieveSaslMechanismFactories(guiceLoader); IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismResolver, - saslMechanismFactories, defaultImapSaslMechanismClassNamesProvider, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); + defaultSaslMechanismFactories, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); factory.setEncryptionFactory(encryptionFactory); return factory; } - private Map, SaslMechanismFactory> retrieveSaslMechanismFactories(GuiceLoader guiceLoader) { - return retrieveSaslMechanismFactoryProviders(guiceLoader) - .entrySet() - .stream() - .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, entry -> entry.getValue().get())); - } - - private Map, Provider> retrieveSaslMechanismFactoryProviders(GuiceLoader guiceLoader) { - try { - // Optional extension point: custom modules can contribute SASL mechanism factories through this annotated map. - return guiceLoader.getInstance(SASL_MECHANISM_FACTORY_PROVIDERS); - } catch (com.google.inject.ConfigurationException e) { - // No custom factory map was bound; resolver will fall back to direct SASL mechanism instantiation. - return ImmutableMap.of(); - } + @Provides + @Singleton + @ImapDefaultSaslMechanismFactories + ImmutableList provideDefaultImapSaslMechanismFactories(PlainSaslMechanismFactory plain, + OauthBearerSaslMechanismFactory oauthBearer, + XOauth2SaslMechanismFactory xoauth2) { + return ImmutableList.of(plain, oauthBearer, xoauth2); } DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, @@ -210,6 +196,9 @@ private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, I if (processor instanceof AuthenticateProcessor authenticateProcessor) { authenticateProcessor.configureSaslMechanisms(saslMechanisms); } + if (processor instanceof LoginProcessor loginProcessor) { + loginProcessor.configureSaslMechanisms(saslMechanisms); + } return processor; } @@ -231,39 +220,37 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig } private ImmutableList retrieveSaslMechanisms(GuiceSaslMechanismResolver saslMechanismResolver, - Map, SaslMechanismFactory> saslMechanismFactories, - DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, + ImmutableList defaultSaslMechanismFactories, HierarchicalConfiguration configuration) throws ConfigurationException { - ImmutableList mechanismClassNames = retrieveSaslMechanismClassNames(configuration, defaultImapSaslMechanismClassNamesProvider); - return saslMechanismResolver.resolve(mechanismClassNames, configuration, saslMechanismFactories); + ImmutableList mechanismFactoryClassNames = retrieveSaslMechanismFactoryClassNames(configuration); + ImmutableList enabledDefaultFactories = + BuiltInSaslMechanismFactories.enabledForServer(defaultSaslMechanismFactories, configuration); + return saslMechanismResolver.resolve(mechanismFactoryClassNames, enabledDefaultFactories, configuration); } - ImmutableList retrieveSaslMechanismClassNames(HierarchicalConfiguration configuration, - DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider) throws ConfigurationException { + ImmutableList retrieveSaslMechanismFactoryClassNames(HierarchicalConfiguration configuration) throws ConfigurationException { if (!configuration.containsKey("auth.saslMechanisms")) { - return defaultImapSaslMechanismClassNamesProvider.resolve(configuration); + return ImmutableList.of(); } - ImmutableList mechanismClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms")) + ImmutableList mechanismFactoryClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms")) .flatMap(value -> Arrays.stream(value.split(","))) .map(String::trim) .collect(ImmutableList.toImmutableList()); - if (mechanismClassNames.isEmpty() || mechanismClassNames.stream().anyMatch(StringUtils::isBlank)) { + if (mechanismFactoryClassNames.isEmpty() || mechanismFactoryClassNames.stream().anyMatch(StringUtils::isBlank)) { throw new ConfigurationException("auth.saslMechanisms must not be blank when configured"); } - return mechanismClassNames; + return mechanismFactoryClassNames; } private ThrowingFunction, ImapSuite> imapSuiteLoader(GuiceLoader guiceLoader, GuiceSaslMechanismResolver saslMechanismResolver, - Map, SaslMechanismFactory> saslMechanismFactories, - DefaultImapSaslMechanismClassNamesProvider defaultImapSaslMechanismClassNamesProvider, + ImmutableList defaultSaslMechanismFactories, StatusResponseFactory statusResponseFactory) { return configuration -> { ImapPackage imapPackage = retrievePackages(guiceLoader, configuration); - ImmutableList saslMechanisms = retrieveSaslMechanisms(saslMechanismResolver, saslMechanismFactories, - defaultImapSaslMechanismClassNamesProvider, configuration); + ImmutableList saslMechanisms = retrieveSaslMechanisms(saslMechanismResolver, defaultSaslMechanismFactories, configuration); DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory); ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader); diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java new file mode 100644 index 00000000000..6cac7200fd6 --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import com.google.inject.BindingAnnotation; + +@BindingAnnotation +@Retention(RUNTIME) +public @interface ImapDefaultSaslMechanismFactories { +} diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java index 1b18bf76695..611ec5ceea3 100644 --- a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java +++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java @@ -24,80 +24,83 @@ import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; class IMAPServerModuleTest { - private static final JamesDefaultImapSaslMechanismClassNamesProvider JAMES_DEFAULT_PROVIDER = new JamesDefaultImapSaslMechanismClassNamesProvider(); - private final IMAPServerModule testee = new IMAPServerModule(); @Test - void retrieveSaslMechanismClassNamesShouldReturnDefaultsWhenAbsent() throws Exception { + void provideDefaultImapSaslMechanismFactoriesShouldReturnJamesDefaults() { // GIVEN no auth.saslMechanisms configuration - BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - - // WHEN IMAP resolves its SASL mechanism class names - ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER); - - // THEN existing James IMAP defaults are preserved - assertThat(mechanismClassNames) - .containsExactly("PlainSaslMechanism", "OauthBearerSaslMechanism", "XOauth2SaslMechanism"); + // WHEN IMAP provides its default SASL factories + + // THEN existing James IMAP defaults are preserved in order + assertThat(testee.provideDefaultImapSaslMechanismFactories( + new PlainSaslMechanismFactory(), + new OauthBearerSaslMechanismFactory(), + new XOauth2SaslMechanismFactory())) + .map(factory -> factory.getClass().getSimpleName()) + .containsExactly( + PlainSaslMechanismFactory.class.getSimpleName(), + OauthBearerSaslMechanismFactory.class.getSimpleName(), + XOauth2SaslMechanismFactory.class.getSimpleName()); } @Test - void retrieveSaslMechanismClassNamesShouldUseConfiguredDefaultProviderOverJamesDefaultProviderWhenAbsent() throws Exception { - // GIVEN a non-James default provider configured by Guice. - // This allows community custom IMAP packages with custom authentication to provide - // their own default SASL list and avoid breaking changes when auth.saslMechanisms is absent. + void retrieveSaslMechanismFactoryClassNamesShouldReturnEmptyWhenAbsent() throws Exception { + // GIVEN no auth.saslMechanisms configuration. + // The empty configured list lets the resolver use the Guice-provided default factory list. + // Community custom IMAP packages can override that default factory list to avoid breaking changes. BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - DefaultImapSaslMechanismClassNamesProvider communityDefaultProvider = ignored -> ImmutableList.of("com.example.CustomSaslMechanism"); // WHEN auth.saslMechanisms is absent - ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, communityDefaultProvider); + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); - // THEN IMAP uses the configured community default provider instead of James default mechanisms - assertThat(mechanismClassNames) - .containsExactly("com.example.CustomSaslMechanism"); + // THEN there is no configured override + assertThat(mechanismFactoryClassNames).isEmpty(); } @Test - void retrieveSaslMechanismClassNamesShouldReturnConfiguredSaslMechanismList() throws Exception { - // GIVEN an explicit server-specific SASL mechanism list + void retrieveSaslMechanismFactoryClassNamesShouldReturnConfiguredSaslFactoryList() throws Exception { + // GIVEN an explicit server-specific SASL factory list BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.saslMechanisms", - "PlainSaslMechanism,com.example.CustomSaslMechanism,PlainSaslMechanism"); + "PlainSaslMechanismFactory,com.example.CustomSaslMechanismFactory,PlainSaslMechanismFactory"); - // WHEN IMAP resolves configured class names - ImmutableList mechanismClassNames = testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER); + // WHEN IMAP resolves configured factory class names + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); // THEN the exact configured order is passed to the resolver - assertThat(mechanismClassNames) - .containsExactly("PlainSaslMechanism", "com.example.CustomSaslMechanism", "PlainSaslMechanism"); + assertThat(mechanismFactoryClassNames) + .containsExactly("PlainSaslMechanismFactory", "com.example.CustomSaslMechanismFactory", "PlainSaslMechanismFactory"); } @Test - void retrieveSaslMechanismClassNamesShouldRejectBlankConfiguredList() { + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankConfiguredList() { // GIVEN auth.saslMechanisms is present but blank BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("auth.saslMechanisms", " "); - // WHEN resolving class names + // WHEN resolving factory class names // THEN startup fails instead of silently disabling all mechanisms - assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) .isInstanceOf(ConfigurationException.class); } @Test - void retrieveSaslMechanismClassNamesShouldRejectBlankEntry() { + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankEntry() { // GIVEN auth.saslMechanisms contains a blank entry BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); - configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanism,,XOauth2SaslMechanism"); + configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanismFactory,,XOauth2SaslMechanismFactory"); - // WHEN resolving class names + // WHEN resolving factory class names // THEN startup fails with an invalid configured list - assertThatThrownBy(() -> testee.retrieveSaslMechanismClassNames(configuration, JAMES_DEFAULT_PROVIDER)) + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) .isInstanceOf(ConfigurationException.class); } } From 6111259841dde70a96c12a959c70c16c410081bf Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:17:14 +0700 Subject: [PATCH 25/29] JAMES-4210 Remove IMAP authentication configuration from Netty session wiring Stop carrying OIDC/authentication configuration in IMAP server/session objects now that SASL factories own mechanism configuration. Keep Spring IMAP startup compatible with static defaults. --- .../james/imap/api/process/ImapSession.java | 24 ------- .../james/imap/encode/FakeImapSession.java | 21 ------ .../james/imapserver/netty/IMAPServer.java | 64 ------------------- .../netty/ImapChannelUpstreamHandler.java | 18 +----- .../imapserver/netty/NettyImapSession.java | 32 +--------- .../META-INF/spring/imapserver-context.xml | 20 +++--- .../netty/AbstractIMAPServerTest.java | 3 +- 7 files changed, 18 insertions(+), 164 deletions(-) diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java index ebdce2fdbaf..791e8ce69a5 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java @@ -31,7 +31,6 @@ import org.apache.commons.text.RandomStringGenerator; import org.apache.james.core.Username; import org.apache.james.imap.api.ImapSessionState; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.protocols.api.CommandDetectionSession; import org.apache.james.util.MDCBuilder; @@ -247,19 +246,6 @@ default boolean backpressureNeeded(Runnable restoreBackpressure) { */ void popLineHandler(); - /** - * Return true if SSL is required when Authenticating - */ - boolean isSSLRequired(); - - /** - * Return true if the login / authentication via plain username / password is - * enabled - */ - boolean isPlainAuthEnabled(); - - boolean supportsOAuth(); - default void withMDC(Runnable runnable) { try (Closeable c = mdc().build()) { runnable.run(); @@ -280,8 +266,6 @@ default MDCBuilder mdc() { */ InetSocketAddress getRemoteAddress(); - Optional oidcSaslConfiguration(); - default void setMailboxSession(MailboxSession mailboxSession) { setAttribute(MAILBOX_SESSION_ATTRIBUTE_SESSION_KEY, mailboxSession); } @@ -296,14 +280,6 @@ default Username getUserName() { .orElse(null); } - default boolean isPlainAuthDisallowed() { - return !isPlainAuthEnabled() || isAuthenticatingNonEncryptedWhenRequiredSSL(); - } - - default boolean isAuthenticatingNonEncryptedWhenRequiredSSL() { - return isSSLRequired() && !isTLSActive(); - } - void schedule(Runnable runnable, Duration waitDelay); } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java index d73af074e1b..cd7ad586589 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java @@ -34,7 +34,6 @@ import org.apache.james.imap.api.process.ImapLineHandler; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.api.process.SelectedMailbox; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.util.concurrent.NamedThreadFactory; import reactor.core.publisher.Mono; @@ -166,31 +165,11 @@ public void popLineHandler() { } - @Override - public boolean isSSLRequired() { - return false; - } - - @Override - public boolean isPlainAuthEnabled() { - return true; - } - - @Override - public boolean supportsOAuth() { - return false; - } - @Override public InetSocketAddress getRemoteAddress() { return null; } - @Override - public Optional oidcSaslConfiguration() { - return Optional.empty(); - } - @Override public boolean isTLSActive() { return false; diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java index d0b43c0ff08..9d4cdc492fc 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java @@ -21,9 +21,7 @@ import static org.apache.james.imapserver.netty.HAProxyMessageHandler.PROXY_INFO; import java.net.InetSocketAddress; -import java.net.MalformedURLException; import java.net.SocketAddress; -import java.net.URISyntaxException; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -50,7 +48,6 @@ import org.apache.james.imap.api.process.SelectedMailbox; import org.apache.james.imap.decode.ImapDecoder; import org.apache.james.imap.encode.ImapEncoder; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.metrics.api.GaugeRegistry; @@ -94,64 +91,6 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC private static final Logger LOG = LoggerFactory.getLogger(IMAPServer.class); public static final AttributeKey CONNECTION_DATE = AttributeKey.newInstance("connectionDate"); - public static class AuthenticationConfiguration { - private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true; - private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true; - private static final String OIDC_PATH = "auth.oidc"; - - public static AuthenticationConfiguration parse(HierarchicalConfiguration configuration) throws ConfigurationException { - boolean isRequireSSL = configuration.getBoolean("auth.requireSSL", fallback(configuration)); - boolean isPlainAuthEnabled = configuration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT); - - if (configuration.immutableConfigurationsAt(OIDC_PATH).isEmpty()) { - return new AuthenticationConfiguration( - isRequireSSL, - isPlainAuthEnabled); - } else { - try { - return new AuthenticationConfiguration( - isRequireSSL, - isPlainAuthEnabled, - OidcSASLConfiguration.parse(configuration.configurationAt(OIDC_PATH))); - } catch (MalformedURLException | NullPointerException | URISyntaxException exception) { - throw new ConfigurationException("Failed to retrieve oauth component", exception); - } - } - } - - private static boolean fallback(HierarchicalConfiguration configuration) { - return configuration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT); - } - - private final boolean isSSLRequired; - private final boolean plainAuthEnabled; - private final Optional oidcSASLConfiguration; - - public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled) { - this.isSSLRequired = isSSLRequired; - this.plainAuthEnabled = plainAuthEnabled; - this.oidcSASLConfiguration = Optional.empty(); - } - - public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled, OidcSASLConfiguration oidcSASLConfiguration) { - this.isSSLRequired = isSSLRequired; - this.plainAuthEnabled = plainAuthEnabled; - this.oidcSASLConfiguration = Optional.of(oidcSASLConfiguration); - } - - public boolean isSSLRequired() { - return isSSLRequired; - } - - public boolean isPlainAuthEnabled() { - return plainAuthEnabled; - } - - public Optional getOidcSASLConfiguration() { - return oidcSASLConfiguration; - } - } - private static final String SOFTWARE_TYPE = "JAMES " + VERSION + " Server "; private static final String DEFAULT_TIME_UNIT = "SECONDS"; private static final String CAPABILITY_SEPARATOR = "|"; @@ -174,7 +113,6 @@ public Optional getOidcSASLConfiguration() { private int inMemorySizeLimit; private int timeout; private int literalSizeLimit; - private AuthenticationConfiguration authenticationConfiguration; private Optional trafficShaping = Optional.empty(); private Optional connectionLimitUpstreamHandler = Optional.empty(); private Optional connectionPerIpLimitUpstreamHandler = Optional.empty(); @@ -212,7 +150,6 @@ public void doConfigure(HierarchicalConfiguration configuration) if (timeout < DEFAULT_TIMEOUT) { throw new ConfigurationException("Minimum timeout of 30 minutes required. See rfc2060 5.4 for details"); } - authenticationConfiguration = AuthenticationConfiguration.parse(configuration); connectionLimitUpstreamHandler = ConnectionLimitUpstreamHandler.forCount(connectionLimit); connectionPerIpLimitUpstreamHandler = ConnectionPerIpLimitUpstreamHandler.forCount(connPerIP); ignoreIDLEUponProcessing = configuration.getBoolean("ignoreIDLEUponProcessing", true); @@ -368,7 +305,6 @@ protected ChannelInboundHandlerAdapter createCoreHandler() { .processor(processor) .encoder(encoder) .compress(compress) - .authenticationConfiguration(authenticationConfiguration) .connectionChecks(connectionChecks) .secure(secure) .imapMetrics(imapMetrics) diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java index e4435a7786f..23d953c64ec 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java @@ -19,7 +19,6 @@ package org.apache.james.imapserver.netty; import static org.apache.james.imap.api.process.ImapSession.MDC_KEY; -import static org.apache.james.imapserver.netty.IMAPServer.AuthenticationConfiguration; import java.io.Closeable; import java.io.IOException; @@ -88,7 +87,6 @@ public static class ImapChannelUpstreamHandlerBuilder { private boolean compress; private ImapProcessor processor; private ImapEncoder encoder; - private IMAPServer.AuthenticationConfiguration authenticationConfiguration; private ImapMetrics imapMetrics; private boolean ignoreIDLEUponProcessing; private Duration heartbeatInterval; @@ -127,11 +125,6 @@ public ImapChannelUpstreamHandlerBuilder encoder(ImapEncoder encoder) { return this; } - public ImapChannelUpstreamHandlerBuilder authenticationConfiguration(IMAPServer.AuthenticationConfiguration authenticationConfiguration) { - this.authenticationConfiguration = authenticationConfiguration; - return this; - } - public ImapChannelUpstreamHandlerBuilder connectionChecks(Set connectionChecks) { this.connectionChecks = connectionChecks; return this; @@ -163,7 +156,7 @@ public ImapChannelUpstreamHandlerBuilder imapChannelGroup(ChannelGroup imapChann } public ImapChannelUpstreamHandler build() { - return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, authenticationConfiguration, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup); + return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup); } } @@ -182,7 +175,6 @@ public static ImapChannelUpstreamHandlerBuilder builder() { private final ImapProcessor processor; private final ImapEncoder encoder; private final ImapHeartbeatHandler heartbeatHandler; - private final AuthenticationConfiguration authenticationConfiguration; private final Metric imapConnectionsMetric; private final Metric imapCommandsMetric; private final boolean ignoreIDLEUponProcessing; @@ -192,15 +184,13 @@ public static ImapChannelUpstreamHandlerBuilder builder() { private final ChannelGroup imapChannelGroup; public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEncoder encoder, boolean compress, - Encryption secure, ImapMetrics imapMetrics, AuthenticationConfiguration authenticationConfiguration, - boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler, + Encryption secure, ImapMetrics imapMetrics, boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler, Set connectionChecks, boolean proxyRequired, ChannelGroup imapChannelGroup) { this.hello = hello; this.processor = processor; this.encoder = encoder; this.secure = secure; this.compress = compress; - this.authenticationConfiguration = authenticationConfiguration; this.imapConnectionsMetric = imapMetrics.getConnectionsMetric(); this.imapCommandsMetric = imapMetrics.getCommandsMetric(); this.ignoreIDLEUponProcessing = ignoreIDLEUponProcessing; @@ -215,9 +205,7 @@ public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEnc public void channelActive(ChannelHandlerContext ctx) { imapChannelGroup.add(ctx.channel()); SessionId sessionId = SessionId.generate(); - ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, authenticationConfiguration.isSSLRequired(), - authenticationConfiguration.isPlainAuthEnabled(), sessionId, - authenticationConfiguration.getOidcSASLConfiguration()); + ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, sessionId); ctx.channel().attr(IMAP_SESSION_ATTRIBUTE_KEY).set(imapsession); ctx.channel().attr(REQUEST_COUNTER).set(new AtomicLong()); ctx.channel().attr(LINEARIZER_ATTRIBUTE_KEY).set(new ImapLinerarizer()); diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java index 9a10733e645..866ea6d579b 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java @@ -39,7 +39,6 @@ import org.apache.james.imap.api.process.SelectedMailbox; import org.apache.james.imap.encode.ImapResponseWriter; import org.apache.james.imap.message.Literal; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.protocols.netty.Encryption; import org.apache.james.protocols.netty.LineHandlerAware; @@ -61,28 +60,19 @@ public class NettyImapSession implements ImapSession, NettyConstants { private final Encryption secure; private final boolean compress; private final Channel channel; - private final boolean requiredSSL; - private final boolean plainAuthEnabled; private final SessionId sessionId; - private final boolean supportsOAuth; - private final Optional oidcSASLConfiguration; private volatile ImapSessionState state = ImapSessionState.NON_AUTHENTICATED; private final AtomicReference selectedMailbox = new AtomicReference<>(); private volatile boolean needsCommandInjectionDetection; private volatile MailboxSession mailboxSession = null; - public NettyImapSession(Channel channel, Encryption secure, boolean compress, boolean requiredSSL, boolean plainAuthEnabled, SessionId sessionId, - Optional oidcSASLConfiguration) { + public NettyImapSession(Channel channel, Encryption secure, boolean compress, SessionId sessionId) { this.channel = channel; this.secure = secure; this.compress = compress; - this.requiredSSL = requiredSSL; - this.plainAuthEnabled = plainAuthEnabled; this.sessionId = sessionId; this.needsCommandInjectionDetection = true; - this.oidcSASLConfiguration = oidcSASLConfiguration; - this.supportsOAuth = oidcSASLConfiguration.isPresent(); } @Override @@ -311,31 +301,11 @@ public void popLineHandler() { handler.popLineHandler(); } - @Override - public boolean isSSLRequired() { - return requiredSSL; - } - - @Override - public boolean isPlainAuthEnabled() { - return plainAuthEnabled; - } - - @Override - public boolean supportsOAuth() { - return supportsOAuth; - } - @Override public InetSocketAddress getRemoteAddress() { return (InetSocketAddress) channel.remoteAddress(); } - @Override - public Optional oidcSaslConfiguration() { - return oidcSASLConfiguration; - } - @Override public boolean isTLSActive() { return channel.pipeline().get(SSL_HANDLER) != null; diff --git a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml index 117f0baaa83..2d3e4e51846 100644 --- a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml +++ b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml @@ -29,17 +29,21 @@ - - - - - - - - + + + + + + + + + + + + diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java index 7eb0eb22cc9..a4c6471f4c7 100644 --- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java @@ -120,7 +120,8 @@ protected IMAPServer createImapServer(HierarchicalConfiguration c memoryIntegrationResources.getQuotaManager(), memoryIntegrationResources.getQuotaRootResolver(), metricFactory, - localCacheConfiguration), + localCacheConfiguration, + config), new ImapMetrics(metricFactory), new NoopGaugeRegistry(), connectionChecks); From 3f0a554e4c3b3914daecbd41b75a6aa21ae9c8cf Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jun 2026 16:17:32 +0700 Subject: [PATCH 26/29] JAMES-4210 Update custom IMAP SASL example to factory-based SPI Update the custom IMAP example to configure SASL factories through auth.saslMechanisms, remove extension module boilerplate, and demonstrate continuation plus final server data. --- examples/custom-imap/README.md | 14 ++++---------- .../extensions.properties | 19 ------------------- .../sample-configuration/imapserver.xml | 2 +- .../imap/sasl/ExampleTokenSaslMechanism.java | 11 +++++------ .../src/main/resources/extensions.properties | 19 ------------------- .../src/main/resources/imapserver.xml | 6 +++--- 6 files changed, 13 insertions(+), 58 deletions(-) delete mode 100644 examples/custom-imap/sample-configuration/extensions.properties delete mode 100644 examples/custom-imap/src/main/resources/extensions.properties diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md index 5727c349a9b..750ce1b7bee 100644 --- a/examples/custom-imap/README.md +++ b/examples/custom-imap/README.md @@ -22,7 +22,7 @@ while `auth.exampleToken` is a custom configuration block owned by the extension ```xml - PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory secret-token bob@domain.tld @@ -30,15 +30,9 @@ while `auth.exampleToken` is a custom configuration block owned by the extension ``` -The extension module is declared in `extensions.properties`: - -```properties -guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule -``` - -The module binds a `SaslMechanismFactory` for `ExampleTokenSaslMechanism`. -James still uses `auth.saslMechanisms` to select the mechanism for one IMAP -server, and the factory reads that server's `auth.exampleToken` block. +`auth.saslMechanisms` lists SASL mechanism factory classes. Built-in factories +can use simple names, while custom factories use their fully qualified class name. +The factory reads that server's `auth.exampleToken` block. ## Running the example diff --git a/examples/custom-imap/sample-configuration/extensions.properties b/examples/custom-imap/sample-configuration/extensions.properties deleted file mode 100644 index 1d40a0f64c2..00000000000 --- a/examples/custom-imap/sample-configuration/extensions.properties +++ /dev/null @@ -1,19 +0,0 @@ -##################################################################### -# * As a subpart of Twake Mail, this file is edited by Linagora. * -# * * -# * https://twake-mail.com/ * -# * https://linagora.com * -# * * -# * This file is subject to The Affero Gnu Public License * -# * version 3. * -# * * -# * https://www.gnu.org/licenses/agpl-3.0.en.html * -# * * -# * This program is distributed in the hope that it will be * -# * useful, but WITHOUT ANY WARRANTY; without even the implied * -# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR * -# * PURPOSE. See the GNU Affero General Public License for * -# * more details. * -##################################################################### - -guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule diff --git a/examples/custom-imap/sample-configuration/imapserver.xml b/examples/custom-imap/sample-configuration/imapserver.xml index 8b3b450580d..f1df338c0df 100644 --- a/examples/custom-imap/sample-configuration/imapserver.xml +++ b/examples/custom-imap/sample-configuration/imapserver.xml @@ -34,7 +34,7 @@ under the License. false false - PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory secret-token bob@domain.tld diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java index d0e002e0467..6a8943a01b5 100644 --- a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java @@ -22,7 +22,9 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.api.sasl.SaslInitialRequest; import org.apache.james.protocols.api.sasl.SaslMechanism; @@ -46,7 +48,7 @@ public String name() { } @Override - public SaslExchange start(SaslInitialRequest request) { + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { Optional initialResponse = request.initialResponse(); return new ExampleTokenSaslExchange(initialResponse, configuration); } @@ -73,10 +75,6 @@ public SaslStep onResponse(byte[] clientResponse) { return authenticate(clientResponse); } - @Override - public void abort() { - } - @Override public void close() { } @@ -90,7 +88,8 @@ private SaslStep authenticate(byte[] clientResponse) { if ((configuration.expectedToken() + SUCCESS_DATA_TOKEN_SUFFIX).equals(token)) { return success(Optional.of(SUCCESS_DATA.getBytes(StandardCharsets.UTF_8))); } - return new SaslStep.Failure("EXAMPLE-TOKEN authentication failed."); + return new SaslStep.Failure(SaslFailure.authenticationFailed(Optional.empty(), Optional.of(configuration.authorizedUser()), + "EXAMPLE-TOKEN authentication failed.")); } private SaslStep success(Optional serverData) { diff --git a/examples/custom-imap/src/main/resources/extensions.properties b/examples/custom-imap/src/main/resources/extensions.properties deleted file mode 100644 index 1d40a0f64c2..00000000000 --- a/examples/custom-imap/src/main/resources/extensions.properties +++ /dev/null @@ -1,19 +0,0 @@ -##################################################################### -# * As a subpart of Twake Mail, this file is edited by Linagora. * -# * * -# * https://twake-mail.com/ * -# * https://linagora.com * -# * * -# * This file is subject to The Affero Gnu Public License * -# * version 3. * -# * * -# * https://www.gnu.org/licenses/agpl-3.0.en.html * -# * * -# * This program is distributed in the hope that it will be * -# * useful, but WITHOUT ANY WARRANTY; without even the implied * -# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR * -# * PURPOSE. See the GNU Affero General Public License for * -# * more details. * -##################################################################### - -guice.extension.module=org.apache.james.examples.imap.sasl.ExampleTokenSaslModule diff --git a/examples/custom-imap/src/main/resources/imapserver.xml b/examples/custom-imap/src/main/resources/imapserver.xml index cf02bd2d1c0..3ffc60b05a6 100644 --- a/examples/custom-imap/src/main/resources/imapserver.xml +++ b/examples/custom-imap/src/main/resources/imapserver.xml @@ -37,7 +37,7 @@ under the License. prop.b=anotherValue false - PlainSaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory secret-token bob@domain.tld @@ -63,7 +63,7 @@ under the License. prop.b=baad false - PlainSaslMechanism,OauthBearerSaslMechanism,XOauth2SaslMechanism,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory secret-token bob@domain.tld @@ -72,4 +72,4 @@ under the License. org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages - \ No newline at end of file + From 90848daaa077e1a744186bac219f9b30298fc43f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 17 Jun 2026 09:58:49 +0700 Subject: [PATCH 27/29] JAMES-4210 [FIX] IMAP Login/plain authentication should preserve loggedInUser information --- .../imap/processor/AbstractAuthProcessor.java | 49 +++++-------------- .../MemoryWebAdminServerIntegrationTest.java | 30 ++++++++++++ .../src/test/resources/imapserver.xml | 5 ++ 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java index 4de92480fa5..cad4359952f 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java @@ -91,8 +91,14 @@ protected Authorizator withAdminUsers() { protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier, ImapSession session, ImapRequest request, Responder responder, Username authenticateUser, Username delegatorUser) { + doAuth(mailboxSessionSupplier, session, request, responder, authenticateUser, delegatorUser, "Authentication with delegation succeeded."); + } + + protected void doAuth(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier, + ImapSession session, ImapRequest request, Responder responder, + Username authenticateUser, Username delegatorUser, String successLog) { try { - authSuccess(session, mailboxSessionSupplier.get(), request, responder, "Authentication with delegation succeeded."); + authSuccess(session, mailboxSessionSupplier.get(), request, responder, successLog); } catch (BadCredentialsException e) { authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, Optional.of(authenticateUser), Optional.of(delegatorUser), "Password authentication with delegation failed because of bad credentials."); @@ -127,7 +133,10 @@ protected void handleSaslSuccess(SaslStep.Success success, ImapSession session, return; } - authSuccess(session, getMailboxManager().createSystemSession(identity.authenticationId()), request, responder, successLog); + doAuth(() -> getMailboxManager() + .authenticate(identity.authenticationId()) + .withoutDelegation(), + session, request, responder, identity.authenticationId(), identity.authorizationId(), successLog); } protected void handleSaslFailure(SaslFailure failure, ImapSession session, ImapRequest request, Responder responder) { @@ -202,14 +211,6 @@ protected void manageFailureCount(ImapSession session, ImapRequest request, Resp } } - protected static AuthenticationAttempt delegation(Username authorizeId, Username authenticationId, String password) { - return new AuthenticationAttempt(Optional.of(authorizeId), authenticationId, password); - } - - protected static AuthenticationAttempt noDelegation(Username authenticationId, String password) { - return new AuthenticationAttempt(Optional.empty(), authenticationId, password); - } - protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String successLog) { session.authenticated(); session.setMailboxSession(mailboxSession); @@ -248,32 +249,4 @@ protected void authFailure(ImapSession session, ImapRequest request, Responder r entry.log(failureReason); manageFailureCount(session, request, responder, failed); } - - protected static class AuthenticationAttempt { - private final Optional delegateUserName; - private final Username authenticationId; - private final String password; - - public AuthenticationAttempt(Optional delegateUserName, Username authenticationId, String password) { - this.delegateUserName = delegateUserName; - this.authenticationId = authenticationId; - this.password = password; - } - - public boolean isDelegation() { - return delegateUserName.isPresent() && !delegateUserName.get().equals(authenticationId); - } - - public Optional getDelegateUserName() { - return delegateUserName; - } - - public Username getAuthenticationId() { - return authenticationId; - } - - public String getPassword() { - return password; - } - } } diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java index c63833fb9e6..2341714eea2 100644 --- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java @@ -22,8 +22,12 @@ import static io.restassured.RestAssured.when; import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import static org.apache.james.jmap.JMAPTestingConstants.LOCALHOST_IP; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; @@ -40,6 +44,7 @@ class MemoryWebAdminServerIntegrationTest extends WebAdminServerIntegrationTest { private static final String DOMAIN = "domain"; private static final String USERNAME = "bob@" + DOMAIN; + private static final String ADMIN_USERNAME = "admin@" + DOMAIN; private static final String PASSWORD = "password"; @RegisterExtension @@ -79,4 +84,29 @@ void shouldDescribeConnectedImapChannels(GuiceJamesServer server) throws Excepti .body("[0].protocolSpecificInformation.userAgent", is("{name=Thunderbird, version=102.7.1}")) .body("[0].protocolSpecificInformation.requestCount", is("3")); } + + @Test + void shouldDescribeDelegatedImapChannels(GuiceJamesServer server) throws Exception { + int imapPort = server.getProbe(ImapGuiceProbe.class).getImapPort(); + + server.getProbe(DataProbeImpl.class).addUser(USERNAME, PASSWORD); + server.getProbe(DataProbeImpl.class).addUser(ADMIN_USERNAME, PASSWORD); + + String initialClientResponse = Base64.getEncoder() + .encodeToString((USERNAME + "\0" + ADMIN_USERNAME + "\0" + PASSWORD).getBytes(StandardCharsets.US_ASCII)); + + testIMAPClient.connect(LOCALHOST_IP, imapPort); + String authenticateResponse = testIMAPClient.sendCommand("AUTHENTICATE PLAIN " + initialClientResponse); + testIMAPClient.select("INBOX"); + + assertThat(authenticateResponse).contains("OK AUTHENTICATE completed."); + + // loggedInUser should be well recorded for delegation + when() + .get("/servers/channels/" + USERNAME) + .then() + .statusCode(HttpStatus.OK_200) + .body("[0].username", is(USERNAME)) + .body("[0].protocolSpecificInformation.loggedInUser", is(ADMIN_USERNAME)); + } } \ No newline at end of file diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml index f30afe0b249..c77b813a5c9 100644 --- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml +++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml @@ -36,6 +36,11 @@ under the License. 0 0 false + + + admin@domain + + false 0 From 620d5670ff1dfe055d1628f9eb3c1185d1b940b5 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 17 Jun 2026 10:24:54 +0700 Subject: [PATCH 28/29] JAMES-4210 Move JamesSaslAuthenticator initialization out of DefaultProcessor resolve https://github.com/apache/james-project/pull/3059#discussion_r3419797271 --- .../imap/processor/DefaultProcessor.java | 29 ++----------------- .../sasl/JamesSaslAuthenticator.java | 25 ++++++++++++++++ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java index 7e0d49d820c..be56964b79c 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java @@ -19,8 +19,9 @@ package org.apache.james.imap.processor; +import static org.apache.james.protocols.sasl.JamesSaslAuthenticator.jamesSaslAuthenticator; + import java.util.Map; -import java.util.Optional; import java.util.stream.Stream; import org.apache.commons.lang3.tuple.Pair; @@ -35,14 +36,9 @@ import org.apache.james.imap.processor.base.AbstractProcessor; import org.apache.james.imap.processor.base.ImapResponseMessageProcessor; import org.apache.james.imap.processor.fetch.FetchProcessor; -import org.apache.james.mailbox.Authenticator; -import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.MailboxCounterCorrector; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.exception.BadCredentialsException; -import org.apache.james.mailbox.exception.ForbiddenDelegationException; -import org.apache.james.mailbox.exception.UserDoesNotExistException; import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.metrics.api.MetricFactory; @@ -185,27 +181,6 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess return new DefaultProcessor(processorMap, chainEndProcessor); } - private 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 static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); 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 index 5ca22c99856..b253153bde9 100644 --- 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 @@ -26,13 +26,38 @@ 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; From b30dd7f6c83a411cdd874bbc47eb3db05494b88a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 17 Jun 2026 11:16:23 +0700 Subject: [PATCH 29/29] JAMES-4210 Merge OAuthSaslMechanism OauthBearer and XOauth2 share 1 mechanism class. OauthBearer/XOauth2 factory will decide the name of mechanism. --- .../sasl/OauthBearerSaslMechanismFactory.java | 5 +- .../sasl/XOauth2SaslMechanismFactory.java | 5 +- ...echanisms.java => OAuthSaslMechanism.java} | 33 +++++++++---- .../sasl/oidc/OauthBearerSaslMechanism.java | 47 ------------------- .../sasl/oidc/XOauth2SaslMechanism.java | 47 ------------------- .../sasl/oidc/OidcSaslMechanismTest.java | 25 +++++----- 6 files changed, 42 insertions(+), 120 deletions(-) rename protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/{OidcSaslMechanisms.java => OAuthSaslMechanism.java} (78%) delete mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java delete mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java 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 index a0c52a5a8ea..49de4f2e404 100644 --- 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 @@ -23,11 +23,12 @@ 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.sasl.oidc.OauthBearerSaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory { @Override public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { - return new OauthBearerSaslMechanism(parseVerifier(serverConfiguration)); + return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, parseVerifier(serverConfiguration)); } } 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 index 931801eb102..9f9c895b7f3 100644 --- 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 @@ -23,11 +23,12 @@ 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.sasl.oidc.XOauth2SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory { @Override public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { - return new XOauth2SaslMechanism(parseVerifier(serverConfiguration)); + return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, parseVerifier(serverConfiguration)); } } diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java similarity index 78% rename from protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java rename to protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java index 90fef3d40de..cd2106d1f01 100644 --- a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanisms.java +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java @@ -30,26 +30,39 @@ 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; -final class OidcSaslMechanisms { - static SaslExchange start(Optional initialResponse, OidcJwtTokenVerifier verifier, SaslAuthenticator authenticator) { - return new OidcSaslExchange(initialResponse, verifier, authenticator); +/** + * 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; } - private OidcSaslMechanisms() { + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new OAuthSaslExchange(request.initialResponse(), authenticator); } - private static class OidcSaslExchange implements SaslExchange { + private class OAuthSaslExchange implements SaslExchange { private final Optional initialResponse; - private final OidcJwtTokenVerifier verifier; private final SaslAuthenticator authenticator; - private OidcSaslExchange(Optional initialResponse, - OidcJwtTokenVerifier verifier, - SaslAuthenticator authenticator) { + private OAuthSaslExchange(Optional initialResponse, SaslAuthenticator authenticator) { this.initialResponse = initialResponse; - this.verifier = verifier; this.authenticator = authenticator; } diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java deleted file mode 100644 index 17ed0d9cd25..00000000000 --- a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OauthBearerSaslMechanism.java +++ /dev/null @@ -1,47 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.sasl.oidc; - -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.protocols.api.sasl.SaslAuthenticator; -import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismNames; - -public class OauthBearerSaslMechanism implements SaslMechanism { - public static final String NAME = SaslMechanismNames.OAUTHBEARER; - - private final OidcJwtTokenVerifier verifier; - - public OauthBearerSaslMechanism(OidcJwtTokenVerifier verifier) { - this.verifier = verifier; - } - - @Override - public String name() { - return NAME; - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { - return OidcSaslMechanisms.start(request.initialResponse(), verifier, authenticator); - } -} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java deleted file mode 100644 index cb59f6d112b..00000000000 --- a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/XOauth2SaslMechanism.java +++ /dev/null @@ -1,47 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.protocols.sasl.oidc; - -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.protocols.api.sasl.SaslAuthenticator; -import org.apache.james.protocols.api.sasl.SaslExchange; -import org.apache.james.protocols.api.sasl.SaslInitialRequest; -import org.apache.james.protocols.api.sasl.SaslMechanism; -import org.apache.james.protocols.api.sasl.SaslMechanismNames; - -public class XOauth2SaslMechanism implements SaslMechanism { - public static final String NAME = SaslMechanismNames.XOAUTH2; - - private final OidcJwtTokenVerifier verifier; - - public XOauth2SaslMechanism(OidcJwtTokenVerifier verifier) { - this.verifier = verifier; - } - - @Override - public String name() { - return NAME; - } - - @Override - public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { - return OidcSaslMechanisms.start(request.initialResponse(), verifier, authenticator); - } -} 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 index d15694c2719..ddc1eeae5f3 100644 --- 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 @@ -31,6 +31,7 @@ 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; @@ -42,11 +43,11 @@ class OidcSaslMechanismTest { @Test void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { // GIVEN a decoded OAUTHBEARER initial response - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + 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 OauthBearerSaslMechanism(verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -55,11 +56,11 @@ void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { @Test void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() { // GIVEN a decoded XOAUTH2 initial response - SaslInitialRequest request = new SaslInitialRequest(XOauth2SaslMechanism.NAME, + 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 XOauth2SaslMechanism(verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -68,10 +69,10 @@ void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() { @Test void shouldChallengeWhenNoInitialResponse() { // GIVEN an OIDC SASL exchange without SASL-IR - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, Optional.empty()); + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.empty()); // WHEN the mechanism starts - SaslStep firstStep = new OauthBearerSaslMechanism(verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -80,11 +81,11 @@ void shouldChallengeWhenNoInitialResponse() { @Test void shouldFailMalformedResponse() { // GIVEN a malformed OIDC SASL response - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.of(bytes("invalid"))); // WHEN the mechanism consumes the response - SaslStep step = new OauthBearerSaslMechanism(verifyingToken()).start(request, authorizing()).firstStep(); + 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."))); @@ -93,11 +94,11 @@ void shouldFailMalformedResponse() { @Test void shouldFailWhenTokenIsRejected() { // GIVEN an OIDC SASL response with an invalid bearer token - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + 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 OauthBearerSaslMechanism(rejectingToken()).start(request, authorizing()).firstStep(); + 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( @@ -107,12 +108,12 @@ void shouldFailWhenTokenIsRejected() { @Test void shouldReturnAuthorizationFailure() { // GIVEN a valid token but a James authorization rule rejecting the requested identity - SaslInitialRequest request = new SaslInitialRequest(OauthBearerSaslMechanism.NAME, + 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 OauthBearerSaslMechanism(verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + 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));