From eac416df2d9fac75e24ae561b7c0d6e9ffa28994 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 21 Jul 2025 14:39:04 +0200 Subject: [PATCH 01/17] feat(managesieve): add XOAUTH2 authentication mechanism --- protocols/managesieve/pom.xml | 8 ++ .../managesieve/api/CapabilityAdvertiser.java | 27 ----- .../apache/james/managesieve/api/Session.java | 6 + .../api/commands/Authenticate.java | 2 +- .../api/commands/CoreCommands.java | 5 +- .../james/managesieve/core/CoreProcessor.java | 25 ++-- .../core/XOAUTH2AuthenticationProcessor.java | 110 ++++++++++++++++++ .../managesieve/transcode/ArgumentParser.java | 4 - .../transcode/ManageSieveProcessor.java | 4 +- .../managesieve/util/SettableSession.java | 14 +++ .../ManageSieveChannelUpstreamHandler.java | 9 +- .../netty/ManageSieveServer.java | 17 ++- 12 files changed, 178 insertions(+), 53 deletions(-) delete mode 100644 protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java create mode 100644 protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java diff --git a/protocols/managesieve/pom.xml b/protocols/managesieve/pom.xml index fb367c75f5e..5522b6fa883 100644 --- a/protocols/managesieve/pom.xml +++ b/protocols/managesieve/pom.xml @@ -41,11 +41,19 @@ ${james.groupId} james-server-data-api + + ${james.groupId} + james-server-jwt + ${james.groupId} testing-base test + + ${james.protocols.groupId} + protocols-api + com.google.guava guava 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 de8c12ad106..00000000000 --- 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 c33293d8ee4..ca5ed2b2fab 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 @@ enum State { boolean isSslEnabled(); + Optional getOidcSASLConfiguration(); + + void setOidcSASLConfiguration(Optional 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 02c05311d8b..69123482834 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 @@ 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 092472a9552..6016b9c192d 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 RFC 5804 Commands */ 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 c4bbe10691a..c8f6aab73b5 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 @@ 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 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 CoreProcessor(SieveRepository repository, UsersRepository usersRepository 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 @@ private Map computeCapabilityMap(Session session) { 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 String chooseMechanism(Session session, String mechanism) { } 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 @@ private Map precomputedCapabilitiesBase(SieveParser parser Map 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 @@ private Map precomputedCapabilitiesBase(SieveParser parser } 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 00000000000..8a65202ac55 --- /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 oidcInitialResponseResult = OIDCSASLParser.parse(suppliedClientData); + if (oidcInitialResponseResult.isEmpty()) { + throw new SyntaxException("Could not parse the given JWT"); + } + OIDCInitialResponse oidcInitialResponse = oidcInitialResponseResult.get(); + + Optional 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 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 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 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 e9f78f2629c..c6d2cb16f6e 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 ArgumentParser(CoreCommands core, boolean validatePutSize) { 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 a68359ce8fe..94708c1761c 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 @@ private String matchCommandWithImplementation(Session session, String arguments, 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 09b49dea6e2..f689ffcc2e3 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 = Optional.empty(); public SettableSession() { this.state = State.UNAUTHENTICATED; @@ -80,4 +84,14 @@ public void setSslEnabled(boolean sslEnabled) { public boolean isSslEnabled() { return sslEnabled; } + + @Override + public Optional getOidcSASLConfiguration() { + return this.oidcSASLConfiguration; + } + + @Override + public void setOidcSASLConfiguration(Optional 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 444d426351e..0e6454ad802 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 @@ 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 oidcConfiguration; public ManageSieveChannelUpstreamHandler( - ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength) { + ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength, Optional oidcConfiguration) { this.manageSieveProcessor = manageSieveProcessor; this.secure = secure; this.maxLineLength = maxLineLength; + this.oidcConfiguration = oidcConfiguration; } private boolean isSSL() { @@ -146,13 +150,14 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { 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 114b0822178..3c52a0f6c73 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 @@ 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 = Optional.empty(); private Optional connectionPerIpLimitUpstreamHandler = Optional.empty(); + private Optional oidcConfiguration; public ManageSieveServer(int maxLineLength, ManageSieveProcessor manageSieveProcessor) { this.maxLineLength = maxLineLength; @@ -70,6 +75,16 @@ protected void doConfigure(HierarchicalConfiguration config) thro 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 @@ protected String getDefaultJMXName() { @Override protected ChannelInboundHandlerAdapter createCoreHandler() { - return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength); + return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength, this.oidcConfiguration); } @Override From 51db4db5f6725fecc88dde6c4b98773b6532eff0 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Thu, 14 Aug 2025 20:42:27 +0200 Subject: [PATCH 02/17] fix(oidc): correct differentiation between XOAUTH2 and OAUTHBEARER XOAUTH2 is described here: https://developers.google.com/workspace/gmail/imap/xoauth2-protocol OAUTHBERER is described here: https://datatracker.ietf.org/doc/html/rfc5801#section-4 There is a small difference in how the user argument is called and OAUTHBEARER also includes a GS2 header. --- .../james/protocols/api/OIDCSASLParser.java | 13 ++++-- .../james/protocols/api/OIDCSASLHelper.java | 12 +++++- .../imapserver/netty/IMAPServerTest.java | 26 ++++++------ .../apache/james/smtpserver/SMTPSaslTest.java | 40 +++++++++---------- 4 files changed, 53 insertions(+), 38 deletions(-) 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 eb2f556f75b..72df0612141 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 @@ -33,9 +33,11 @@ public class OIDCSASLParser { public static final char SASL_SEPARATOR = 1; public static final String PREFIX_TOKEN = "Bearer "; public static final String TOKEN_PART_PREFIX = "auth="; - public static final String USER_PART_PREFIX = "user="; + public static final String XOAUTH2_USER_PART_PREFIX = "user="; + public static final String OAUTHBEARER_USER_PART_PREFIX = "a="; public static final int TOKEN_PART_INDEX = TOKEN_PART_PREFIX.length(); - public static final int USER_PART_INDEX = USER_PART_PREFIX.length(); + public static final int XOAUTH2_USER_PART_INDEX = XOAUTH2_USER_PART_PREFIX.length(); + public static final int OAUTHBEARER_USER_PART_INDEX = OAUTHBEARER_USER_PART_PREFIX.length(); public static class OIDCInitialResponse { private final String associatedUser; @@ -74,8 +76,11 @@ public static Optional parse(String initialResponse) { if (stringToken.startsWith(TOKEN_PART_PREFIX)) { tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); tokenPartCounter++; - } else if (stringToken.startsWith(USER_PART_PREFIX)) { - userPart = stringToken.substring(USER_PART_INDEX); + } 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); userPartCounter++; } } diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java index 1436649a18c..b806a40acbe 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java @@ -25,9 +25,19 @@ import com.google.common.collect.ImmutableList; public class OIDCSASLHelper { - public static String generateOauthBearer(String username, String token) { + // See the XOAUTH2 specification athttps://developers.google.com/workspace/gmail/imap/xoauth2-protocol + // for details. + public static String generateEncodedXOauth2InitialClientResponse(String username, String token) { return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, ImmutableList.of("user=" + username, "auth=Bearer " + token, "", "")) .getBytes(StandardCharsets.US_ASCII)); } + + // See the OAUTHBEARER specification at https://datatracker.ietf.org/doc/html/rfc5801#section-4 + // for details. + public static String generateEncodedOauthbearerInitialClientResponse(String username, String token) { + return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, + ImmutableList.of("n,a=" + username, "auth=Bearer " + token, "", "")) + .getBytes(StandardCharsets.US_ASCII)); + } } diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java index c123de34edd..6ecaefd717d 100644 --- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java @@ -1343,7 +1343,7 @@ void tearDown() { @Test void oauthShouldSuccessWhenValidToken() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed."); @@ -1384,9 +1384,9 @@ void capabilityShouldAdvertiseXOAUTH2WhenConfigIsProvided() throws Exception { @Test void oauthShouldSupportOAUTH2Type() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String xoauth2 = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); - client.sendCommand("AUTHENTICATE XOAUTH2 " + oauthBearer); + client.sendCommand("AUTHENTICATE XOAUTH2 " + xoauth2); assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed."); } @@ -1405,7 +1405,7 @@ void capabilityShouldNotAdvertiseOAUTHBEARERWhenConfigIsNotProvided() throws Exc @Test void shouldNotOauthWhenAuthIsReady() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); @@ -1414,7 +1414,7 @@ void shouldNotOauthWhenAuthIsReady() throws Exception { @Test void appendShouldSuccessWhenAuthenticated() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient imapsClient = imapsClient(port); imapsClient.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); imapsClient.create("INBOX"); @@ -1451,7 +1451,7 @@ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception { int port = imapServer.getListenAddresses().get(0).getPort(); - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed."); @@ -1478,7 +1478,7 @@ void oauthShouldSuccessWhenIntrospectTokenReturnActiveIsTrue() throws Exception int port = imapServer.getListenAddresses().get(0).getPort(); - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed."); @@ -1503,7 +1503,7 @@ void oauthShouldFailWhenIntrospectTokenServerError() throws Exception { int port = imapServer.getListenAddresses().get(0).getPort(); - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("NO AUTHENTICATE processing failed."); @@ -1530,7 +1530,7 @@ void oauthShouldSuccessWhenCheckTokenByUserInfoIsPassed() throws Exception { int port = imapServer.getListenAddresses().get(0).getPort(); - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed."); @@ -1556,7 +1556,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { int port = imapServer.getListenAddresses().get(0).getPort(); - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("NO AUTHENTICATE processing failed."); @@ -1564,7 +1564,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { @Test void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER3.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER3.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("NO AUTHENTICATE"); @@ -1572,7 +1572,7 @@ void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { @Test void oauthShouldImpersonateSuccessWhenDelegated() throws Exception { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed."); @@ -1588,7 +1588,7 @@ void impersonationShouldWorkWhenDelegated() throws Exception { // USER1 authenticate and impersonate as USER2 try (TestIMAPClient client = new TestIMAPClient(imapsClient(port))) { - String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN); + String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN); String authenticateResponse = client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); assertThat(authenticateResponse).contains("OK AUTHENTICATE completed."); diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java index 0e8b5153128..45bac667831 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java @@ -61,8 +61,8 @@ class SMTPSaslTest { public static final String SCOPE = "scope"; public static final String FAIL_RESPONSE_TOKEN = Base64.getEncoder().encodeToString( String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", SCOPE, OIDC_URL).getBytes(UTF_8)); - public static final String VALID_TOKEN = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); - public static final String INVALID_TOKEN = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.INVALID_TOKEN); + public static final String VALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); + public static final String INVALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.INVALID_TOKEN); private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); @@ -115,7 +115,7 @@ void tearDown() { void oauthShouldSuccessWhenValidToken() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -129,7 +129,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuation() throws Exception { client.sendCommand("AUTH OAUTHBEARER"); assertThat(client.getReplyString()).contains("334"); - client.sendCommand(VALID_TOKEN); + client.sendCommand(VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -143,7 +143,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuationAndXOauth2() throws Exceptio client.sendCommand("AUTH XOAUTH2"); assertThat(client.getReplyString()).contains("334"); - client.sendCommand(VALID_TOKEN); + client.sendCommand(OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -155,7 +155,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuationAndXOauth2() throws Exceptio void oauthShouldSupportXOAUTH2Type() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH XOAUTH2 " + VALID_TOKEN); + client.sendCommand("AUTH XOAUTH2 " + OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -171,7 +171,7 @@ void oauthWithNoTLSConnectShouldFail() throws Exception { .as("Should not advertise OAUTHBEARER when no TLS connect.") .doesNotContain("OAUTHBEARER"); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); } @@ -179,7 +179,7 @@ void oauthWithNoTLSConnectShouldFail() throws Exception { void oauthShouldFailWhenInvalidToken() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + INVALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + INVALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("334 " + FAIL_RESPONSE_TOKEN); client.sendCommand("AQ=="); @@ -192,7 +192,7 @@ void sendMailShouldSuccessWhenAuthenticatedByOAuthBearer() throws Exception { client.sendCommand("EHLO localhost"); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); client.setSender(USER.asString()); client.addRecipient("mail@domain.org"); @@ -224,8 +224,8 @@ void sendMailShouldFailWhenNotAuthenticated() throws Exception { void shouldNotOauthWhenAlreadyAuthenticated() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("503 5.5.0 User has previously authenticated. Further authentication is not required!"); } @@ -239,7 +239,7 @@ void oauthShouldFailWhenConfigIsNotProvided() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); } @@ -323,7 +323,7 @@ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("334 " + FAIL_RESPONSE_TOKEN); @@ -352,7 +352,7 @@ void oauthShouldSuccessWhenIntrospectTokenReturnActiveIsTrue() throws Exception SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -377,7 +377,7 @@ void oauthShouldFailWhenIntrospectTokenServerError() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("451 Unable to process request"); } @@ -402,7 +402,7 @@ void oauthShouldSuccessWhenCheckTokenByUserInfoIsPassed() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -426,7 +426,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("451 Unable to process request"); } @@ -434,7 +434,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { @Test void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { SMTPSClient client = initSMTPSClient(); - String tokenWithImpersonation = OIDCSASLHelper.generateOauthBearer("another@domain.org", OidcTokenFixture.VALID_TOKEN); + String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse("another@domain.org", OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); assertThat(client.getReplyString()).contains("334 "); @@ -446,7 +446,7 @@ void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { @Test void oauthShouldImpersonateSuccessWhenDelegated() throws Exception { SMTPSClient client = initSMTPSClient(); - String tokenWithImpersonation = OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN); + String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -458,7 +458,7 @@ void impersonationShouldWorkWhenDelegated() throws Exception { client.sendCommand("EHLO localhost"); - client.sendCommand("AUTH OAUTHBEARER " + OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN)); + client.sendCommand("AUTH OAUTHBEARER " + OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN)); client.setSender(USER2.asString()); client.addRecipient("mail@domain.org"); From 95baf1472dcb32ce02dacee049658f75bbba53d6 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Fri, 15 Aug 2025 07:53:04 +0200 Subject: [PATCH 03/17] feat(managesieve): add OAUTHBEARER authentication mechanism --- .../apache/james/managesieve/api/commands/Authenticate.java | 2 +- .../org/apache/james/managesieve/core/CoreProcessor.java | 3 ++- ...ationProcessor.java => OAUTHAuthenticationProcessor.java} | 5 ++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename protocols/managesieve/src/main/java/org/apache/james/managesieve/core/{XOAUTH2AuthenticationProcessor.java => OAUTHAuthenticationProcessor.java} (96%) 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 69123482834..4e4820cd905 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 @@ public interface Authenticate { enum SupportedMechanism { - PLAIN, XOAUTH2; + PLAIN, XOAUTH2, OAUTHBEARER; 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/core/CoreProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java index c8f6aab73b5..3417fff3cdd 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 @@ -100,7 +100,8 @@ private Map computeCapabilityMap(Session session) { capabilities.put(Capabilities.OWNER, session.getUser().asString()); } session.getOidcSASLConfiguration().ifPresent(oidcConfiguration -> { - this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.XOAUTH2, new XOAUTH2AuthenticationProcessor(oidcConfiguration)); + this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.XOAUTH2, new OAUTHAuthenticationProcessor(oidcConfiguration)); + this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.OAUTHBEARER, new OAUTHAuthenticationProcessor(oidcConfiguration)); }); capabilities.put(Capabilities.SASL, constructSaslSupportedAuthenticationMechanisms()); return capabilities; 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/OAUTHAuthenticationProcessor.java similarity index 96% rename from protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java rename to protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java index 8a65202ac55..ba925141a87 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/XOAUTH2AuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java @@ -35,11 +35,11 @@ import reactor.core.publisher.Mono; -public class XOAUTH2AuthenticationProcessor implements AuthenticationProcessor { +public class OAUTHAuthenticationProcessor implements AuthenticationProcessor { private final OidcSASLConfiguration oidcConfiguration; - public XOAUTH2AuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { + public OAUTHAuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { this.oidcConfiguration = oidcConfiguration; } @@ -107,4 +107,3 @@ private Optional validTokenWithIntrospection(String token) { .map(Username::of); } } - From c2e8dfc8557b1d138315aa0663efa71c9a8f3243 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Fri, 15 Aug 2025 07:57:19 +0200 Subject: [PATCH 04/17] fix(managesieve): small fixes in authentication logic - Authentication is now aborted when session is already authenticated - Authentication is now aborted when client sends "*" - Arguments by the client are now consistently unquoted - If unquotation fails, authentication is aborted to prevent later NullPointerExceptions - Fixed NullPointerException on AuthenticationException - Server now sends correctly formatted response (including code) when waiting for additional authentication arguments - When client sends authentication credentials in second message, the first argument (command) is now interpreted as argument - When choosing mechanism failed, a proper error is returned to the client - When argument is not quoted, authentication is aborted I think with these changes, the server now behaves more like described in the RFC: https://datatracker.ietf.org/doc/html/rfc5804#section-4 --- .../james/managesieve/core/CoreProcessor.java | 14 ++++++++++++-- .../core/OAUTHAuthenticationProcessor.java | 2 +- .../core/PlainAuthenticationProcessor.java | 2 +- .../transcode/ManageSieveProcessor.java | 13 ++++++++++--- 4 files changed, 24 insertions(+), 7 deletions(-) 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 3417fff3cdd..ebb8b7e36d7 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 @@ -210,6 +210,9 @@ public String noop(String tag) { @Override public String chooseMechanism(Session session, String mechanism) { + if (session.isAuthenticated()) { + return "NO \"already authenticated\""; + } try { if (Strings.isNullOrEmpty(mechanism)) { return "NO ManageSieve syntax is incorrect : You must specify a SASL mechanism as an argument of AUTHENTICATE command"; @@ -234,7 +237,14 @@ public String authenticate(Session session, String suppliedData) { try { SupportedMechanism currentAuthenticationMechanism = session.getChoosedAuthenticationMechanism(); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(currentAuthenticationMechanism); - Username authenticatedUsername = authenticationProcessor.isAuthenticationSuccesfull(session, suppliedData); + String unquotedSuppliedData = ParserUtils.unquoteFirst(suppliedData); + if (unquotedSuppliedData == null) { + return "NO \"authentication failed\""; + } + if (unquotedSuppliedData.equals("*")) { + return "NO \"authentication aborted\""; + } + Username authenticatedUsername = authenticationProcessor.isAuthenticationSuccesfull(session, unquotedSuppliedData); if (authenticatedUsername != null) { session.setUser(authenticatedUsername); session.setState(Session.State.AUTHENTICATED); @@ -245,7 +255,7 @@ public String authenticate(Session session, String suppliedData) { return "NO authentication failed"; } } catch (AuthenticationException e) { - return "NO Authentication failed with " + e.getCause().getClass() + " : " + e.getMessage(); + return "NO Authentication failed with: " + e.getMessage(); } catch (SyntaxException e) { return "NO ManageSieve syntax is incorrect : " + e.getMessage(); } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java index ba925141a87..e424d2759ec 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java @@ -45,7 +45,7 @@ public OAUTHAuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { @Override public String initialServerResponse(Session session) { - return "+ \"\""; + return "\"\"\r\nOK"; } @Override diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java index f59daedb9a6..72837b37e6a 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java @@ -50,7 +50,7 @@ public PlainAuthenticationProcessor(UsersRepository usersRepository) { @Override public String initialServerResponse(Session session) { - return "+ \"\""; + return "\"\"\r\nOK"; } 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 94708c1761c..d07b323c4d6 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 @@ -86,17 +86,24 @@ private String parseArguments(String request, int firstWordEndIndex) { private String matchCommandWithImplementation(Session session, String arguments, String command) throws SessionTerminatedException { if (session.getState() == Session.State.AUTHENTICATION_IN_PROGRESS) { - return argumentParser.authenticate(session, arguments); + return argumentParser.authenticate(session, command); } if (command.equalsIgnoreCase(AUTHENTICATE)) { if (StringUtils.countMatches(arguments, "\"") == 4) { - argumentParser.chooseMechanism(session, arguments); + String result = argumentParser.chooseMechanism(session, arguments); + if (session.getState() != Session.State.AUTHENTICATION_IN_PROGRESS) { + return result; + } int bracket1 = arguments.indexOf('\"'); int bracket2 = arguments.indexOf('\"', bracket1 + 1); int bracket3 = arguments.indexOf('\"', bracket2 + 1); int bracket4 = arguments.indexOf('\"', bracket3 + 1); - return argumentParser.authenticate(session, arguments.substring(bracket3 + 1, bracket4)); + return argumentParser.authenticate(session, arguments.substring(bracket3, bracket4 + 1)); + } else if (arguments.split(" ").length != 1) { + // The client send additional arguments but didn't quote them. It probably thinks that it does not need + // to send more, but the server expects more. Reject this authentication now to solve this conflict. + return "NO \"unquoted argument found\""; } return argumentParser.chooseMechanism(session, arguments); } else if (command.equalsIgnoreCase(CAPABILITY)) { From c770df5a2165d6303cebef63f192616dd59389a3 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Fri, 15 Aug 2025 08:05:13 +0200 Subject: [PATCH 05/17] test(managesieve): test authentication of managesieve server --- .../apache/james/jwt/OidcTokenFixture.java | 2 +- .../protocols/protocols-managesieve/pom.xml | 53 ++ .../managesieveserver/AuthenticateTest.java | 220 +++++++ .../managesieveserver/CapabilityTest.java | 74 +++ .../managesieveserver/ManageSieveClient.java | 104 ++++ .../ManageSieveServerTestSystem.java | 93 +++ .../james/managesieveserver/OIDCTest.java | 568 ++++++++++++++++++ .../test/resources/managesieveserver-oidc.xml | 16 + .../src/test/resources/managesieveserver.xml | 9 + 9 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java create mode 100644 server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java create mode 100644 server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java create mode 100644 server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java create mode 100644 server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java create mode 100644 server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml create mode 100644 server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcTokenFixture.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcTokenFixture.java index d07646e5c42..dcf09137bac 100644 --- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcTokenFixture.java +++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcTokenFixture.java @@ -107,7 +107,7 @@ public class OidcTokenFixture { "}"; public static final String CLAIM = "email_address"; - // "email_address": "user@domain.org" + public static final String USER_EMAIL_ADDRESS = "user@domain.org"; public static final String VALID_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Inc4MFBzNUlhc24tYUdXbXcyVHJ4RGlOY2FocEgyc1h6NXBxZGhBbDlIWGMifQ.eyJleHAiOjM5Mzk1MDYxNjcsImlhdCI6MTYzOTUwNTg2NywiYXV0aF90aW1lIjozNjM5NTA1ODQxLCJqdGkiOiJjMjQ5ZTBkNi1jY2JiLTRmZDAtODI5Yi04OTM1MjczN2YzZGIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvcmVhbG0xIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjIwNDUyNzFiLWMxYmItNDJiOC1hMTkwLThlYWI1MmYzYmEwOSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFjY291bnQtY29uc29sZSIsIm5vbmNlIjoiNWUyOGJjNTAtODE5NS00NjM3LThmMWEtYWUzNWFlYTk0NTc1Iiwic2Vzc2lvbl9zdGF0ZSI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImFjciI6IjAiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFtZXMiLCJlbWFpbF9hZGRyZXNzIjoidXNlckBkb21haW4ub3JnIn0.bqHsX3yngXwXyVW7LenKzHbdqZy1AmCjE3QWrp7Y1sd_zcQEu5WABwLIOAzrXiNFeGwyww8taGJBdYa0KTBCY6MYkAHAEa1vyyO1LfJgr3cIfQT6WCf3g2BJqHRjUsqNgT_Sit9druMRke01m1V0EmzqIdLLHp8Vl-u4R3JSDx1bsQ1w3WCRlcgr_k3EJ7jNiuNnklCH8_o59y4c7Rzdpl-Y8tcA07nGjeJ_7qPgNZX6lgwvr0EhpQpbVDHXwQlp2NDzkWwBLJR0-V50Q0a-L0QD69wqeEaqi1xaRAfx2Gwn2FgCgMUWzKeW_qkEBP0tnN-pzl7j31EOnmKhshlOtw"; public static final String VALID_TOKEN_HAS_NOT_KID = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM5Mzk1MDYxNjcsImlhdCI6MTYzOTUwNTg2NywiYXV0aF90aW1lIjozNjM5NTA1ODQxLCJqdGkiOiJjMjQ5ZTBkNi1jY2JiLTRmZDAtODI5Yi04OTM1MjczN2YzZGIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvcmVhbG0xIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjIwNDUyNzFiLWMxYmItNDJiOC1hMTkwLThlYWI1MmYzYmEwOSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFjY291bnQtY29uc29sZSIsIm5vbmNlIjoiNWUyOGJjNTAtODE5NS00NjM3LThmMWEtYWUzNWFlYTk0NTc1Iiwic2Vzc2lvbl9zdGF0ZSI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImFjciI6IjAiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFtZXMiLCJlbWFpbF9hZGRyZXNzIjoidXNlckBkb21haW4ub3JnIn0.GR0Xi0de9_G_PyX4f3oj-_VWAIiae0UAOvFJZT3Jy3hqh2gFxC83PmCNKYXMVg8VXfdEHJRqjF4-swqVRGJGGlrz7C-0-sBh4geoh5HIPw4nSfQsdr2NS9IBPFurJjBJqf2u0VM9lZdvRnameFGZasSv0Ob6tnm4oLcL3MfFK5AO9NQslrV7RUCPgjF6B7FFoimvXp1dPYfL_6L_yQeyscroIWxmkcheXSA-yRf5jdmn3MTFfpvrBi-VT8HEueJSkk5HjU7PlMUesaZG07B98Q4eN8CmsKhQNDf__DMCRuVhUstcNbWXk0z_loEHARjnBDTl74cm6yVLI2mMYtrHkg"; public static final String VALID_TOKEN_HAS_NOT_FOUND_KID = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5vdEZvdW5kIn0.eyJleHAiOjM5Mzk1MDYxNjcsImlhdCI6MTYzOTUwNTg2NywiYXV0aF90aW1lIjozNjM5NTA1ODQxLCJqdGkiOiJjMjQ5ZTBkNi1jY2JiLTRmZDAtODI5Yi04OTM1MjczN2YzZGIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvcmVhbG0xIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjIwNDUyNzFiLWMxYmItNDJiOC1hMTkwLThlYWI1MmYzYmEwOSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFjY291bnQtY29uc29sZSIsIm5vbmNlIjoiNWUyOGJjNTAtODE5NS00NjM3LThmMWEtYWUzNWFlYTk0NTc1Iiwic2Vzc2lvbl9zdGF0ZSI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImFjciI6IjAiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6ImMxYzI3MmYwLWMwMjAtNGZmMC1hMzYwLTQ3MGJlYWVlNWUwMCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFtZXMiLCJlbWFpbF9hZGRyZXNzIjoidXNlckBkb21haW4ub3JnIn0.dgcqfAhyUxw1nLgqojWjrFzxjJaJX-xGpb2kMPe_3fbTBauXndI5y1CMyxvG9yA3BevijqdUOZ5s6oLAJc_1qQ45KYf7Oh3jiNpw3CcDk4cLnap5NbdsiDHM10HrJl7qbaUVa1-YljloGMk6qbYRjM_UKYyfRDHbqkPnMhyGQuG_4oSjuQMOXhCDvXUSfjpP20efQxFoZA7A5MDPd0YXs2UxGR1Hg6POW9zNZkH4XQms0SXfxY87tnt7ETN11t9xB3i1XrYOjts7rwRfnu3eXTcFQQhWd14hm9b-_DwMisfvPNrIAHIrY_dCvmOe87ekHL-5VYMaB8x5g_gjQUaUsw"; diff --git a/server/protocols/protocols-managesieve/pom.xml b/server/protocols/protocols-managesieve/pom.xml index 3a96075ebc4..0c7f1e5b1d6 100644 --- a/server/protocols/protocols-managesieve/pom.xml +++ b/server/protocols/protocols-managesieve/pom.xml @@ -13,14 +13,42 @@ Apache James :: Server :: ManageSieve + + ${james.groupId} + james-server-data-file + test + + + ${james.groupId} + james-server-data-memory + test + + + ${james.groupId} + james-server-filesystem-api + ${james.groupId} james-server-filesystem-api + test-jar + test + + + ${james.groupId} + james-server-jwt + test-jar + test ${james.groupId} james-server-protocols-library + + ${james.groupId} + james-server-protocols-library + test-jar + test + ${james.groupId} james-server-util @@ -30,6 +58,16 @@ testing-base test + + ${james.protocols.groupId} + protocols-api + + + ${james.protocols.groupId} + protocols-api + test-jar + test + ${james.protocols.groupId} protocols-managesieve @@ -38,6 +76,11 @@ ${james.protocols.groupId} protocols-netty + + commons-net + commons-net + test + io.netty netty-handler @@ -54,6 +97,16 @@ org.apache.commons commons-configuration2 + + org.apache.commons + commons-lang3 + test + + + org.mock-server + mockserver-netty + test + org.slf4j jcl-over-slf4j diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java new file mode 100644 index 00000000000..b9d0829aa3a --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -0,0 +1,220 @@ +/**************************************************************** + * 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.managesieveserver; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class AuthenticateTest { + private ManageSieveClient client; + private final ManageSieveServerTestSystem testSystem; + + public AuthenticateTest() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @BeforeEach + void setUp() throws Exception { + this.testSystem.setUp(); + this.client = new ManageSieveClient(); + this.client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + this.client.readResponse(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @Test + void plainLoginWithCorrectCredentialsShouldSucceed() throws IOException { + this.authenticatePlain(); + } + + @Test + void plainLoginWithWrongPasswordShouldNotSucceed() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD + "wrong"); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithNotExistingUserShouldNotSucceed() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "not-existing" + "\0" + "pwd"); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithoutPasswordShouldNotSucceed() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0"); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + // The SASL PLAIN standard (https://datatracker.ietf.org/doc/html/rfc4616) defines the following message: + // message = [authzid] UTF8NUL authcid UTF8NUL passwd + // The current code is more lenient. + @Disabled + @Test + void plainLoginWithMalformedMessageShouldNotSucceed() throws IOException { + String initialClientResponse = (ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithoutMechanismQuotesShouldNotSucceed() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + this.client.sendCommand("AUTHENTICATE PLAIN \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithoutInitialResponseQuotesShouldNotSucceed() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" " + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8))); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithContinuationShouldSucceed() throws IOException { + this.client.sendCommand("AUTHENTICATE \"PLAIN\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + this.client.sendCommand("\"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void plainLoginWithContinuationCanBeAborted() throws IOException { + this.client.sendCommand("AUTHENTICATE \"PLAIN\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + this.client.sendCommand("\"*\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + } + + @Test + void doubleAuthenticationShouldFail() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String command = "AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""; + + this.client.sendCommand(command); + ManageSieveClient.ServerResponse firstAuthenticationResponse = this.client.readResponse(); + Assertions.assertThat(firstAuthenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand(command); + ManageSieveClient.ServerResponse secondAuthenticationResponse = this.client.readResponse(); + Assertions.assertThat(secondAuthenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(secondAuthenticationResponse.explanation()).get().isEqualTo("already authenticated"); + } + + @Test + void unauthenticateInUnauthenticatedStateShouldFail() throws IOException { + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void unauthenticateInAuthenticatedStateShouldSucceed() throws IOException { + this.authenticatePlain(); + + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void authenticatedStateUnlocksNewCommands() throws IOException { + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse unauthenticatedResponse = this.client.readResponse(); + Assertions.assertThat(unauthenticatedResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + + this.authenticatePlain(); + + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse authenticatedResponse = this.client.readResponse(); + Assertions.assertThat(authenticatedResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse loggedOutResponse = this.client.readResponse(); + Assertions.assertThat(loggedOutResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + // The server actually disconnects but isConnected still returns True. + // Even when adding a delay, it still returns True. + // There is probably something else broken with this test. + @Disabled + @Test + void logoutShouldWorkInUnauthenticatedState() throws IOException, InterruptedException { + this.client.sendCommand("LOGOUT"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(this.client.isConnected()).isFalse(); + } + + // The server actually disconnects but isConnected still returns True. + // Even when adding a delay, it still returns True. + // There is probably something else broken with this test. + @Disabled + @Test + void logoutShouldWorkInAuthenticatedState() throws IOException, InterruptedException { + this.authenticatePlain(); + + this.client.sendCommand("LOGOUT"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(this.client.isConnected()).isFalse(); + } + + void authenticatePlain() throws IOException { + String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java new file mode 100644 index 00000000000..f13ffe3d8f2 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java @@ -0,0 +1,74 @@ +/**************************************************************** + * 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.managesieveserver; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class CapabilityTest { + private final ManageSieveServerTestSystem testSystem; + + public CapabilityTest() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @Test + void shouldAnnounceOnlyPlainAuthenticationWithDefaultConfig() throws Exception { + this.testSystem.setUp(); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + ManageSieveClient.ServerResponse initialGreeting = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(initialGreeting)).containsExactlyInAnyOrder("PLAIN"); + + client.sendCommand("CAPABILITY"); + ManageSieveClient.ServerResponse capabilityResponse = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(capabilityResponse)).containsExactlyInAnyOrder("PLAIN"); + } + + @Test + void shouldAnnouncePlainAndOauthWhenConfigured() throws Exception { + this.testSystem.setUp("managesieveserver-oidc.xml"); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + ManageSieveClient.ServerResponse initialGreeting = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(initialGreeting)).containsExactlyInAnyOrder("PLAIN", "XOAUTH2", "OAUTHBEARER"); + + client.sendCommand("CAPABILITY"); + ManageSieveClient.ServerResponse capabilityResponse = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(capabilityResponse)).containsExactlyInAnyOrder("PLAIN", "XOAUTH2", "OAUTHBEARER"); + } + + private String[] getSASLMechanisms(ManageSieveClient.ServerResponse response) { + String saslLine = Assertions.assertThat(response.responseLines()) + .filteredOn(line -> line.startsWith("\"SASL\"")) + .hasSize(1) + .first() + .actual(); + return saslLine.substring("\"SASL\" \"".length(), saslLine.length() - 1).split(" "); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java new file mode 100644 index 00000000000..e712489b709 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java @@ -0,0 +1,104 @@ +/**************************************************************** + * 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.managesieveserver; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Optional; + +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.net.SocketClient; +import org.apache.commons.net.io.CRLFLineReader; + +public class ManageSieveClient extends SocketClient { + private static final String ENCODING = StandardCharsets.UTF_8.name(); + + enum ResponseType { + BYE, + NO, + OK; + } + + record ServerResponse( + ResponseType responseType, + Optional responseCode, + Optional explanation, + ArrayList responseLines + ) {} + + private BufferedReader reader; + private BufferedWriter writer; + + @Override + protected void _connectAction_() throws IOException { + super._connectAction_(); + this.reader = new CRLFLineReader(new InputStreamReader(_input_, ENCODING)); + this.writer = new BufferedWriter(new OutputStreamWriter(_output_, ENCODING)); + } + + @Override + public void disconnect() throws IOException { + super.disconnect(); + this.reader = null; + this.writer = null; + } + + public ServerResponse readResponse() throws IOException { + ServerResponse response = null; + ArrayList lines = new ArrayList<>(); + while (response == null) { + String line = this.reader.readLine(); + String[] tokens = line.split(" ", 3); + if (EnumUtils.isValidEnumIgnoreCase(ResponseType.class, tokens[0])) { + ResponseType responseType = EnumUtils.getEnumIgnoreCase(ResponseType.class, tokens[0]); + Optional responseCode = Optional.empty(); + Optional explanation = Optional.empty(); + if (tokens.length == 2 && tokens[1].startsWith("(")) { + responseCode = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + } else if (tokens.length == 2 && !tokens[1].startsWith("(")) { + explanation = Optional.of(tokens[1]); + } else if (tokens.length == 3 && tokens[1].startsWith("(")) { + responseCode = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + explanation = Optional.of(tokens[2]); + } else if (tokens.length == 3 && !tokens[1].startsWith("(")) { + explanation = Optional.of(tokens[1] + " " + tokens[2]); + } + if (explanation.isPresent() && explanation.get().charAt(0) == '"' && explanation.get().charAt(explanation.get().length() - 1) == '"') { + explanation = Optional.of(explanation.get().substring(1, explanation.get().length() - 1)); + } + + response = new ServerResponse(responseType, responseCode, explanation, lines); + } else { + lines.addLast(line); + } + } + return response; + } + + public void sendCommand(String command) throws IOException { + this.writer.write(command + "\r\n"); + this.writer.flush(); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java new file mode 100644 index 00000000000..185c40e48ed --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.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.managesieveserver; + +import java.net.InetAddress; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.core.Username; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.filesystem.api.mock.MockFileSystem; +import org.apache.james.managesieve.core.CoreProcessor; +import org.apache.james.managesieve.jsieve.Parser; +import org.apache.james.managesieve.transcode.ArgumentParser; +import org.apache.james.managesieve.transcode.ManageSieveProcessor; +import org.apache.james.managesieveserver.netty.ManageSieveServer; +import org.apache.james.protocols.api.utils.ProtocolServerUtils; +import org.apache.james.server.core.configuration.FileConfigurationProvider; +import org.apache.james.sieverepository.file.SieveFileRepository; +import org.apache.james.user.memory.MemoryUsersRepository; + +class ManageSieveServerTestSystem { + private static final int MAX_LINE_LENGTH = 8000; + private static final DomainList NO_DOMAIN_LIST = null; + public static final String PASSWORD = "bobpwd"; + public static final Username USERNAME = Username.of("bob"); + + + private ManageSieveProcessor manageSieveProcessor; + public ManageSieveServer manageSieveServer; + private MemoryUsersRepository usersRepository; + private MockFileSystem fileSystem; + + public ManageSieveServerTestSystem() throws Exception { + this.usersRepository = MemoryUsersRepository.withoutVirtualHosting(NO_DOMAIN_LIST); + this.usersRepository.addUser(USERNAME, PASSWORD); + this.fileSystem = new MockFileSystem(); + this.manageSieveProcessor = new ManageSieveProcessor( + new ArgumentParser( + new CoreProcessor( + new SieveFileRepository(this.fileSystem), + this.usersRepository, + new Parser() + ) + ) + ); + } + + public void setUp(HierarchicalConfiguration configuration) throws Exception { + this.fileSystem.clear(); + this.manageSieveServer = new ManageSieveServer( + MAX_LINE_LENGTH, + this.manageSieveProcessor + ); + this.manageSieveServer.setFileSystem(this.fileSystem); + this.manageSieveServer.configure(configuration); + this.manageSieveServer.init(); + } + + public void setUp(String configFilePath) throws Exception { + HierarchicalConfiguration configuration = FileConfigurationProvider.getConfig(ClassLoader.getSystemResourceAsStream(configFilePath)); + setUp(configuration); + } + + public void setUp() throws Exception { + setUp("managesieveserver.xml"); + } + + public InetAddress getBindedIP() { + return new ProtocolServerUtils(this.manageSieveServer).retrieveBindedAddress().getAddress(); + } + + public int getBindedPort() { + return new ProtocolServerUtils(this.manageSieveServer).retrieveBindedAddress().getPort(); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java new file mode 100644 index 00000000000..d331c24f718 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -0,0 +1,568 @@ +/**************************************************************** + * 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.managesieveserver; + +import java.nio.charset.StandardCharsets; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcTokenFixture; +import org.apache.james.protocols.api.OIDCSASLHelper; +import org.apache.james.protocols.lib.mock.ConfigLoader; +import org.apache.james.util.ClassLoaderUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +public class OIDCTest { + private static final String DISCOVERY_URI_PATH = "/oidc/.well-known/openid-configuration"; + private static final String JWKS_URI_PATH = "/oidc/jwks"; + private static final String INTROSPECTION_URI_PATH = "/oidc/introspect"; + private static final String SCOPE = "scope"; + private static final String USERINFO_URI_PATH = "/oidc/userinfo"; + public static final String VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.VALID_TOKEN + ); + public static final String VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.VALID_TOKEN + ); + public static final String INVALID_XOAUTH2_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.INVALID_TOKEN + ); + public static final String INVALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.INVALID_TOKEN + ); + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + public class LocalValidation { + private ClientAndServer authServer; + private ManageSieveClient client; + private final ManageSieveServerTestSystem testSystem; + private final HierarchicalConfiguration configuration; + + public LocalValidation() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + this.configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + this.configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + this.configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + this.configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + this.configuration.addProperty("oidc.scope", SCOPE); + } + + @BeforeEach + void setUp() throws Exception { + this.testSystem.setUp(this.configuration); + this.client = new ManageSieveClient(); + this.client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + this.client.readResponse(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @AfterAll + void finalTearDown() { + this.authServer.stop(); + } + + @Test + void oauthbearerLoginWithValidTokenShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerLoginWithValidTokenAndContinuationShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + this.client.sendCommand("\"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerLoginWithValidTokenAndContinuationCanBeAborted() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + this.client.sendCommand("\"*\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + } + + @Test + void oauthbearerLoginWithInvalidTokenShouldNotSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + INVALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void xoauth2LoginWithValidTokenShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\" \"" + VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void xoauth2LoginWithValidTokenAndContinuationShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + this.client.sendCommand("\"" + VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void xoauth2LoginWithValidTokenAndContinuationCanBeAborted() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + + this.client.sendCommand("\"*\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + } + + @Test + void xoauth2LoginWithInvalidTokenShouldNotSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\" \"" + INVALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } + + @Nested + public class Introspection { + private final ManageSieveServerTestSystem testSystem; + private ClientAndServer authServer; + + public Introspection() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + this.authServer.stop(); + } + + @Test + void oauthbearerShouldSucceedWhenIntrospectReturnsActiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerShouldFailWhenIntrospectReturnsInactiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": false, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectReturnsWrongActiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s-wrong\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectDoesNotContainActiveField() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectDoesNotContainUserField() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"active\": true}", StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectEndpointErrors() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerIntrospectionValidationShouldFailWhenLocalValidationFails() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } + + @Nested + public class Userinfo { + private final ManageSieveServerTestSystem testSystem; + private ClientAndServer authServer; + + public Userinfo() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + this.authServer.stop(); + } + + @Test + void oauthbearerShouldSucceedWhenUserinfoClaimMatches() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerShouldFailWhenUserinfoClaimDiffers() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"test\"}", OidcTokenFixture.CLAIM), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenUserinfoClaimIsMissing() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{}"), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenUserinfoErrors() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerUserinfoValidationShouldFailWhenLocalValidationFails() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } +} diff --git a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml new file mode 100644 index 00000000000..9125d16891e --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml @@ -0,0 +1,16 @@ + + managesieveserver + 0.0.0.0:4190 + + 200 + 360 + 0 + 0 + + + http://127.0.0.1/realms/test/protocol/openid-connect/certs + sub + https://127.0.0.1/realms/test/.well-known/openid-configuration + email + + diff --git a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml new file mode 100644 index 00000000000..77cc6f3a2a7 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml @@ -0,0 +1,9 @@ + + managesieveserver + 0.0.0.0:4190 + + 200 + 360 + 0 + 0 + From 57dab092e252dd56d622891becb044392f15e401 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 25 Aug 2025 16:22:00 +0200 Subject: [PATCH 06/17] docs(managesieve): document usage of oidc authentication --- .../servers/partials/configure/sieve.adoc | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/modules/servers/partials/configure/sieve.adoc b/docs/modules/servers/partials/configure/sieve.adoc index 7ecd4c452f7..25f2219aff4 100644 --- a/docs/modules/servers/partials/configure/sieve.adoc +++ b/docs/modules/servers/partials/configure/sieve.adoc @@ -86,4 +86,28 @@ Optional integer, defaults to 2 times the count of CPUs. | maxExecutorCount | Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing ManageSieve commands. Optional integer, defaults to 16. -|=== \ No newline at end of file + +| oidc +| If this property is present, OIDC will be configured and the following properties are mandatory (unless otherwise specified). + +| oidc.oidcConfigurationURL +| Your identity provider's OIDC discovery URL. This is currently not used for managesieve but is still required when OIDC is configured. + +| oidc.jwksURL +| URL to the endpoint for the JSON Web Key Set of your provider. This is used to locally validate tokens. + +| oidc.claim +| Name of the claim in the token you want to use as the identifier for the user (e.g. "email_address"). + +| oidc.scope +| OIDC scope. This is currently not used for managesieve but is still required when OIDC is configured. + +| oidc.introspection.url +| URL to your identity provider's introspection endpoint. It is optional and if specified James will use the endpoint to validate the token in addition to local validation. + +| oidc.introspection.auth +| Provide Authorization header for introspection requests (optional, e.g. `Basic xyz`). + +| oidc.userinfo.url +| URL to your identity provider's userinfo endpoint. It is optional and if specified James will use the endpoint to validate the token in addition to local validation. James will ignore this option if `oidc.introspection.url` is already configured. +|=== From 0e6a8eac0b1071845bea9769bf46dfb3a1f7f51a Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 8 Sep 2025 14:08:51 +0200 Subject: [PATCH 07/17] fix(examples): fix introspection mismatching issuer error --- examples/oidc/docker-compose.yml | 6 +++--- examples/oidc/james/imapserver.xml | 12 +++--------- examples/oidc/james/smtpserver.xml | 15 +++------------ 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/examples/oidc/docker-compose.yml b/examples/oidc/docker-compose.yml index a27f1ff294b..65659d06f4b 100644 --- a/examples/oidc/docker-compose.yml +++ b/examples/oidc/docker-compose.yml @@ -27,6 +27,8 @@ services: volumes: - ./james/usersrepository.xml:/root/conf/usersrepository.xml - ./james/jmap.properties:/root/conf/jmap.properties + - ./james/imapserver.xml:/root/conf/imapserver.xml + - ./james/smtpserver.xml:/root/conf/smtpserver.xml ports: - "8000:8000" healthcheck: @@ -46,9 +48,7 @@ services: - KEYCLOAK_PASSWORD=admin - KEYCLOAK_IMPORT=/tmp/realm-oidc.json networks: - james: - aliases: - - keycloak + - james ldap: container_name: ldap diff --git a/examples/oidc/james/imapserver.xml b/examples/oidc/james/imapserver.xml index e590c7dd5e1..641f6c50675 100644 --- a/examples/oidc/james/imapserver.xml +++ b/examples/oidc/james/imapserver.xml @@ -4,12 +4,6 @@ imapserver 0.0.0.0:143 200 - - file://conf/keystore - PKCS12 - james72laBalle - org.bouncycastle.jce.provider.BouncyCastleProvider - 0 0 120 @@ -18,12 +12,12 @@ true - http://keycloak:8080/auth/realms/oidc/.well-known/openid-configuration - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs email openid profile email - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= diff --git a/examples/oidc/james/smtpserver.xml b/examples/oidc/james/smtpserver.xml index 6af07c4554b..e4f3655157b 100644 --- a/examples/oidc/james/smtpserver.xml +++ b/examples/oidc/james/smtpserver.xml @@ -4,13 +4,6 @@ smtpserver 0.0.0.0:587 200 - - file://conf/keystore - PKCS12 - james72laBalle - org.bouncycastle.jce.provider.BouncyCastleProvider - SunX509 - 360 0 0 @@ -18,12 +11,12 @@ forUnauthorizedAddresses true - http://keycloak:8080/auth/realms/oidc/.well-known/openid-configuration - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs email openid profile email - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= @@ -39,5 +32,3 @@ - - From 67e94fbbc06b3665760b526b1355386872bf4f94 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 8 Sep 2025 14:11:03 +0200 Subject: [PATCH 08/17] feature(examples): configure managesieve and test oidc authentication in oidc example --- examples/oidc/README.md | 33 +++++++++++++++++- examples/oidc/docker-compose.yml | 4 +++ examples/oidc/james/managesieveserver.xml | 21 ++++++++++++ examples/oidc/test.sh | 41 +++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 examples/oidc/james/managesieveserver.xml mode change 100644 => 100755 examples/oidc/test.sh diff --git a/examples/oidc/README.md b/examples/oidc/README.md index ea9b617863f..08d7eae74e7 100644 --- a/examples/oidc/README.md +++ b/examples/oidc/README.md @@ -189,4 +189,35 @@ We would use Thunderbird version 91.4.1 as a mail client (above versions should ![](_media/receive-mail.png) A remark here is that if you generate a new client_secret for `james-thunderbird` client in Keycloak, you have to modify -it accordingly in `OAuth2Providers.jsm`. \ No newline at end of file +it accordingly in `OAuth2Providers.jsm`. + +### IMAP on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 143`. Here are some commands that can be tried: + +- `a AUTHENTICATE XOAUTH2 ` (unauthenticated state) +- `b AUTHENTICATE OAUTHBEARER ` (unauthenticated state) +- `c LOGOUT` (any state) + +You can get the initial response from the [test script](./test.sh). + +### ManageSieve on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 4190`. Here are some commands that can be tried: + +- `AUTHENTICATE "XOAUTH2" ""` (unauthenticated state) +- `AUTHENTICATE "OAUTHBEARER" ""` (unauthenticated state) +- `CAPABILITY` (any state) +- `LOGOUT` (any state) + +You can get the initial response from the [test script](./test.sh). + +### SMTP on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 587`. Here are some commands that can be tried: + +- `AUTH XOAUTH2 ` (unauthenticated state) +- `AUTH OAUTHBEARER ` (unauthenticated state) +- `QUIT` (any state) + +You can get the initial response from the [test script](./test.sh). diff --git a/examples/oidc/docker-compose.yml b/examples/oidc/docker-compose.yml index 65659d06f4b..31e0261d845 100644 --- a/examples/oidc/docker-compose.yml +++ b/examples/oidc/docker-compose.yml @@ -29,8 +29,12 @@ services: - ./james/jmap.properties:/root/conf/jmap.properties - ./james/imapserver.xml:/root/conf/imapserver.xml - ./james/smtpserver.xml:/root/conf/smtpserver.xml + - ./james/managesieveserver.xml:/root/conf/managesieveserver.xml ports: - "8000:8000" + - "143:143" + - "587:587" + - "4190:4190" healthcheck: test: ["CMD", "curl", "-f", "http://james:8000/domains"] diff --git a/examples/oidc/james/managesieveserver.xml b/examples/oidc/james/managesieveserver.xml new file mode 100644 index 00000000000..a0e2b79439b --- /dev/null +++ b/examples/oidc/james/managesieveserver.xml @@ -0,0 +1,21 @@ + + + + managesieveserver + 0.0.0.0:4190 + 200 + 360 + 0 + 0 + + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs + email + openid profile email + + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= + + + + diff --git a/examples/oidc/test.sh b/examples/oidc/test.sh old mode 100644 new mode 100755 index 5c9665c3ef1..5477975955b --- a/examples/oidc/test.sh +++ b/examples/oidc/test.sh @@ -32,6 +32,47 @@ else echo "Not OK" fi +XOAUTH2_INITIAL_CLIENT_RESPONSE=`echo -n -e "user=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` +OAUTHBEARER_INITIAL_CLIENT_RESPONSE=`echo -n -e "n,a=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` + +set +x +MANAGESIEVE_XOAUTH2_RESPONSE=`(echo "AUTHENTICATE \"XOAUTH2\" \"${XOAUTH2_INITIAL_CLIENT_RESPONSE}\""; echo "CAPABILITY"; echo "LOGOUT"; sleep 3) | telnet localhost 4190` +if echo $MANAGESIEVE_XOAUTH2_RESPONSE | grep "\"OWNER\" \"james-user@localhost\"" > /dev/null; then + echo "Success: Managesieve XOAUTH2 login" +else + echo "Error: Managesieve XOAUTH2 login" +fi +if echo $MANAGESIEVE_XOAUTH2_RESPONSE | grep "OK channel is closing" > /dev/null; then + echo "Success: Managesieve XOAUTH2 logout" +else + echo "Error: Managesieve XOAUTH2 logout" +fi + +IMAP_XOAUTH2_RESPONSE=`(echo "a AUTHENTICATE XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "c LOGOUT"; sleep 3) | telnet localhost 143` +if echo $IMAP_XOAUTH2_RESPONSE | grep "a OK AUTHENTICATE completed" > /dev/null; then + echo "Success: IMAP XOAUTH2 login" +else + echo "Error: IMAP XOAUTH2 login" +fi +if echo $IMAP_XOAUTH2_RESPONSE | grep "c OK LOGOUT completed" > /dev/null; then + echo "Success: IMAP XOAUTH2 logout" +else + echo "Error: IMAP XOAUTH2 logout" +fi + +SMTP_XOAUTH2_RESPONSE=`(echo "AUTH XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "QUIT"; sleep 3) | telnet localhost 587` +if echo $SMTP_XOAUTH2_RESPONSE | grep "235 Authentication successful" > /dev/null; then + echo "Success: SMTP XOAUTH2 login" +else + echo "Error: SMTP XOAUTH2 login" +fi +if echo $SMTP_XOAUTH2_RESPONSE | grep "221 2.0.0 james.local Service closing transmission channel" > /dev/null; then + echo "Success: SMTP XOAUTH2 logout" +else + echo "Error: SMTP XOAUTH2 logout" +fi +set -x + # Logout curl --location 'http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/logout' \ From e0098fc958a81469db10082345b28847a611f2df Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 8 Sep 2025 15:08:46 +0200 Subject: [PATCH 09/17] refactor(examples): small improvements for oidc example - More modern syntax in compose file. - Remove non-working links from readme. - Use consistent container names (always ending in .example.com). - Reduce output of test script. --- examples/oidc/README.md | 14 +++--- examples/oidc/apisix/conf/apisix.yaml | 2 +- .../oidc/{docker-compose.yml => compose.yaml} | 43 +++++++++---------- examples/oidc/james/usersrepository.xml | 2 +- examples/oidc/test.sh | 33 +++++++------- 5 files changed, 46 insertions(+), 48 deletions(-) rename examples/oidc/{docker-compose.yml => compose.yaml} (71%) diff --git a/examples/oidc/README.md b/examples/oidc/README.md index 08d7eae74e7..8fe5e13e920 100644 --- a/examples/oidc/README.md +++ b/examples/oidc/README.md @@ -6,12 +6,10 @@ This is example of an OIDC setup with James. The API Gateway for example is [Apisix](https://apisix.apache.org/), we can use Apisix for websocket gateway, horizontal scaling, etc... -This [docker-compose](docker-compose.yml) will start the following services: +This [docker compose](./compose.yaml) will start the following services: - apisix: The image `linagora/apisix:3.2.0-debian-javaplugin` was created by Linagora. It based on `apisix:3.2.0-debian`, it already contain apisix plugin for SLO (Single Logout) and rewrite the `X-User` header. - - Dockerfile: [here](https://github.com/linagora/tmail-backend/blob/master/demo/apisix/Dockerfile) - - Project `tmail-apisix-plugin-runner`: [here](https://github.com/linagora/tmail-backend/tree/master/demo/apisix/tmail-apisix-plugin-runner) - Apisix being the OIDC gateway against James by exposing two endpoints: - `POST /jmap` for JMAP requests against James with normal authentication - `POST /oidc/jmap` for JMAP request against James with a JWT token issued by the LemonLDAP @@ -161,21 +159,21 @@ Use websocket with endpoint `ws://apisix.example.com:9080/oidc/jmap/ws` and the We would use Thunderbird version 91.4.1 as a mail client (above versions should work). * Open `/thunderbird/omni.ja` in your host, find and modify `OAuth2Providers.jsm`: - * Add James hostname in kHostnames: `["localhost", ["james.local", "email"]],` + * Add James hostname in kHostnames: `["localhost", ["james.example.com", "email"]],` * Register using `james-thunderbird` Keycloak client in kIssuers: ``` [ - "james.local", + "james.example.com", [ "james-thunderbird", //client_id from keycloak "Xw9ht1veTu0Tk5sMMy03PdzY3AiFvssw", // client_secret from keycloak - "http://keycloak.local:8080/auth/realms/oidc/protocol/openid-connect/auth", - "http://keycloak.local:8080/auth/realms/oidc/protocol/openid-connect/token", + "http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/auth", + "http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token", ], ] ``` -* Adding a line `127.0.0.1 keycloak.local` to your `/etc/hosts` so Thunderbird can resolve the address of keycloak. +* Adding a line `127.0.0.1 sso.example.com` to your `/etc/hosts` so Thunderbird can resolve the address of keycloak. * Run Thunderbird, configure it using `james-user@localhost` account against these IMAP/SMTP settings: * IMAP: server: localhost, port: 143, connection security: No, authentication method: OAUTH2 ![](_media/imap-setting.png) diff --git a/examples/oidc/apisix/conf/apisix.yaml b/examples/oidc/apisix/conf/apisix.yaml index cc8d14db342..cf27c1afe29 100644 --- a/examples/oidc/apisix/conf/apisix.yaml +++ b/examples/oidc/apisix/conf/apisix.yaml @@ -219,7 +219,7 @@ upstreams: - id: jmap_upstream nodes: - "james:80": 1 + "james.example.com:80": 1 type: roundrobin plugin_configs: diff --git a/examples/oidc/docker-compose.yml b/examples/oidc/compose.yaml similarity index 71% rename from examples/oidc/docker-compose.yml rename to examples/oidc/compose.yaml index 31e0261d845..dc39fd20bac 100644 --- a/examples/oidc/docker-compose.yml +++ b/examples/oidc/compose.yaml @@ -1,5 +1,3 @@ -version: "3" - services: apisix: container_name: apisix.example.com @@ -8,11 +6,11 @@ services: - ./apisix/conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml - ./apisix/conf/config.yaml:/usr/local/apisix/conf/config.yaml environment: - - X_USER_SECRET=xusersecret123 + X_USER_SECRET: xusersecret123 networks: - james ports: - - "9080:9080/tcp" + - "127.0.0.1:9080:9080" james: depends_on: @@ -20,10 +18,9 @@ services: networks: - james image: apache/james:memory-latest - container_name: james - hostname: james.local - command: - - --generate-keystore + container_name: james.example.com + hostname: james.example.com + command: [--generate-keystore] volumes: - ./james/usersrepository.xml:/root/conf/usersrepository.xml - ./james/jmap.properties:/root/conf/jmap.properties @@ -31,14 +28,14 @@ services: - ./james/smtpserver.xml:/root/conf/smtpserver.xml - ./james/managesieveserver.xml:/root/conf/managesieveserver.xml ports: - - "8000:8000" - - "143:143" - - "587:587" - - "4190:4190" + - "127.0.0.1:8000:8000" + - "127.0.0.1:143:143" + - "127.0.0.1:587:587" + - "127.0.0.1:4190:4190" healthcheck: test: ["CMD", "curl", "-f", "http://james:8000/domains"] - sso.example.com: + sso: depends_on: - ldap image: quay.io/keycloak/keycloak:16.1.0 @@ -46,25 +43,25 @@ services: volumes: - ./keycloak/realm-oidc.json:/tmp/realm-oidc.json ports: - - "8080:8080" + - "127.0.0.1:8080:8080" environment: - - KEYCLOAK_USER=admin - - KEYCLOAK_PASSWORD=admin - - KEYCLOAK_IMPORT=/tmp/realm-oidc.json + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: admin + KEYCLOAK_IMPORT: /tmp/realm-oidc.json networks: - james ldap: - container_name: ldap + container_name: ldap.example.com image: osixia/openldap:1.5.0 ports: - - "389:389" - - "636:636" + - "127.0.0.1:389:389" + - "127.0.0.1:636:636" command: [--copy-service] volumes: - ./ldap/populate.ldif:/container/service/slapd/assets/config/bootstrap/ldif/data.ldif environment: - - LDAP_DOMAIN=localhost + LDAP_DOMAIN: localhost networks: - james @@ -75,7 +72,7 @@ services: networks: - james ports: - - "6379:6379" + - "127.0.0.1:6379:6379" networks: - james: \ No newline at end of file + james: diff --git a/examples/oidc/james/usersrepository.xml b/examples/oidc/james/usersrepository.xml index a0c316db385..3f348fd41c9 100644 --- a/examples/oidc/james/usersrepository.xml +++ b/examples/oidc/james/usersrepository.xml @@ -22,7 +22,7 @@ /dev/null` ACCESS_TOKEN=`echo $GET_TOKEN_RESPONSE 2>/dev/null |perl -pe 's/^.*"access_token"\s*:\s*"(.*?)".*$/$1/'` +echo "Access token: $ACCESS_TOKEN" REFRESH_TOKEN=`echo $GET_TOKEN_RESPONSE 2>/dev/null |perl -pe 's/^.*"refresh_token"\s*:\s*"(.*?)".*$/$1/'` +echo "Refresh token: $REFRESH_TOKEN" echo "Got an access_token" if curl -H "Authorization: Bearer $ACCESS_TOKEN" http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/userinfo 2>/dev/null| grep james-user >/dev/null; then @@ -23,8 +27,7 @@ else echo "ACCESS_TOKEN VERIFICATION FAILED" fi -echo -n "Trying James: " - +echo -n "Trying James:" APISIX_JMAP_ENDPOINT=apisix.example.com:9080/oidc/jmap/session if curl -v -H 'Accept: application/json; jmapVersion=rfc-8621' -H "Authorization: Bearer $ACCESS_TOKEN" $APISIX_JMAP_ENDPOINT 2>/dev/null | grep uploadUrl >/dev/null; then echo "OK" @@ -33,45 +36,45 @@ else fi XOAUTH2_INITIAL_CLIENT_RESPONSE=`echo -n -e "user=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` +echo "XOAUTH2: $XOAUTH2_INITIAL_CLIENT_RESPONSE" OAUTHBEARER_INITIAL_CLIENT_RESPONSE=`echo -n -e "n,a=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` +echo "OAUTHBEARER: $OAUTHBEARER_INITIAL_CLIENT_RESPONSE" -set +x -MANAGESIEVE_XOAUTH2_RESPONSE=`(echo "AUTHENTICATE \"XOAUTH2\" \"${XOAUTH2_INITIAL_CLIENT_RESPONSE}\""; echo "CAPABILITY"; echo "LOGOUT"; sleep 3) | telnet localhost 4190` -if echo $MANAGESIEVE_XOAUTH2_RESPONSE | grep "\"OWNER\" \"james-user@localhost\"" > /dev/null; then +MANAGESIEVE_XOAUTH2_RESPONSE=`(echo "AUTHENTICATE \"XOAUTH2\" \"${XOAUTH2_INITIAL_CLIENT_RESPONSE}\""; echo "CAPABILITY"; echo "LOGOUT"; sleep 3) | telnet 127.0.0.1 4190` +if echo "$MANAGESIEVE_XOAUTH2_RESPONSE" | grep "\"OWNER\" \"james-user@localhost\"" > /dev/null; then echo "Success: Managesieve XOAUTH2 login" else echo "Error: Managesieve XOAUTH2 login" fi -if echo $MANAGESIEVE_XOAUTH2_RESPONSE | grep "OK channel is closing" > /dev/null; then +if echo "$MANAGESIEVE_XOAUTH2_RESPONSE" | grep "OK channel is closing" > /dev/null; then echo "Success: Managesieve XOAUTH2 logout" else echo "Error: Managesieve XOAUTH2 logout" fi -IMAP_XOAUTH2_RESPONSE=`(echo "a AUTHENTICATE XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "c LOGOUT"; sleep 3) | telnet localhost 143` -if echo $IMAP_XOAUTH2_RESPONSE | grep "a OK AUTHENTICATE completed" > /dev/null; then +IMAP_XOAUTH2_RESPONSE=`(echo "a AUTHENTICATE XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "c LOGOUT"; sleep 3) | telnet 127.0.0.1 143` +if echo "$IMAP_XOAUTH2_RESPONSE" | grep "a OK AUTHENTICATE completed" > /dev/null; then echo "Success: IMAP XOAUTH2 login" else echo "Error: IMAP XOAUTH2 login" fi -if echo $IMAP_XOAUTH2_RESPONSE | grep "c OK LOGOUT completed" > /dev/null; then +if echo "$IMAP_XOAUTH2_RESPONSE" | grep "c OK LOGOUT completed" > /dev/null; then echo "Success: IMAP XOAUTH2 logout" else echo "Error: IMAP XOAUTH2 logout" fi -SMTP_XOAUTH2_RESPONSE=`(echo "AUTH XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "QUIT"; sleep 3) | telnet localhost 587` -if echo $SMTP_XOAUTH2_RESPONSE | grep "235 Authentication successful" > /dev/null; then +SMTP_XOAUTH2_RESPONSE=`(echo "AUTH XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "QUIT"; sleep 3) | telnet 127.0.0.1 587` +if echo "$SMTP_XOAUTH2_RESPONSE" | grep "235 Authentication successful" > /dev/null; then echo "Success: SMTP XOAUTH2 login" else echo "Error: SMTP XOAUTH2 login" fi -if echo $SMTP_XOAUTH2_RESPONSE | grep "221 2.0.0 james.local Service closing transmission channel" > /dev/null; then +if echo "$SMTP_XOAUTH2_RESPONSE" | grep "221 2.0.0 james.example.com Service closing transmission channel" > /dev/null; then echo "Success: SMTP XOAUTH2 logout" else echo "Error: SMTP XOAUTH2 logout" fi -set -x # Logout From 76babe3496cdf5487849603b13a84579266b5500 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Wed, 8 Oct 2025 17:20:38 +0200 Subject: [PATCH 10/17] refactor: add more errors and reset authentication state on authentication error While trying to get the MPT tests for managesieve running, I realized that a client is trapped in the state "AuthenticationInProgress" when using continuation. This made longer test scripts impossible. To consistently reset the session, I introduced more errors that are handled in one place instead of returning a "NO" answer directly during authentication. Those changes also required some small changes to the MPT and the normal tests. Additionally, there were a parsing bug when sending space-separated username and password in the authentication continuation which is fixed now. --- .../managesieve/scripts/authenticate.test | 23 +++++--- .../scripts/authenticateBase64.test | 2 +- .../james/managesieve/scripts/capability.test | 7 ++- .../managesieve/scripts/checkscript.test | 6 +- .../managesieve/scripts/deletescript.test | 7 ++- .../james/managesieve/scripts/getscript.test | 7 +-- .../james/managesieve/scripts/havespace.test | 7 ++- .../managesieve/scripts/listscripts.test | 5 +- .../james/managesieve/scripts/logout.test | 2 +- .../james/managesieve/scripts/noop.test | 2 +- .../james/managesieve/scripts/putscript.test | 6 +- .../managesieve/scripts/renamescript.test | 7 +-- .../james/managesieve/scripts/setactive.test | 5 +- .../james/managesieve/scripts/starttls.test | 7 ++- .../managesieve/scripts/unauthenticate.test | 7 ++- .../james/managesieve/core/CoreProcessor.java | 50 +++++++++------- .../core/PlainAuthenticationProcessor.java | 6 +- .../transcode/ManageSieveProcessor.java | 59 +++++++++++-------- .../james/managesieve/util/ParserUtils.java | 4 +- .../managesieveserver/AuthenticateTest.java | 22 +++---- .../james/managesieveserver/OIDCTest.java | 4 +- 21 files changed, 137 insertions(+), 108 deletions(-) diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test index 334699cf14a..9914f24f288 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test @@ -18,20 +18,25 @@ ################################################################ C: AUTHENTICATE -S: NO ManageSieve syntax is incorrect : You must specify a SASL mechanism as an argument of AUTHENTICATE command +S: NO "ManageSieve syntax is incorrect: quoted SASL mechanism must be supplied" C: AUTHENTICATE "UNKNOWN" -S: NO Unknown SASL mechanism UNKNOWN +S: NO "Unknown SASL mechanism UNKNOWN" C: AUTHENTICATE "PLAIN" -S: \+ "" +S: "" +S: OK C: GETSCRIPT toto.sieve -S: NO ManageSieve syntax is incorrect : You must supply a password for the authentication mechanism. Formal syntax : usernamepassword +S: NO "ManageSieve syntax is incorrect: quoted authentication data must be supplied" -C: tin password -S: NO authentication failed +C: AUTHENTICATE "PLAIN" +S: "" +S: OK +C: "tin password" +S: NO "Authentication failed with: Verification of credentials failed" C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password -S: OK \ No newline at end of file +S: "" +S: OK +C: "user password" +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test index cee22fb254a..720257bbb2c 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test @@ -18,4 +18,4 @@ ################################################################ C: AUTHENTICATE "PLAIN" "AHVzZXIAcGFzc3dvcmQ=" -S: OK \ No newline at end of file +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test index 1b2141b1247..16e6a75e028 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test @@ -38,8 +38,9 @@ S: "VERSION" "1.0" S: OK C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: CAPABILITY @@ -51,4 +52,4 @@ S: "STARTTLS" S: "IMPLEMENTATION" "Apache ManageSieve v1.0" S: "VERSION" "1.0" } -S: OK \ No newline at end of file +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test index 8fc76dd5001..33cc62e8214 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test @@ -34,8 +34,9 @@ C: S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: CHECKSCRIPT {99+} @@ -51,4 +52,3 @@ C: #comment C: InvalidSieveCommand C: S: NO "Syntax Error: org.apache.jsieve.parser.generated.ParseException: Encountered "" at line 2, column 21. - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test index 5352d2aaf67..91658e2b559 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test @@ -24,8 +24,9 @@ C: DELETESCRIPT "foo" S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: DELETESCRIPT "foo" @@ -77,4 +78,4 @@ C: SETACTIVE "mysievescript" S: OK C: DELETESCRIPT "mysievescript" -S: NO \(ACTIVE\) "You may not delete an active script" \ No newline at end of file +S: NO \(ACTIVE\) "You may not delete an active script" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test index aa5ca1d9bb0..8d1359100e0 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test @@ -24,8 +24,9 @@ C: GETSCRIPT "foo" S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: GETSCRIPT "foo" @@ -48,5 +49,3 @@ S: fileinto "INBOX.sent"; S: \} S: S: OK - - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test index 2b0958276e6..653f60fe433 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test @@ -27,12 +27,13 @@ C: HAVESPACE "scriptname" 49 S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: HAVESPACE "scriptname" 49 S: OK C: HAVESPACE "scriptname" 51 -S: NO \(QUOTA/MAXSIZE\) "Quota exceeded" \ No newline at end of file +S: NO \(QUOTA/MAXSIZE\) "Quota exceeded" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test index 1539277af0c..a59dd4a7007 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test @@ -21,8 +21,9 @@ C: LISTSCRIPTS S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: LISTSCRIPTS diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test index 125c4210e4e..65bb0a2cc54 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test @@ -18,4 +18,4 @@ ################################################################ C: LOGOUT -S: OK channel is closing \ No newline at end of file +S: OK channel is closing diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test index 837fb930012..bebba0bd4aa 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test @@ -25,4 +25,4 @@ S: OK \(TAG \{16\} S: STARTTLS-SYNC-42\) "DONE" C: NooP -S: OK "NOOP completed" \ No newline at end of file +S: OK "NOOP completed" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test index e8e300f64d2..2b9c72e33fe 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test @@ -39,8 +39,9 @@ C: S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: PUTSCRIPT "mysievescript" {97+} @@ -56,4 +57,3 @@ C: #comment C: InvalidSieveCommand C: S: NO "Syntax Error: org.apache.jsieve.parser.generated.ParseException: Encountered "" at line 2, column 21. - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test index 355cd844610..1de65f25d00 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test @@ -27,8 +27,9 @@ C: RENAMESCRIPT "foo" "bar" S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: PUTSCRIPT "mysievescript" {99+} @@ -75,5 +76,3 @@ S: OK C: RENAMESCRIPT "mysievescript" "mysievescriptbis" S: NO \(ALREADYEXISTS\) "A script with that name already exists" - - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test index c05ff82e95e..5a836693f60 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test @@ -24,8 +24,9 @@ C: SETACTIVE "foo" S: NO C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: SETACTIVE "foo" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test index 7e973540b43..8220140f7e3 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test @@ -24,11 +24,12 @@ C: STARTTLS S: NO You can't enable two time SSL encryption C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: STARTTLS S: NO command STARTTLS is issued in the wrong state. It must be issued as you are unauthenticated -C: LOGOUT \ No newline at end of file +C: LOGOUT diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test index 4acc436f4af..21ef39e49fe 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test @@ -27,8 +27,9 @@ C: UNAUTHENTICATE S: NO UNAUTHENTICATE command must be issued in authenticated state C: AUTHENTICATE "PLAIN" -S: \+ "" -C: user password +S: "" +S: OK +C: "user password" S: OK C: GETSCRIPT any @@ -38,4 +39,4 @@ C: UNAUTHENTICATE S: OK C: GETSCRIPT any -S: NO \ No newline at end of file +S: NO 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 ebb8b7e36d7..33cc055fb1c 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 @@ -210,25 +210,26 @@ public String noop(String tag) { @Override public String chooseMechanism(Session session, String mechanism) { - if (session.isAuthenticated()) { - return "NO \"already authenticated\""; - } try { if (Strings.isNullOrEmpty(mechanism)) { - return "NO ManageSieve syntax is incorrect : You must specify a SASL mechanism as an argument of AUTHENTICATE command"; + throw new SyntaxException("quoted SASL mechanism must be supplied"); } - String unquotedMechanism = ParserUtils.unquoteFirst(mechanism); - SupportedMechanism supportedMechanism = SupportedMechanism.retrieveMechanism(unquotedMechanism); + + SupportedMechanism supportedMechanism = SupportedMechanism.retrieveMechanism(mechanism); if (!this.authenticationProcessorMap.containsKey(supportedMechanism)) { - throw new UnknownSaslMechanism("SASL mechanism disabled: " + unquotedMechanism); + throw new UnknownSaslMechanism("SASL mechanism disabled: " + mechanism); } session.setChoosedAuthenticationMechanism(supportedMechanism); session.setState(Session.State.AUTHENTICATION_IN_PROGRESS); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(supportedMechanism); return authenticationProcessor.initialServerResponse(session); - } catch (UnknownSaslMechanism unknownSaslMechanism) { - return "NO " + unknownSaslMechanism.getMessage(); + } catch (UnknownSaslMechanism e) { + resetSession(session); + return "NO \"" + e.getMessage() + "\""; + } catch (SyntaxException e) { + resetSession(session); + return "NO \"ManageSieve syntax is incorrect: " + e.getMessage() + "\""; } } @@ -237,41 +238,46 @@ public String authenticate(Session session, String suppliedData) { try { SupportedMechanism currentAuthenticationMechanism = session.getChoosedAuthenticationMechanism(); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(currentAuthenticationMechanism); - String unquotedSuppliedData = ParserUtils.unquoteFirst(suppliedData); - if (unquotedSuppliedData == null) { - return "NO \"authentication failed\""; + if (Strings.isNullOrEmpty(suppliedData)) { + throw new SyntaxException("quoted authentication data must be supplied"); } - if (unquotedSuppliedData.equals("*")) { - return "NO \"authentication aborted\""; + if (suppliedData.equals("*")) { + throw new AuthenticationException("authentication aborted by client"); } - Username authenticatedUsername = authenticationProcessor.isAuthenticationSuccesfull(session, unquotedSuppliedData); + Username authenticatedUsername = authenticationProcessor.isAuthenticationSuccesfull(session, suppliedData); if (authenticatedUsername != null) { session.setUser(authenticatedUsername); session.setState(Session.State.AUTHENTICATED); return "OK"; } else { - session.setState(Session.State.UNAUTHENTICATED); - session.setUser(null); - return "NO authentication failed"; + resetSession(session); + return "NO \"authentication failed\""; } } catch (AuthenticationException e) { - return "NO Authentication failed with: " + e.getMessage(); + resetSession(session); + return "NO \"Authentication failed with: " + e.getMessage() + "\""; } catch (SyntaxException e) { - return "NO ManageSieve syntax is incorrect : " + e.getMessage(); + resetSession(session); + return "NO \"ManageSieve syntax is incorrect: " + e.getMessage() + "\""; } } @Override public String unauthenticate(Session session) { if (session.isAuthenticated()) { - session.setState(Session.State.UNAUTHENTICATED); - session.setUser(null); + resetSession(session); return "OK"; } else { return "NO UNAUTHENTICATE command must be issued in authenticated state"; } } + private static void resetSession(Session session) { + session.setState(Session.State.UNAUTHENTICATED); + session.setUser(null); + session.setChoosedAuthenticationMechanism(null); + } + @Override public void logout() throws SessionTerminatedException { throw new SessionTerminatedException(); diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java index 72837b37e6a..1a6164b5d86 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java @@ -72,11 +72,11 @@ public Username isAuthenticationSuccesfull(Session session, String suppliedClien private Username authenticateWithSeparator(Session session, String suppliedClientData, char c) throws SyntaxException, AuthenticationException { Iterator it = Splitter.on(c).omitEmptyStrings().split(suppliedClientData).iterator(); if (!it.hasNext()) { - throw new SyntaxException("You must supply a username for the authentication mechanism. Formal syntax : usernamepassword"); + throw new SyntaxException("You must supply a username for the authentication mechanism. Formal syntax: usernamepassword"); } Username userName = Username.of(it.next()); if (!it.hasNext()) { - throw new SyntaxException("You must supply a password for the authentication mechanism. Formal syntax : usernamepassword"); + throw new SyntaxException("You must supply a password for the authentication mechanism. Formal syntax: usernamepassword"); } String password = it.next(); session.setUser(userName); @@ -85,7 +85,7 @@ private Username authenticateWithSeparator(Session session, String suppliedClien if (user != null && user.verifyPassword(password)) { return user.getUserName(); } else { - return null; + throw new AuthenticationException("Verification of credentials failed"); } } catch (UsersRepositoryException e) { throw new AuthenticationException(e.getMessage()); 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 d07b323c4d6..83cca6f8a09 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 @@ -22,10 +22,10 @@ import jakarta.inject.Inject; -import org.apache.commons.lang3.StringUtils; import org.apache.james.managesieve.api.ManageSieveException; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.SessionTerminatedException; +import org.apache.james.managesieve.util.ParserUtils; import org.apache.james.sieverepository.api.exception.SieveRepositoryException; public class ManageSieveProcessor { @@ -54,6 +54,17 @@ public ManageSieveProcessor(ArgumentParser argumentParser) { } public String handleRequest(Session session, String request) throws ManageSieveException, SieveRepositoryException { + if (request.endsWith("\n")) { + request = request.substring(0, request.length() - 1); + } + if (request.endsWith("\r")) { + request = request.substring(0, request.length() - 1); + } + + if (session.getState() == Session.State.AUTHENTICATION_IN_PROGRESS) { + return matchCommandWithImplementation(session, request.trim(), AUTHENTICATE) + "\r\n"; + } + int firstWordEndIndex = request.indexOf(' '); String arguments = parseArguments(request, firstWordEndIndex); String command = parseCommand(request, firstWordEndIndex); @@ -67,12 +78,6 @@ private String parseCommand(String request, int firstWordEndIndex) { } else { command = request; } - if (command.endsWith("\n")) { - command = command.substring(0, command.length() - 1); - } - if (command.endsWith("\r")) { - command = command.substring(0, command.length() - 1); - } return command; } @@ -85,27 +90,35 @@ private String parseArguments(String request, int firstWordEndIndex) { } private String matchCommandWithImplementation(Session session, String arguments, String command) throws SessionTerminatedException { - if (session.getState() == Session.State.AUTHENTICATION_IN_PROGRESS) { - return argumentParser.authenticate(session, command); - } if (command.equalsIgnoreCase(AUTHENTICATE)) { - if (StringUtils.countMatches(arguments, "\"") == 4) { - String result = argumentParser.chooseMechanism(session, arguments); + // The RFC forbids the AUTHENTICATE command if the session is already authenticated. + if (session.isAuthenticated()) { + return "NO \"already authenticated\""; + } + + // If no authentication is in progress, the authentication mechanism needs to be chosen. + if (session.getState() != Session.State.AUTHENTICATION_IN_PROGRESS) { + String mechanism = ParserUtils.unquoteFirst(arguments); + String result = argumentParser.chooseMechanism(session, mechanism); + // If the authentication is not in progress, return the result (error) because choosing the mechanism has failed. if (session.getState() != Session.State.AUTHENTICATION_IN_PROGRESS) { return result; } - int bracket1 = arguments.indexOf('\"'); - int bracket2 = arguments.indexOf('\"', bracket1 + 1); - int bracket3 = arguments.indexOf('\"', bracket2 + 1); - int bracket4 = arguments.indexOf('\"', bracket3 + 1); - - return argumentParser.authenticate(session, arguments.substring(bracket3, bracket4 + 1)); - } else if (arguments.split(" ").length != 1) { - // The client send additional arguments but didn't quote them. It probably thinks that it does not need - // to send more, but the server expects more. Reject this authentication now to solve this conflict. - return "NO \"unquoted argument found\""; + + // Skips the whole mechanism, the closing quote, and the space if present. + // If the request is well-formatted, the arguments are now empty or contain the client's initial response. + arguments = arguments.substring(arguments.indexOf(mechanism) + mechanism.length() + 1); + if (arguments.startsWith(" ")) { + arguments = arguments.substring(1); + } + // If there are is no initial client response left, return the result (initial server response). + if (arguments.isEmpty()) { + return result; + } } - return argumentParser.chooseMechanism(session, arguments); + + // The authentication is in progress, the mechanism has been chosen, and the arguments contain an initial client response. + return argumentParser.authenticate(session, ParserUtils.unquoteFirst(arguments)); } else if (command.equalsIgnoreCase(CAPABILITY)) { return argumentParser.capability(session, arguments); } else if (command.equalsIgnoreCase(CHECKSCRIPT)) { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java index f07198843f3..86fbd7c02d5 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java @@ -56,8 +56,8 @@ public static String unquoteFirst(String quoted) { } if (quoted.length() > 2 && quoted.startsWith("\"") && quoted.indexOf('\"', 1) >= 0) { return quoted.substring(1, quoted.indexOf('\"', 1)); - } else if (quoted.startsWith("'") && quoted.endsWith("'")) { - return quoted.substring(1, quoted.length() - 1); + } else if (quoted.length() > 2 && quoted.startsWith("'") && quoted.indexOf('\'', 1) >= 0) { + return quoted.substring(1, quoted.indexOf('\'', 1)); } return null; } diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index b9d0829aa3a..91d99139c2e 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -57,7 +57,7 @@ void plainLoginWithCorrectCredentialsShouldSucceed() throws IOException { @Test void plainLoginWithWrongPasswordShouldNotSucceed() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD + "wrong"); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD + "wrong"; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -65,7 +65,7 @@ void plainLoginWithWrongPasswordShouldNotSucceed() throws IOException { @Test void plainLoginWithNotExistingUserShouldNotSucceed() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "not-existing" + "\0" + "pwd"); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "not-existing" + "\0" + "pwd"; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -73,7 +73,7 @@ void plainLoginWithNotExistingUserShouldNotSucceed() throws IOException { @Test void plainLoginWithoutPasswordShouldNotSucceed() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0"); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0"; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -81,11 +81,11 @@ void plainLoginWithoutPasswordShouldNotSucceed() throws IOException { // The SASL PLAIN standard (https://datatracker.ietf.org/doc/html/rfc4616) defines the following message: // message = [authzid] UTF8NUL authcid UTF8NUL passwd - // The current code is more lenient. + // The current code is more lenient and accepts the message without the first null byte. @Disabled @Test void plainLoginWithMalformedMessageShouldNotSucceed() throws IOException { - String initialClientResponse = (ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -93,7 +93,7 @@ void plainLoginWithMalformedMessageShouldNotSucceed() throws IOException { @Test void plainLoginWithoutMechanismQuotesShouldNotSucceed() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE PLAIN \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -101,7 +101,7 @@ void plainLoginWithoutMechanismQuotesShouldNotSucceed() throws IOException { @Test void plainLoginWithoutInitialResponseQuotesShouldNotSucceed() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE \"PLAIN\" " + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8))); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); @@ -114,7 +114,7 @@ void plainLoginWithContinuationShouldSucceed() throws IOException { Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("\"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); @@ -130,12 +130,12 @@ void plainLoginWithContinuationCanBeAborted() throws IOException { this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); - Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); } @Test void doubleAuthenticationShouldFail() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; String command = "AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""; this.client.sendCommand(command); @@ -212,7 +212,7 @@ void logoutShouldWorkInAuthenticatedState() throws IOException, InterruptedExcep } void authenticatePlain() throws IOException { - String initialClientResponse = ("\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD); + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java index d331c24f718..b122d8ddf67 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -132,7 +132,7 @@ void oauthbearerLoginWithValidTokenAndContinuationCanBeAborted() throws Exceptio this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); - Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); } @Test @@ -171,7 +171,7 @@ void xoauth2LoginWithValidTokenAndContinuationCanBeAborted() throws Exception { this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); - Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("authentication aborted"); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); } @Test From 066b39df12119da9ee5527befbc25677091d71e5 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 17 Nov 2025 11:02:56 +0100 Subject: [PATCH 11/17] fix(managesieve): Implement continuation as described in RFC-4616 --- .../james/managesieve/scripts/authenticate.test | 9 +++------ .../james/managesieve/scripts/capability.test | 3 +-- .../james/managesieve/scripts/checkscript.test | 3 +-- .../james/managesieve/scripts/deletescript.test | 3 +-- .../james/managesieve/scripts/getscript.test | 3 +-- .../james/managesieve/scripts/havespace.test | 3 +-- .../james/managesieve/scripts/listscripts.test | 3 +-- .../james/managesieve/scripts/putscript.test | 3 +-- .../james/managesieve/scripts/renamescript.test | 3 +-- .../james/managesieve/scripts/setactive.test | 3 +-- .../james/managesieve/scripts/starttls.test | 3 +-- .../managesieve/scripts/unauthenticate.test | 3 +-- .../core/OAUTHAuthenticationProcessor.java | 2 +- .../core/PlainAuthenticationProcessor.java | 2 +- .../managesieveserver/AuthenticateTest.java | 8 ++++---- .../managesieveserver/ManageSieveClient.java | 4 ++++ .../apache/james/managesieveserver/OIDCTest.java | 16 ++++++++-------- 17 files changed, 32 insertions(+), 42 deletions(-) diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test index 9914f24f288..54c8a6140c3 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test @@ -24,19 +24,16 @@ C: AUTHENTICATE "UNKNOWN" S: NO "Unknown SASL mechanism UNKNOWN" C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: GETSCRIPT toto.sieve S: NO "ManageSieve syntax is incorrect: quoted authentication data must be supplied" C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "tin password" S: NO "Authentication failed with: Verification of credentials failed" C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test index 16e6a75e028..095c34db56a 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test @@ -38,8 +38,7 @@ S: "VERSION" "1.0" S: OK C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test index 33cc62e8214..768909990c8 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test @@ -34,8 +34,7 @@ C: S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test index 91658e2b559..be80044cac6 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test @@ -24,8 +24,7 @@ C: DELETESCRIPT "foo" S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test index 8d1359100e0..a04c277122e 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test @@ -24,8 +24,7 @@ C: GETSCRIPT "foo" S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test index 653f60fe433..aaa2d3bfe94 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test @@ -27,8 +27,7 @@ C: HAVESPACE "scriptname" 49 S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test index a59dd4a7007..9f048b42f9c 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test @@ -21,8 +21,7 @@ C: LISTSCRIPTS S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test index 2b9c72e33fe..b8048ce22a4 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test @@ -39,8 +39,7 @@ C: S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test index 1de65f25d00..7b5ff183448 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test @@ -27,8 +27,7 @@ C: RENAMESCRIPT "foo" "bar" S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test index 5a836693f60..c233498a47a 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test @@ -24,8 +24,7 @@ C: SETACTIVE "foo" S: NO C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test index 8220140f7e3..39fdbf3f1dc 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test @@ -24,8 +24,7 @@ C: STARTTLS S: NO You can't enable two time SSL encryption C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test index 21ef39e49fe..92576fdba8d 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test @@ -27,8 +27,7 @@ C: UNAUTHENTICATE S: NO UNAUTHENTICATE command must be issued in authenticated state C: AUTHENTICATE "PLAIN" -S: "" -S: OK +S: \+ "" C: "user password" S: OK diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java index e424d2759ec..ba925141a87 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java @@ -45,7 +45,7 @@ public OAUTHAuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { @Override public String initialServerResponse(Session session) { - return "\"\"\r\nOK"; + return "+ \"\""; } @Override diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java index 1a6164b5d86..1e9e6596380 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java @@ -50,7 +50,7 @@ public PlainAuthenticationProcessor(UsersRepository usersRepository) { @Override public String initialServerResponse(Session session) { - return "\"\"\r\nOK"; + return "+ \"\""; } diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index 91d99139c2e..e9aea8302d6 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -111,8 +111,8 @@ void plainLoginWithoutInitialResponseQuotesShouldNotSucceed() throws IOException void plainLoginWithContinuationShouldSucceed() throws IOException { this.client.sendCommand("AUTHENTICATE \"PLAIN\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("\"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); @@ -124,8 +124,8 @@ void plainLoginWithContinuationShouldSucceed() throws IOException { void plainLoginWithContinuationCanBeAborted() throws IOException { this.client.sendCommand("AUTHENTICATE \"PLAIN\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java index e712489b709..c4f042b7bad 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java @@ -37,6 +37,7 @@ public class ManageSieveClient extends SocketClient { enum ResponseType { BYE, + CONTINUATION, NO, OK; } @@ -90,6 +91,9 @@ public ServerResponse readResponse() throws IOException { } response = new ServerResponse(responseType, responseCode, explanation, lines); + } else if (tokens[0].equals("+")) { + Optional explanation = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + response = new ServerResponse(ResponseType.CONTINUATION, Optional.empty(), explanation, new ArrayList()); } else { lines.addLast(line); } diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java index b122d8ddf67..a07785e77d8 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -114,8 +114,8 @@ void oauthbearerLoginWithValidTokenShouldSucceed() throws Exception { void oauthbearerLoginWithValidTokenAndContinuationShouldSucceed() throws Exception { this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); this.client.sendCommand("\"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); @@ -126,8 +126,8 @@ void oauthbearerLoginWithValidTokenAndContinuationShouldSucceed() throws Excepti void oauthbearerLoginWithValidTokenAndContinuationCanBeAborted() throws Exception { this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); @@ -153,8 +153,8 @@ void xoauth2LoginWithValidTokenShouldSucceed() throws Exception { void xoauth2LoginWithValidTokenAndContinuationShouldSucceed() throws Exception { this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); this.client.sendCommand("\"" + VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); @@ -165,8 +165,8 @@ void xoauth2LoginWithValidTokenAndContinuationShouldSucceed() throws Exception { void xoauth2LoginWithValidTokenAndContinuationCanBeAborted() throws Exception { this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); - Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); - Assertions.assertThat(continuationResponse.responseLines()).containsExactly("\"\""); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); this.client.sendCommand("\"*\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); From 6f14c4977251fa31ecfcac824409d542101045bc Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 17 Nov 2025 11:04:36 +0100 Subject: [PATCH 12/17] fix(oidc): wrong format of gs2-header --- .../org/apache/james/protocols/api/OIDCSASLParser.java | 5 +++++ .../org/apache/james/protocols/api/OIDCSASLHelper.java | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) 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 72df0612141..1623998c954 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 @@ -61,6 +61,7 @@ public static Optional parse(String initialResponse) { Optional decodeResult = decodeBase64(initialResponse); 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()); @@ -81,6 +82,10 @@ public static Optional parse(String initialResponse) { 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++; } } diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java index b806a40acbe..8440a51de04 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java @@ -25,7 +25,7 @@ import com.google.common.collect.ImmutableList; public class OIDCSASLHelper { - // See the XOAUTH2 specification athttps://developers.google.com/workspace/gmail/imap/xoauth2-protocol + // See the XOAUTH2 specification at https://developers.google.com/workspace/gmail/imap/xoauth2-protocol // for details. public static String generateEncodedXOauth2InitialClientResponse(String username, String token) { return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, @@ -33,11 +33,11 @@ public static String generateEncodedXOauth2InitialClientResponse(String username .getBytes(StandardCharsets.US_ASCII)); } - // See the OAUTHBEARER specification at https://datatracker.ietf.org/doc/html/rfc5801#section-4 - // for details. + // See the OAUTHBEARER specification at https://www.rfc-editor.org/rfc/rfc7628.html#section-3.1 + // and the GSS-API specification at https://www.rfc-editor.org/rfc/rfc5801#section-4 for details. public static String generateEncodedOauthbearerInitialClientResponse(String username, String token) { return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, - ImmutableList.of("n,a=" + username, "auth=Bearer " + token, "", "")) + ImmutableList.of("n,a=" + username + ",", "auth=Bearer " + token, "", "")) .getBytes(StandardCharsets.US_ASCII)); } } From f8fd8daf0844c28d3bdbcc9ba5266c19d315d357 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 17 Nov 2025 11:05:08 +0100 Subject: [PATCH 13/17] refactor(managesieve): accept plain authentication without leading null byte --- .../org/apache/james/managesieveserver/AuthenticateTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index e9aea8302d6..dfdce977772 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -82,13 +82,12 @@ void plainLoginWithoutPasswordShouldNotSucceed() throws IOException { // The SASL PLAIN standard (https://datatracker.ietf.org/doc/html/rfc4616) defines the following message: // message = [authzid] UTF8NUL authcid UTF8NUL passwd // The current code is more lenient and accepts the message without the first null byte. - @Disabled @Test - void plainLoginWithMalformedMessageShouldNotSucceed() throws IOException { + void plainLoginWithoutLeadingNullByteShouldSucceed() throws IOException { String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); - Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); } @Test From 9633e474fa37939abdbe86540942cd985eb29e99 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 24 Nov 2025 09:29:32 +0100 Subject: [PATCH 14/17] feat(managesieve): add test for old SASL PLAIN parsing behavior --- .../james/managesieveserver/AuthenticateTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index dfdce977772..05b3a838627 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -90,6 +90,16 @@ void plainLoginWithoutLeadingNullByteShouldSucceed() throws IOException { Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); } + // The SASL PLAIN standard defines UTF8NUL as separator. To stay compatible with older versions of James, + // James is more lenient and also supports a space as the delimiter if the message is not base64-encoded. + @Test + void plainLoginWithSpaceAsDelimiterShouldSucceed() throws IOException { + String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + " " + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + initialClientResponse + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + @Test void plainLoginWithoutMechanismQuotesShouldNotSucceed() throws IOException { String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; From 869ff60421733f3e0cc660ca82b6173f6fae67fc Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 1 Dec 2025 11:08:37 +0100 Subject: [PATCH 15/17] refactor(managesieve): only accept unquoted initial client response during continuation --- .../james/managesieve/scripts/authenticate.test | 11 ++++++++--- .../apache/james/managesieve/scripts/capability.test | 2 +- .../apache/james/managesieve/scripts/checkscript.test | 2 +- .../james/managesieve/scripts/deletescript.test | 2 +- .../apache/james/managesieve/scripts/getscript.test | 2 +- .../apache/james/managesieve/scripts/havespace.test | 2 +- .../apache/james/managesieve/scripts/listscripts.test | 2 +- .../apache/james/managesieve/scripts/putscript.test | 2 +- .../james/managesieve/scripts/renamescript.test | 2 +- .../apache/james/managesieve/scripts/setactive.test | 2 +- .../apache/james/managesieve/scripts/starttls.test | 2 +- .../james/managesieve/scripts/unauthenticate.test | 2 +- .../apache/james/managesieve/core/CoreProcessor.java | 2 +- .../managesieve/transcode/ManageSieveProcessor.java | 4 +++- .../james/managesieveserver/AuthenticateTest.java | 4 ++-- .../org/apache/james/managesieveserver/OIDCTest.java | 8 ++++---- 16 files changed, 29 insertions(+), 22 deletions(-) diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test index 54c8a6140c3..1b03d55e313 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test @@ -26,14 +26,19 @@ S: NO "Unknown SASL mechanism UNKNOWN" C: AUTHENTICATE "PLAIN" S: \+ "" C: GETSCRIPT toto.sieve -S: NO "ManageSieve syntax is incorrect: quoted authentication data must be supplied" +S: NO "Authentication failed with: Verification of credentials failed" + +C: AUTHENTICATE "PLAIN" +S: \+ "" +C: +S: NO "ManageSieve syntax is incorrect: authentication data must be supplied" C: AUTHENTICATE "PLAIN" S: \+ "" -C: "tin password" +C: tin password S: NO "Authentication failed with: Verification of credentials failed" C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test index 095c34db56a..5ae0e4344a3 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test @@ -39,7 +39,7 @@ S: OK C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: CAPABILITY diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test index 768909990c8..b2df42bd631 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test @@ -35,7 +35,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: CHECKSCRIPT {99+} diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test index be80044cac6..6fc30c96205 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: DELETESCRIPT "foo" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test index a04c277122e..9042c1d925e 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: GETSCRIPT "foo" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test index aaa2d3bfe94..2e742f9912b 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test @@ -28,7 +28,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: HAVESPACE "scriptname" 49 diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test index 9f048b42f9c..f470c37fc06 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test @@ -22,7 +22,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: LISTSCRIPTS diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test index b8048ce22a4..e481bfe634a 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test @@ -40,7 +40,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: PUTSCRIPT "mysievescript" {97+} diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test index 7b5ff183448..2472f3b81ca 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test @@ -28,7 +28,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: PUTSCRIPT "mysievescript" {99+} diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test index c233498a47a..e232ef0d606 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: SETACTIVE "foo" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test index 39fdbf3f1dc..6e1526c13d9 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test @@ -25,7 +25,7 @@ S: NO You can't enable two time SSL encryption C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: STARTTLS diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test index 92576fdba8d..b2da7480c04 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test @@ -28,7 +28,7 @@ S: NO UNAUTHENTICATE command must be issued in authenticated state C: AUTHENTICATE "PLAIN" S: \+ "" -C: "user password" +C: user password S: OK C: GETSCRIPT any 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 33cc055fb1c..0e44c9c33df 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 @@ -239,7 +239,7 @@ public String authenticate(Session session, String suppliedData) { SupportedMechanism currentAuthenticationMechanism = session.getChoosedAuthenticationMechanism(); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(currentAuthenticationMechanism); if (Strings.isNullOrEmpty(suppliedData)) { - throw new SyntaxException("quoted authentication data must be supplied"); + throw new SyntaxException("authentication data must be supplied"); } if (suppliedData.equals("*")) { throw new AuthenticationException("authentication aborted by client"); 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 83cca6f8a09..391e9203b9a 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 @@ -115,10 +115,12 @@ private String matchCommandWithImplementation(Session session, String arguments, if (arguments.isEmpty()) { return result; } + // Unquote the argument in this case because continuation is not used. + arguments = ParserUtils.unquoteFirst(arguments); } // The authentication is in progress, the mechanism has been chosen, and the arguments contain an initial client response. - return argumentParser.authenticate(session, ParserUtils.unquoteFirst(arguments)); + return argumentParser.authenticate(session, arguments); } else if (command.equalsIgnoreCase(CAPABILITY)) { return argumentParser.capability(session, arguments); } else if (command.equalsIgnoreCase(CHECKSCRIPT)) { diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index 05b3a838627..dc25cc5f648 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -124,7 +124,7 @@ void plainLoginWithContinuationShouldSucceed() throws IOException { Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; - this.client.sendCommand("\"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + this.client.sendCommand(Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8))); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); } @@ -136,7 +136,7 @@ void plainLoginWithContinuationCanBeAborted() throws IOException { Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); - this.client.sendCommand("\"*\""); + this.client.sendCommand("*"); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java index a07785e77d8..87ca3dbffef 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -117,7 +117,7 @@ void oauthbearerLoginWithValidTokenAndContinuationShouldSucceed() throws Excepti Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); - this.client.sendCommand("\"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + this.client.sendCommand(VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); } @@ -129,7 +129,7 @@ void oauthbearerLoginWithValidTokenAndContinuationCanBeAborted() throws Exceptio Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); - this.client.sendCommand("\"*\""); + this.client.sendCommand("*"); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); @@ -156,7 +156,7 @@ void xoauth2LoginWithValidTokenAndContinuationShouldSucceed() throws Exception { Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); - this.client.sendCommand("\"" + VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + this.client.sendCommand(VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); } @@ -168,7 +168,7 @@ void xoauth2LoginWithValidTokenAndContinuationCanBeAborted() throws Exception { Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); - this.client.sendCommand("\"*\""); + this.client.sendCommand("*"); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); From 12015a02180a9bc383032a3d2d321c40b3c3a22c Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 8 Dec 2025 12:54:51 +0100 Subject: [PATCH 16/17] feat(managesieve): add additional test for usage of spaces as delimiters in SASL PLAIN --- .../apache/james/managesieveserver/AuthenticateTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java index dc25cc5f648..63f9b86ec0c 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -94,6 +94,15 @@ void plainLoginWithoutLeadingNullByteShouldSucceed() throws IOException { // James is more lenient and also supports a space as the delimiter if the message is not base64-encoded. @Test void plainLoginWithSpaceAsDelimiterShouldSucceed() throws IOException { + String initialClientResponse = " " + ManageSieveServerTestSystem.USERNAME.asString() + " " + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + initialClientResponse + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + // This tests the combination of both lenient behaviors above. + @Test + void plainLoginWithSpaceAsDelimiterWithoutLeadingSpaceShouldSucceed() throws IOException { String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + " " + ManageSieveServerTestSystem.PASSWORD; this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + initialClientResponse + "\""); ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); From 8ab414cdbb8e223064ecd38696b183be312ceb97 Mon Sep 17 00:00:00 2001 From: Felix Auringer Date: Mon, 5 Jan 2026 13:45:24 +0100 Subject: [PATCH 17/17] refactor(managesieve): rebase on new oidc token validation --- .../apache/james/managesieve/api/Session.java | 2 +- .../core/OAUTHAuthenticationProcessor.java | 38 +---------------- .../managesieve/util/SettableSession.java | 2 +- .../james/jwt/OidcJwtTokenVerifier.java | 2 +- .../james/jwt/OidcSASLConfiguration.java | 7 +--- .../apache/james/jwt/OidcTokenFixture.java | 1 + .../ManageSieveChannelUpstreamHandler.java | 2 +- .../netty/ManageSieveServer.java | 2 +- .../managesieveserver/ManageSieveClient.java | 4 +- .../james/managesieveserver/OIDCTest.java | 42 +++++++++++++++---- .../test/resources/managesieveserver-oidc.xml | 4 ++ 11 files changed, 51 insertions(+), 55 deletions(-) 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 ca5ed2b2fab..9f1a058f19f 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 @@ -23,8 +23,8 @@ import java.util.Optional; import org.apache.james.core.Username; +import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.managesieve.api.commands.Authenticate; -import org.apache.james.protocols.api.OidcSASLConfiguration; public interface Session { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java index ba925141a87..ebdfe25c332 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java @@ -24,16 +24,13 @@ import org.apache.james.core.Username; import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.jwt.introspection.IntrospectionEndpoint; +import org.apache.james.jwt.OidcSASLConfiguration; 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 OAUTHAuthenticationProcessor implements AuthenticationProcessor { @@ -58,7 +55,7 @@ public Username isAuthenticationSuccesfull(Session session, String suppliedClien Optional authenticatedUserResult = Optional.empty(); try { - authenticatedUserResult = validateToken(oidcInitialResponse.getToken()); + authenticatedUserResult = new OidcJwtTokenVerifier(this.oidcConfiguration).validateToken(oidcInitialResponse.getToken()); } catch (Exception e) { throw new AuthenticationException("Could not validate the JWT"); } @@ -75,35 +72,4 @@ public Username isAuthenticationSuccesfull(Session session, String suppliedClien return authenticatedUser; } - - private Optional 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 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 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/util/SettableSession.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java index f689ffcc2e3..204a39e6881 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 @@ -23,9 +23,9 @@ import java.util.Optional; import org.apache.james.core.Username; +import org.apache.james.jwt.OidcSASLConfiguration; 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 { diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index 7f87132bb7c..28af294b64e 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -104,7 +104,7 @@ private Predicate validateAud(String expectedAud) { } @VisibleForTesting - Publisher verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) { + Publisher verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) { return Mono.fromCallable(() -> verifySignatureAndExtractClaim(jwtToken)) .flatMap(optional -> optional.map(Mono::just).orElseGet(Mono::empty)) .flatMap(claimResult -> Mono.from(CHECK_TOKEN_CLIENT.userInfo(userinfoEndpoint, jwtToken)) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java index cb59ef1811b..0fbc52f46e4 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java @@ -37,9 +37,6 @@ public class OidcSASLConfiguration { private static final Logger LOGGER = LoggerFactory.getLogger(OidcSASLConfiguration.class); - private static final boolean FORCE_INTROSPECT = Boolean.parseBoolean(System.getProperty("james.sasl.oidc.force.introspect", "true")); - private static final boolean VALIDATE_AUD = Boolean.parseBoolean(System.getProperty("james.sasl.oidc.validate.aud", "true")); - @VisibleForTesting static Builder builder() { return new Builder(); @@ -140,7 +137,7 @@ public static OidcSASLConfiguration parse(HierarchicalConfiguration explanation = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + response = new ServerResponse(ResponseType.CONTINUATION, Optional.empty(), explanation, new ArrayList()); } else { lines.addLast(line); } diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java index 87ca3dbffef..fd2cb0b2823 100644 --- a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -30,6 +30,7 @@ import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -85,6 +86,12 @@ public LocalValidation() throws Exception { this.configuration.addProperty("oidc.scope", SCOPE); } + @BeforeAll + void initialSetup() { + System.setProperty("james.sasl.oidc.force.introspect", "false"); + System.setProperty("james.sasl.oidc.validate.aud", "false"); + } + @BeforeEach void setUp() throws Exception { this.testSystem.setUp(this.configuration); @@ -99,8 +106,10 @@ void tearDown() { } @AfterAll - void finalTearDown() { + void finalTeardown() { this.authServer.stop(); + System.clearProperty("james.sasl.oidc.force.introspect"); + System.clearProperty("james.sasl.oidc.validate.aud"); } @Test @@ -204,7 +213,7 @@ void oauthbearerShouldSucceedWhenIntrospectReturnsActiveUser() throws Exception .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody(String.format("{\"active\": true, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + .withBody(String.format("{\"active\": true, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) @@ -216,6 +225,7 @@ void oauthbearerShouldSucceedWhenIntrospectReturnsActiveUser() throws Exception configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -234,7 +244,7 @@ void oauthbearerShouldFailWhenIntrospectReturnsInactiveUser() throws Exception { .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody(String.format("{\"active\": false, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + .withBody(String.format("{\"active\": false, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) @@ -246,6 +256,7 @@ void oauthbearerShouldFailWhenIntrospectReturnsInactiveUser() throws Exception { configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -264,7 +275,7 @@ void oauthbearerShouldFailWhenIntrospectReturnsWrongActiveUser() throws Exceptio .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody(String.format("{\"active\": true, \"%s\": \"%s-wrong\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + .withBody(String.format("{\"active\": true, \"%s\": \"%s-wrong\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) @@ -276,6 +287,7 @@ void oauthbearerShouldFailWhenIntrospectReturnsWrongActiveUser() throws Exceptio configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -294,7 +306,7 @@ void oauthbearerShouldFailWhenIntrospectDoesNotContainActiveField() throws Excep .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + .withBody(String.format("{\"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) @@ -306,6 +318,7 @@ void oauthbearerShouldFailWhenIntrospectDoesNotContainActiveField() throws Excep configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -324,7 +337,7 @@ void oauthbearerShouldFailWhenIntrospectDoesNotContainUserField() throws Excepti .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody("{\"active\": true}", StandardCharsets.UTF_8)); + .withBody(String.format("{\"active\": true, \"aud\": \"%s\"}", OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) @@ -336,6 +349,7 @@ void oauthbearerShouldFailWhenIntrospectDoesNotContainUserField() throws Excepti configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -364,6 +378,7 @@ void oauthbearerShouldFailWhenIntrospectEndpointErrors() throws Exception { configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -382,7 +397,7 @@ void oauthbearerIntrospectionValidationShouldFailWhenLocalValidationFails() thro .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) .respond(HttpResponse.response().withStatusCode(200) .withHeader("Content-Type", "application/json") - .withBody(String.format("{\"active\": true, \"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + .withBody(String.format("{\"active\": true, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); this.authServer .when(HttpRequest.request().withPath(JWKS_URI_PATH)) .respond(HttpResponse.response().withStatusCode(500)); @@ -392,6 +407,7 @@ void oauthbearerIntrospectionValidationShouldFailWhenLocalValidationFails() thro configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); configuration.addProperty("oidc.scope", SCOPE); configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); testSystem.setUp(configuration); ManageSieveClient client = new ManageSieveClient(); @@ -413,12 +429,24 @@ public Userinfo() throws Exception { this.testSystem = new ManageSieveServerTestSystem(); } + @BeforeAll + static void initialSetup() { + System.setProperty("james.sasl.oidc.force.introspect", "false"); + System.setProperty("james.sasl.oidc.validate.aud", "false"); + } + @AfterEach void tearDown() { this.testSystem.manageSieveServer.destroy(); this.authServer.stop(); } + @AfterAll + static void finalTeardown() { + System.clearProperty("james.sasl.oidc.force.introspect"); + System.clearProperty("james.sasl.oidc.validate.aud"); + } + @Test void oauthbearerShouldSucceedWhenUserinfoClaimMatches() throws Exception { this.authServer = ClientAndServer.startClientAndServer(0); diff --git a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml index 9125d16891e..9ed26d01402 100644 --- a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml +++ b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml @@ -12,5 +12,9 @@ sub https://127.0.0.1/realms/test/.well-known/openid-configuration email + + https://127.0.0.1/oidc/introspect + + james