This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 6512a4804847c522926182830c6c21ac616f1248 Author: Felix Auringer <[email protected]> AuthorDate: Mon Jul 21 14:39:04 2025 +0200 feat(managesieve): add XOAUTH2 authentication mechanism --- protocols/managesieve/pom.xml | 8 ++ .../managesieve/api/CapabilityAdvertiser.java | 27 ----- .../org/apache/james/managesieve/api/Session.java | 6 ++ .../managesieve/api/commands/Authenticate.java | 2 +- .../managesieve/api/commands/CoreCommands.java | 5 +- .../james/managesieve/core/CoreProcessor.java | 25 ++--- .../core/XOAUTH2AuthenticationProcessor.java | 110 +++++++++++++++++++++ .../managesieve/transcode/ArgumentParser.java | 4 - .../transcode/ManageSieveProcessor.java | 4 +- .../james/managesieve/util/SettableSession.java | 14 +++ .../netty/ManageSieveChannelUpstreamHandler.java | 9 +- .../managesieveserver/netty/ManageSieveServer.java | 17 +++- 12 files changed, 178 insertions(+), 53 deletions(-) diff --git a/protocols/managesieve/pom.xml b/protocols/managesieve/pom.xml index fb367c75f5..5522b6fa88 100644 --- a/protocols/managesieve/pom.xml +++ b/protocols/managesieve/pom.xml @@ -41,11 +41,19 @@ <groupId>${james.groupId}</groupId> <artifactId>james-server-data-api</artifactId> </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-jwt</artifactId> + </dependency> <dependency> <groupId>${james.groupId}</groupId> <artifactId>testing-base</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-api</artifactId> + </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java deleted file mode 100644 index de8c12ad10..0000000000 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java +++ /dev/null @@ -1,27 +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.managesieve.api; - -public interface CapabilityAdvertiser { - - String getAdvertisedCapabilities(); - -} diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java index c33293d8ee..ca5ed2b2fa 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java @@ -20,8 +20,11 @@ package org.apache.james.managesieve.api; +import java.util.Optional; + import org.apache.james.core.Username; import org.apache.james.managesieve.api.commands.Authenticate; +import org.apache.james.protocols.api.OidcSASLConfiguration; public interface Session { @@ -51,4 +54,7 @@ public interface Session { boolean isSslEnabled(); + Optional<OidcSASLConfiguration> getOidcSASLConfiguration(); + + void setOidcSASLConfiguration(Optional<OidcSASLConfiguration> configuration); } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java index 02c05311d8..6912348283 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java @@ -30,7 +30,7 @@ import org.apache.james.managesieve.api.UnknownSaslMechanism; public interface Authenticate { enum SupportedMechanism { - PLAIN; + PLAIN, XOAUTH2; public static SupportedMechanism retrieveMechanism(String serializedData) throws UnknownSaslMechanism { for (SupportedMechanism supportedMechanism : SupportedMechanism.values()) { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java index 092472a955..6016b9c192 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java @@ -20,15 +20,12 @@ package org.apache.james.managesieve.api.commands; -import org.apache.james.managesieve.api.CapabilityAdvertiser; - /** * Core RFC 5804 Commands common to all transports * * @see <a href=http://tools.ietf.org/html/rfc5804#section-2>RFC 5804 Commands</a> */ public interface CoreCommands extends Capability, CheckScript, DeleteScript, GetScript, HaveSpace, - ListScripts, PutScript, RenameScript, SetActive, Noop, Unauthenticate, Logout, Authenticate, StartTLS, - CapabilityAdvertiser { + ListScripts, PutScript, RenameScript, SetActive, Noop, Unauthenticate, Logout, Authenticate, StartTLS { } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java index c4bbe10691..c8f6aab73b 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java @@ -22,7 +22,6 @@ package org.apache.james.managesieve.core; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -57,7 +56,6 @@ import org.apache.james.user.api.UsersRepository; import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.base.Strings; -import com.google.common.collect.Lists; import com.google.common.collect.Maps; public class CoreProcessor implements CoreCommands { @@ -83,11 +81,6 @@ public class CoreProcessor implements CoreCommands { this.authenticationProcessorMap.put(SupportedMechanism.PLAIN, new PlainAuthenticationProcessor(usersRepository)); } - @Override - public String getAdvertisedCapabilities() { - return convertCapabilityMapToString(capabilitiesBase) + "\r\n"; - } - @Override public String capability(Session session) { return convertCapabilityMapToString(computeCapabilityMap(session)) + "\r\nOK"; @@ -106,6 +99,10 @@ public class CoreProcessor implements CoreCommands { if (session.isAuthenticated()) { capabilities.put(Capabilities.OWNER, session.getUser().asString()); } + session.getOidcSASLConfiguration().ifPresent(oidcConfiguration -> { + this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.XOAUTH2, new XOAUTH2AuthenticationProcessor(oidcConfiguration)); + }); + capabilities.put(Capabilities.SASL, constructSaslSupportedAuthenticationMechanisms()); return capabilities; } @@ -218,6 +215,9 @@ public class CoreProcessor implements CoreCommands { } String unquotedMechanism = ParserUtils.unquoteFirst(mechanism); SupportedMechanism supportedMechanism = SupportedMechanism.retrieveMechanism(unquotedMechanism); + if (!this.authenticationProcessorMap.containsKey(supportedMechanism)) { + throw new UnknownSaslMechanism("SASL mechanism disabled: " + unquotedMechanism); + } session.setChoosedAuthenticationMechanism(supportedMechanism); session.setState(Session.State.AUTHENTICATION_IN_PROGRESS); @@ -328,7 +328,6 @@ public class CoreProcessor implements CoreCommands { Map<Capabilities, String> capabilitiesBase = new HashMap<>(); capabilitiesBase.put(Capabilities.IMPLEMENTATION, IMPLEMENTATION_DESCRIPTION); capabilitiesBase.put(Capabilities.VERSION, MANAGE_SIEVE_VERSION); - capabilitiesBase.put(Capabilities.SASL, constructSaslSupportedAuthenticationMechanisms()); capabilitiesBase.put(Capabilities.STARTTLS, null); if (!extensions.isEmpty()) { capabilitiesBase.put(Capabilities.SIEVE, extensions); @@ -337,10 +336,12 @@ public class CoreProcessor implements CoreCommands { } private String constructSaslSupportedAuthenticationMechanisms() { - return Joiner.on(' ') - .join(Lists.transform( - Arrays.asList(SupportedMechanism.values()), - Enum::toString)); + return Joiner.on(' ').join(this.authenticationProcessorMap + .keySet() + .stream() + .map(Enum::toString) + .iterator() + ); } private String sanitizeString(String message) { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java new file mode 100644 index 0000000000..8a65202ac5 --- /dev/null +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java @@ -0,0 +1,110 @@ +/* + * 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.managesieve.core; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.jwt.introspection.IntrospectionEndpoint; +import org.apache.james.managesieve.api.AuthenticationException; +import org.apache.james.managesieve.api.AuthenticationProcessor; +import org.apache.james.managesieve.api.Session; +import org.apache.james.managesieve.api.SyntaxException; +import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.OIDCSASLParser.OIDCInitialResponse; +import org.apache.james.protocols.api.OidcSASLConfiguration; + +import reactor.core.publisher.Mono; + +public class XOAUTH2AuthenticationProcessor implements AuthenticationProcessor { + + private final OidcSASLConfiguration oidcConfiguration; + + public XOAUTH2AuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { + this.oidcConfiguration = oidcConfiguration; + } + + @Override + public String initialServerResponse(Session session) { + return "+ \"\""; + } + + @Override + public Username isAuthenticationSuccesfull(Session session, String suppliedClientData) throws SyntaxException, AuthenticationException { + Optional<OIDCInitialResponse> oidcInitialResponseResult = OIDCSASLParser.parse(suppliedClientData); + if (oidcInitialResponseResult.isEmpty()) { + throw new SyntaxException("Could not parse the given JWT"); + } + OIDCInitialResponse oidcInitialResponse = oidcInitialResponseResult.get(); + + Optional<Username> authenticatedUserResult = Optional.empty(); + try { + authenticatedUserResult = validateToken(oidcInitialResponse.getToken()); + } catch (Exception e) { + throw new AuthenticationException("Could not validate the JWT"); + } + if (authenticatedUserResult.isEmpty()) { + throw new AuthenticationException("Could not validate the JWT"); + } + Username authenticatedUser = authenticatedUserResult.get(); + + // The user from the managesieve AUTHENTICATE command must match the username in the token. + Username associatedUser = Username.of(oidcInitialResponse.getAssociatedUser()); + if (!authenticatedUser.equals(associatedUser)) { + throw new AuthenticationException("Mismatch between user from command and JWT"); + } + + return authenticatedUser; + } + + private Optional<Username> validateToken(String token) { + if (this.oidcConfiguration.isCheckTokenByIntrospectionEndpoint()) { + return validTokenWithIntrospection(token); + } else if (this.oidcConfiguration.isCheckTokenByUserinfoEndpoint()) { + return validTokenWithUserInfo(token); + } else { + return OidcJwtTokenVerifier.verifySignatureAndExtractClaim(token, this.oidcConfiguration.getJwksURL(), this.oidcConfiguration.getClaim()) + .map(Username::of); + } + } + + private Optional<Username> validTokenWithUserInfo(String token) { + return Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(token, + this.oidcConfiguration.getJwksURL(), + this.oidcConfiguration.getClaim(), + this.oidcConfiguration.getUserInfoEndpoint().orElseThrow())) + .blockOptional() + .map(Username::of); + } + + private Optional<Username> validTokenWithIntrospection(String token) { + return Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(token, + this.oidcConfiguration.getJwksURL(), + this.oidcConfiguration.getClaim(), + this.oidcConfiguration.getIntrospectionEndpoint() + .map(endpoint -> new IntrospectionEndpoint(endpoint, this.oidcConfiguration.getIntrospectionEndpointAuthorization())) + .orElseThrow())) + .blockOptional() + .map(Username::of); + } +} + diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java index e9f78f2629..c6d2cb16f6 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java @@ -53,10 +53,6 @@ public class ArgumentParser { this.validatePutSize = validatePutSize; } - public String getAdvertisedCapabilities() { - return core.getAdvertisedCapabilities(); - } - public String capability(Session session, String args) { if (!args.trim().isEmpty()) { return "NO \"Too many arguments: " + args + "\""; diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java index a68359ce8f..94708c1761 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java @@ -129,8 +129,8 @@ public class ManageSieveProcessor { return "NO unknown " + command + " command"; } - public String getAdvertisedCapabilities() { - return argumentParser.getAdvertisedCapabilities(); + public String getAdvertisedCapabilities(Session session) { + return argumentParser.capability(session, "") + "\r\n"; } } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java index 09b49dea6e..f689ffcc2e 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java @@ -20,9 +20,12 @@ package org.apache.james.managesieve.util; +import java.util.Optional; + import org.apache.james.core.Username; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.commands.Authenticate; +import org.apache.james.protocols.api.OidcSASLConfiguration; public class SettableSession implements Session { @@ -30,6 +33,7 @@ public class SettableSession implements Session { private State state; private Authenticate.SupportedMechanism choosedAuthenticationMechanism; private boolean sslEnabled; + private Optional<OidcSASLConfiguration> oidcSASLConfiguration = Optional.empty(); public SettableSession() { this.state = State.UNAUTHENTICATED; @@ -80,4 +84,14 @@ public class SettableSession implements Session { public boolean isSslEnabled() { return sslEnabled; } + + @Override + public Optional<OidcSASLConfiguration> getOidcSASLConfiguration() { + return this.oidcSASLConfiguration; + } + + @Override + public void setOidcSASLConfiguration(Optional<OidcSASLConfiguration> configuration) { + this.oidcSASLConfiguration = configuration; + } } diff --git a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java index 444d426351..0e6454ad80 100644 --- a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java +++ b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java @@ -21,12 +21,14 @@ package org.apache.james.managesieveserver.netty; import java.io.Closeable; import java.net.InetSocketAddress; +import java.util.Optional; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.SessionTerminatedException; import org.apache.james.managesieve.transcode.ManageSieveProcessor; import org.apache.james.managesieve.transcode.NotEnoughDataException; import org.apache.james.managesieve.util.SettableSession; +import org.apache.james.protocols.api.OidcSASLConfiguration; import org.apache.james.protocols.api.ProxyInformation; import org.apache.james.protocols.netty.Encryption; import org.slf4j.Logger; @@ -51,12 +53,14 @@ public class ManageSieveChannelUpstreamHandler extends ChannelInboundHandlerAdap private final ManageSieveProcessor manageSieveProcessor; private final Encryption secure; private final int maxLineLength; + private final Optional<OidcSASLConfiguration> oidcConfiguration; public ManageSieveChannelUpstreamHandler( - ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength) { + ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength, Optional<OidcSASLConfiguration> oidcConfiguration) { this.manageSieveProcessor = manageSieveProcessor; this.secure = secure; this.maxLineLength = maxLineLength; + this.oidcConfiguration = oidcConfiguration; } private boolean isSSL() { @@ -146,13 +150,14 @@ public class ManageSieveChannelUpstreamHandler extends ChannelInboundHandlerAdap LOGGER.info("Connection established from {}", address.getAddress().getHostAddress()); Session session = new SettableSession(); + session.setOidcSASLConfiguration(this.oidcConfiguration); if (isSSL()) { session.setSslEnabled(true); } ctx.channel().attr(NettyConstants.SESSION_ATTRIBUTE_KEY).set(session); ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).set(new ChannelManageSieveResponseWriter(ctx.channel())); super.channelActive(ctx); - ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).get().write(manageSieveProcessor.getAdvertisedCapabilities() + "OK\r\n"); + ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).get().write(manageSieveProcessor.getAdvertisedCapabilities(session)); } } diff --git a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java index 114b082217..3c52a0f6c7 100644 --- a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java +++ b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java @@ -19,12 +19,15 @@ package org.apache.james.managesieveserver.netty; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.util.Optional; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.managesieve.transcode.ManageSieveProcessor; +import org.apache.james.protocols.api.OidcSASLConfiguration; import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer; import org.apache.james.protocols.netty.AbstractChannelPipelineFactory; import org.apache.james.protocols.netty.AllButStartTlsLineChannelHandlerFactory; @@ -48,11 +51,13 @@ import io.netty.util.CharsetUtil; public class ManageSieveServer extends AbstractConfigurableAsyncServer implements ManageSieveServerMBean { private static final Logger LOGGER = LoggerFactory.getLogger(ManageSieveServer.class); + static final String OIDC_PATH = "oidc"; private final int maxLineLength; private final ManageSieveProcessor manageSieveProcessor; private Optional<ConnectionLimitUpstreamHandler> connectionLimitUpstreamHandler = Optional.empty(); private Optional<ConnectionPerIpLimitUpstreamHandler> connectionPerIpLimitUpstreamHandler = Optional.empty(); + private Optional<OidcSASLConfiguration> oidcConfiguration; public ManageSieveServer(int maxLineLength, ManageSieveProcessor manageSieveProcessor) { this.maxLineLength = maxLineLength; @@ -70,6 +75,16 @@ public class ManageSieveServer extends AbstractConfigurableAsyncServer implement connectionLimitUpstreamHandler = ConnectionLimitUpstreamHandler.forCount(connectionLimit); connectionPerIpLimitUpstreamHandler = ConnectionPerIpLimitUpstreamHandler.forCount(connPerIP); + + if (config.immutableChildConfigurationsAt(OIDC_PATH).isEmpty()) { + this.oidcConfiguration = Optional.empty(); + } else { + try { + this.oidcConfiguration = Optional.of(OidcSASLConfiguration.parse(config.configurationAt(OIDC_PATH))); + } catch (MalformedURLException | NullPointerException | URISyntaxException exception) { + throw new ConfigurationException("Failed to parse OIDC configuration", exception); + } + } } @Override @@ -79,7 +94,7 @@ public class ManageSieveServer extends AbstractConfigurableAsyncServer implement @Override protected ChannelInboundHandlerAdapter createCoreHandler() { - return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength); + return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength, this.oidcConfiguration); } @Override --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
