This is an automated email from the ASF dual-hosted git repository. quantranhong1999 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit c1b7fe031ed774ecc691be00bb1433cfdda65f58 Author: Quan Tran <[email protected]> AuthorDate: Tue Jul 7 09:21:18 2026 +0700 JAMES-4210 Add built-in SASL mechanisms module Add protocols-sasl with James-backed PLAIN, OAUTHBEARER, and XOAUTH2 factories. Move password and token validation into mechanisms through JamesSaslAuthenticator, keep OIDC config per server block, and add unit coverage for PLAIN and OIDC behavior. --- pom.xml | 5 + .../apache/james/protocols/api/OIDCSASLParser.java | 60 ++++--- protocols/pom.xml | 1 + protocols/sasl/pom.xml | 66 +++++++ .../sasl/BuiltInSaslMechanismFactories.java | 46 +++++ .../protocols/sasl/JamesSaslAuthenticator.java | 118 +++++++++++++ .../sasl/OauthBearerSaslMechanismFactory.java | 34 ++++ .../protocols/sasl/OidcSaslMechanismFactory.java | 43 +++++ .../protocols/sasl/PlainSaslMechanismFactory.java | 45 +++++ .../sasl/XOauth2SaslMechanismFactory.java | 34 ++++ .../protocols/sasl/oidc/OAuthSaslMechanism.java | 105 +++++++++++ .../protocols/sasl/plain/PlainSaslMechanism.java | 144 +++++++++++++++ .../protocols/sasl/oidc/OidcSaslMechanismTest.java | 175 +++++++++++++++++++ .../sasl/plain/PlainSaslMechanismTest.java | 194 +++++++++++++++++++++ 14 files changed, 1041 insertions(+), 29 deletions(-) diff --git a/pom.xml b/pom.xml index 8c58c28cfd..5611e9c94c 100644 --- a/pom.xml +++ b/pom.xml @@ -2161,6 +2161,11 @@ <artifactId>protocols-pop3</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-sasl</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>${james.protocols.groupId}</groupId> <artifactId>protocols-smtp</artifactId> 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 1623998c95..be557f1cac 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java @@ -58,41 +58,43 @@ public class OIDCSASLParser { } public static Optional<OIDCInitialResponse> parse(String initialResponse) { - Optional<String> decodeResult = decodeBase64(initialResponse); + return decodeBase64(initialResponse) + .flatMap(OIDCSASLParser::parseDecoded); + } - if (decodeResult.isPresent()) { - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - String decodeValueWithoutDanglingPart = decodeResult.filter(value -> value.startsWith("n,")) - .map(value -> value.substring(2)) - .orElse(decodeResult.get()); + public static Optional<OIDCInitialResponse> parseDecoded(String decodedInitialResponse) { + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + String decodeValueWithoutDanglingPart = Optional.of(decodedInitialResponse) + .filter(value -> value.startsWith("n,")) + .map(value -> value.substring(2)) + .orElse(decodedInitialResponse); - StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); - String tokenPart = null; - String userPart = null; - int tokenPartCounter = 0; - int userPartCounter = 0; + StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); + String tokenPart = null; + String userPart = null; + int tokenPartCounter = 0; + int userPartCounter = 0; - while (stringTokenizer.hasMoreTokens()) { - String stringToken = stringTokenizer.nextToken(); - if (stringToken.startsWith(TOKEN_PART_PREFIX)) { - tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); - tokenPartCounter++; - } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { - userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); - userPartCounter++; - } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { - userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - if (userPart.endsWith(",")) { - userPart = userPart.substring(0, userPart.length() - 1); - } - userPartCounter++; + while (stringTokenizer.hasMoreTokens()) { + String stringToken = stringTokenizer.nextToken(); + if (stringToken.startsWith(TOKEN_PART_PREFIX)) { + tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); + tokenPartCounter++; + } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { + userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); + userPartCounter++; + } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { + userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + if (userPart.endsWith(",")) { + userPart = userPart.substring(0, userPart.length() - 1); } + userPartCounter++; } + } - if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { - return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); - } + if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { + return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); } return Optional.empty(); } diff --git a/protocols/pom.xml b/protocols/pom.xml index e374590783..a643b050a3 100644 --- a/protocols/pom.xml +++ b/protocols/pom.xml @@ -42,6 +42,7 @@ <module>managesieve</module> <module>netty</module> <module>pop3</module> + <module>sasl</module> <module>smtp</module> </modules> diff --git a/protocols/sasl/pom.xml b/protocols/sasl/pom.xml new file mode 100644 index 0000000000..ddb94437af --- /dev/null +++ b/protocols/sasl/pom.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.james.protocols</groupId> + <artifactId>protocols</artifactId> + <version>3.10.0-SNAPSHOT</version> + <relativePath>../pom.xml</relativePath> + </parent> + + <artifactId>protocols-sasl</artifactId> + <packaging>jar</packaging> + + <name>Apache James :: Protocols :: SASL implementations</name> + + <dependencies> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>apache-james-mailbox-api</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-jwt</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>testing-base</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-api</artifactId> + </dependency> + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + </dependency> + <dependency> + <groupId>jakarta.inject</groupId> + <artifactId>jakarta.inject-api</artifactId> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-configuration2</artifactId> + </dependency> + </dependencies> +</project> diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java new file mode 100644 index 0000000000..859895f030 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +import com.google.common.collect.ImmutableList; + +public final class BuiltInSaslMechanismFactories { + public static ImmutableList<SaslMechanismFactory> enabledForServer(ImmutableList<SaslMechanismFactory> defaultFactories, + HierarchicalConfiguration<ImmutableNode> serverConfiguration) { + return defaultFactories.stream() + .filter(factory -> isEnabledByDefault(factory, serverConfiguration)) + .collect(ImmutableList.toImmutableList()); + } + + private static boolean isEnabledByDefault(SaslMechanismFactory factory, + HierarchicalConfiguration<ImmutableNode> serverConfiguration) { + if (factory instanceof OidcSaslMechanismFactory) { + return !serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty(); + } + return true; + } + + private BuiltInSaslMechanismFactories() { + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java new file mode 100644 index 0000000000..b253153bde --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java @@ -0,0 +1,118 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.exception.BadCredentialsException; +import org.apache.james.mailbox.exception.ForbiddenDelegationException; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.exception.UserDoesNotExistException; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; + +public class JamesSaslAuthenticator implements SaslAuthenticator { + public static JamesSaslAuthenticator jamesSaslAuthenticator(MailboxManager mailboxManager) { + Authenticator authenticator = (username, password) -> { + try { + return Optional.of(mailboxManager.authenticate(username, password.toString()).withoutDelegation().getUser()); + } catch (BadCredentialsException e) { + return Optional.empty(); + } + }; + Authorizator authorizator = (username, otherUsername) -> { + try { + mailboxManager.authenticate(username).as(otherUsername); + return Authorizator.AuthorizationState.ALLOWED; + } catch (UserDoesNotExistException e) { + return Authorizator.AuthorizationState.UNKNOWN_USER; + } catch (ForbiddenDelegationException | BadCredentialsException e) { + return Authorizator.AuthorizationState.FORBIDDEN; + } + }; + return new JamesSaslAuthenticator(authenticator, authorizator); + } + + private final Authenticator authenticator; + private final Authorizator authorizator; + + @Inject + public JamesSaslAuthenticator(Authenticator authenticator, Authorizator authorizator) { + this.authenticator = authenticator; + this.authorizator = authorizator; + } + + public JamesSaslAuthenticator withExtraAuthorizator(Authorizator extraAuthorizator) { + return new JamesSaslAuthenticator(authenticator, Authorizator.combine(authorizator, extraAuthorizator)); + } + + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional<Username> authorizationId, + String password) { + try { + Optional<Username> authenticatedUser = authenticator.isAuthentic(authenticationId, password); + if (authenticatedUser.isEmpty()) { + return failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, + "Password authentication failed because of bad credentials.")); + } + Username targetUser = authorizationId.orElse(authenticatedUser.get()); + return authorize(new SaslIdentity(authenticatedUser.get(), targetUser)); + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(authenticationId), authorizationId, "Authentication failed.", e)); + } + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + if (identity.authenticationId().equals(identity.authorizationId())) { + return success(identity); + } + + try { + return switch (authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId())) { + case ALLOWED -> success(identity); + case UNKNOWN_USER -> failure(SaslFailure.userDoesNotExist(identity.authenticationId(), identity.authorizationId(), + "Delegation target user does not exist.")); + case FORBIDDEN -> failure(SaslFailure.delegationForbidden(identity.authenticationId(), identity.authorizationId(), + "Requested delegation is forbidden.")); + }; + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(identity.authenticationId()), Optional.of(identity.authorizationId()), + "Authentication failed.", e)); + } + } + + private SaslAuthenticationResult success(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + + private SaslAuthenticationResult failure(SaslFailure failure) { + return new SaslAuthenticationResult.Failure(failure); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java new file mode 100644 index 0000000000..49de4f2e40 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; + +public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, parseVerifier(serverConfiguration)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java new file mode 100644 index 0000000000..3cf55ed170 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +abstract class OidcSaslMechanismFactory implements SaslMechanismFactory { + protected OidcJwtTokenVerifier parseVerifier(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + if (serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty()) { + throw new ConfigurationException("OAuth SASL mechanisms require an auth.oidc configuration"); + } + try { + return new OidcJwtTokenVerifier(OidcSASLConfiguration.parse(serverConfiguration.configurationAt("auth.oidc"))); + } catch (MalformedURLException | URISyntaxException | NullPointerException e) { + throw new ConfigurationException("Failed to retrieve oauth component", e); + } + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java new file mode 100644 index 0000000000..9a3537a772 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java @@ -0,0 +1,45 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; + +public class PlainSaslMechanismFactory implements SaslMechanismFactory { + private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true; + private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true; + + @Override + public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> serverConfiguration) { + return new PlainSaslMechanism(plainAuthEnabled(serverConfiguration), requiresSsl(serverConfiguration)); + } + + protected boolean plainAuthEnabled(HierarchicalConfiguration<ImmutableNode> serverConfiguration) { + return serverConfiguration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT); + } + + protected boolean requiresSsl(HierarchicalConfiguration<ImmutableNode> serverConfiguration) { + return serverConfiguration.getBoolean("auth.requireSSL", + serverConfiguration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java new file mode 100644 index 0000000000..9f9c895b7f --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; + +public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, parseVerifier(serverConfiguration)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java new file mode 100644 index 0000000000..cd2106d1f0 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java @@ -0,0 +1,105 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.oidc; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; + +/** + * OIDC bearer-token SASL mechanism. OAUTHBEARER and XOAUTH2 share the same exchange and only differ by + * their advertised name, so a single implementation is parameterized with the mechanism name. + */ +public class OAuthSaslMechanism implements SaslMechanism { + private final String name; + private final OidcJwtTokenVerifier verifier; + + public OAuthSaslMechanism(String name, OidcJwtTokenVerifier verifier) { + this.name = name; + this.verifier = verifier; + } + + @Override + public String name() { + return name; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new OAuthSaslExchange(request.initialResponse(), authenticator); + } + + private class OAuthSaslExchange implements SaslExchange { + private final Optional<byte[]> initialResponse; + private final SaslAuthenticator authenticator; + + private OAuthSaslExchange(Optional<byte[]> initialResponse, SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + this.authenticator = authenticator; + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII)) + .map(response -> { + Username authorizationId = Username.of(response.getAssociatedUser()); + return verifier.validateToken(response.getToken()) + .map(authenticationId -> authorize(authenticationId, authorizationId)) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.of(authorizationId), "OAuth authentication failed."))); + }) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private SaslStep authorize(Username authenticationId, Username authorizationId) { + SaslAuthenticationResult result = authenticator.authorize(new SaslIdentity(authenticationId, authorizationId)); + return switch (result) { + case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty()); + case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure()); + }; + } + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java new file mode 100644 index 0000000000..96aa8212c1 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java @@ -0,0 +1,144 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.plain; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; + +import com.google.common.collect.ImmutableList; + +public class PlainSaslMechanism implements SaslMechanism { + public static final String NAME = SaslMechanismNames.PLAIN; + + protected record PlainCredentials(Optional<Username> authorizationId, Username authenticationId, String password) { + } + + protected static PlainCredentials credentials(Optional<Username> authorizationId, Username authenticationId, String password) { + return new PlainCredentials(authorizationId, authenticationId, password); + } + + private final boolean enabled; + private final boolean requiresSsl; + + public PlainSaslMechanism() { + this(true, false); + } + + public PlainSaslMechanism(boolean enabled, boolean requiresSsl) { + this.enabled = enabled; + this.requiresSsl = requiresSsl; + } + + @Override + public String name() { + return NAME; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return enabled && (!requiresSsl || channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new PlainSaslExchange(request.initialResponse(), this::parse, authenticator); + } + + /** + * Verifies cleartext credentials directly for protocols whose command already exposes username/password, + * for example IMAP LOGIN. + */ + public SaslStep authenticate(Username authenticationId, String password, SaslAuthenticator authenticator) { + return verify(credentials(Optional.empty(), authenticationId, password), authenticator); + } + + private static class PlainSaslExchange implements SaslExchange { + private final Optional<byte[]> initialResponse; + private final Function<byte[], Optional<PlainCredentials>> credentialsParser; + private final SaslAuthenticator authenticator; + + private PlainSaslExchange(Optional<byte[]> initialResponse, + Function<byte[], Optional<PlainCredentials>> credentialsParser, + SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + this.credentialsParser = credentialsParser; + this.authenticator = authenticator; + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return credentialsParser.apply(clientResponse) + .map(credentials -> verify(credentials, authenticator)) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + } + + protected static SaslStep verify(PlainCredentials credentials, SaslAuthenticator authenticator) { + SaslAuthenticationResult result = authenticator.authenticatePassword( + credentials.authenticationId(), credentials.authorizationId(), credentials.password()); + return switch (result) { + case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty()); + case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure()); + }; + } + + protected Optional<PlainCredentials> parse(byte[] clientResponse) { + ImmutableList<String> tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) + .collect(ImmutableList.toImmutableList()); + + if (tokens.size() == 2) { + return Optional.of(credentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1))); + } + if (tokens.size() == 3) { + Optional<Username> authorizationId = Optional.of(tokens.get(0)) + .filter(value -> !value.isEmpty()) + .map(Username::of); + return Optional.of(credentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2))); + } + return Optional.empty(); + } +} diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java new file mode 100644 index 0000000000..ddc1eeae5f --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java @@ -0,0 +1,175 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.oidc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class OidcSaslMechanismTest { + private static final Username USER = Username.of("[email protected]"); + private static final Username TOKEN_SUBJECT = Username.of("[email protected]"); + private static final String TOKEN = "token"; + + @Test + void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { + // GIVEN a decoded OAUTHBEARER initial response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it returns the authorized identity directly to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty())); + } + + @Test + void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() { + // GIVEN a decoded XOAUTH2 initial response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.XOAUTH2, + Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it exposes the same authorized identity shape + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty())); + } + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN an OIDC SASL exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldFailMalformedResponse() { + // GIVEN a malformed OIDC SASL response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("invalid"))); + + // WHEN the mechanism consumes the response + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it fails before any token validation side effect + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + @Test + void shouldFailWhenTokenIsRejected() { + // GIVEN an OIDC SASL response with an invalid bearer token + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN token validation rejects the token + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, rejectingToken()).start(request, authorizing()).firstStep(); + + // THEN the mechanism returns a typed authentication failure + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.of(USER), "OAuth authentication failed."))); + } + + @Test + void shouldReturnAuthorizationFailure() { + // GIVEN a valid token but a James authorization rule rejecting the requested identity + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + SaslFailure failure = SaslFailure.delegationForbidden(TOKEN_SUBJECT, USER, "forbidden"); + + // WHEN authorization rejects the identity + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + + // THEN the failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + private static OidcJwtTokenVerifier verifyingToken() { + return new TestOidcJwtTokenVerifier(Optional.of(TOKEN_SUBJECT)); + } + + private static OidcJwtTokenVerifier rejectingToken() { + return new TestOidcJwtTokenVerifier(Optional.empty()); + } + + private static SaslAuthenticator authorizing() { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejectingAuthorization(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Failure(failure); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static class TestOidcJwtTokenVerifier extends OidcJwtTokenVerifier { + private final Optional<Username> validateTokenResult; + + private TestOidcJwtTokenVerifier(Optional<Username> validateTokenResult) { + super(null); + this.validateTokenResult = validateTokenResult; + } + + @Override + public Optional<Username> validateToken(String token) { + return validateTokenResult; + } + } +} diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java new file mode 100644 index 0000000000..066c47168b --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java @@ -0,0 +1,194 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.plain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class PlainSaslMechanismTest { + private static final Username AUTHENTICATION_ID = Username.of("[email protected]"); + private static final Username AUTHORIZATION_ID = Username.of("[email protected]"); + private static final String PASSWORD = "secret"; + + private final PlainSaslMechanism testee = new PlainSaslMechanism(); + + @Test + void shouldBeAvailableOnClearTransportByDefault() { + assertThat(testee.isAvailableOnTransport(false)).isTrue(); + } + + @Test + void shouldNotBeAvailableOnClearTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(false)).isFalse(); + } + + @Test + void shouldBeAvailableOnEncryptedTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(true)).isTrue(); + } + + @Test + void shouldNotBeAvailableWhenDisabled() { + assertThat(new PlainSaslMechanism(false, false).isAvailableOnTransport(true)).isFalse(); + } + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN a PLAIN exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = testee.start(request, authenticating()).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldAuthenticateInitialResponseWithoutDelegation() { + // GIVEN a valid PLAIN initial response without an authorization identity + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the initial response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it authenticates through the shared SASL authenticator and returns the authenticated identity + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldAuthenticateContinuationResponseWithDelegation() { + // GIVEN a PLAIN exchange waiting for the client response + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + SaslExchange exchange = testee.start(request, authenticating()); + + // WHEN the client sends a response with an authorization identity + SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)); + + // THEN both identities are preserved after mechanism-owned authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID), Optional.empty())); + } + + @Test + void shouldAcceptTwoPartResponseWithoutAuthorizationIdentity() { + // GIVEN a PLAIN response encoded as authcid/password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes(AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it treats the response as non-delegated authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldPassPasswordUnmodifiedToAuthenticator() { + // GIVEN a PLAIN response whose password is made of whitespace only + AtomicReference<String> capturedPassword = new AtomicReference<>(); + String whitespacePassword = " "; + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + whitespacePassword))); + + // WHEN the mechanism consumes the response + testee.start(request, authenticating(capturedPassword)).firstStep(); + + // THEN the password is kept unchanged instead of being filtered as blank + assertThat(capturedPassword).hasValue(whitespacePassword); + } + + @Test + void shouldReturnAuthenticatorFailure() { + // GIVEN a valid PLAIN response but an authenticator rejecting the password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + SaslFailure failure = SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected"); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, rejecting(failure)).firstStep(); + + // THEN the typed failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + @Test + void shouldFailMalformedResponse() { + // GIVEN a PLAIN response without the expected separators + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("missing-separators"))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it fails before calling protocol-neutral authentication + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private static SaslAuthenticator authenticating() { + return authenticating(new AtomicReference<>()); + } + + private static SaslAuthenticator authenticating(AtomicReference<String> capturedPassword) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + capturedPassword.set(password); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId.orElse(authenticationId))); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejecting(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + return new SaslAuthenticationResult.Failure(failure); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
