You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2022/01/07 07:24:34 UTC
[james-project] 05/05: JAMES-3680 AuthenticateProcessor - implement doSASL for IMAP
This is an automated email from the ASF dual-hosted git repository.
btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 82839949cdfcc89460cc1588cba3b7c776f217c1
Author: Tung Van TRAN <vt...@linagora.com>
AuthorDate: Thu Dec 16 14:56:54 2021 +0700
JAMES-3680 AuthenticateProcessor - implement doSASL for IMAP
---
pom.xml | 6 +
.../james/protocols/api/OidcSASLConfiguration.java | 77 +++++++++++
.../apache/james/protocols/api/OIDCSASLHelper.java | 35 ++---
protocols/imap/pom.xml | 4 +
.../apache/james/imap/api/process/ImapSession.java | 5 +
.../apache/james/imap/encode/FakeImapSession.java | 12 ++
.../imap/processor/AbstractAuthProcessor.java | 6 +
.../imap/processor/AuthenticateProcessor.java | 66 +++++++--
.../imap/processor/DefaultProcessorChain.java | 2 +-
...KeyProvider.java => JwksPublicKeyProvider.java} | 14 +-
.../org/apache/james/jwt/OidcJwtTokenVerifier.java | 31 ++++-
...derTest.java => JwksPublicKeyProviderTest.java} | 10 +-
server/protocols/protocols-imap4/pom.xml | 11 ++
.../apache/james/imapserver/netty/IMAPServer.java | 39 +++++-
.../netty/ImapChannelUpstreamHandler.java | 3 +-
.../james/imapserver/netty/NettyImapSession.java | 19 ++-
.../james/imapserver/netty/IMAPServerTest.java | 148 ++++++++++++++++++++-
.../protocols-imap4/src/test/resources/oauth.xml | 13 ++
18 files changed, 441 insertions(+), 60 deletions(-)
diff --git a/pom.xml b/pom.xml
index 744d7cd..fcd136a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1573,6 +1573,12 @@
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
+ <artifactId>james-server-jwt</artifactId>
+ <version>${project.version}</version>
+ <type>test-jar</type>
+ </dependency>
+ <dependency>
+ <groupId>${james.groupId}</groupId>
<artifactId>james-server-lifecycle-api</artifactId>
<version>${project.version}</version>
</dependency>
diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/OidcSASLConfiguration.java b/protocols/api/src/main/java/org/apache/james/protocols/api/OidcSASLConfiguration.java
new file mode 100644
index 0000000..313bca2
--- /dev/null
+++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OidcSASLConfiguration.java
@@ -0,0 +1,77 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.protocols.api;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+
+import com.google.common.base.Preconditions;
+
+public class OidcSASLConfiguration {
+
+ public static OidcSASLConfiguration parse(HierarchicalConfiguration<ImmutableNode> configuration) throws MalformedURLException {
+ String jwksURL = configuration.getString("jwksURL", null);
+ String claim = configuration.getString("claim", null);
+ String oidcConfigurationURL = configuration.getString("oidcConfigurationURL", null);
+ String scope = configuration.getString("scope", null);
+
+ Preconditions.checkNotNull(jwksURL, "`jwksURL` property need to be specified inside the oidc tag");
+ Preconditions.checkNotNull(claim, "`claim` property need to be specified inside the oidc tag");
+ Preconditions.checkNotNull(oidcConfigurationURL, "`oidcConfigurationURL` property need to be specified inside the oidc tag");
+ Preconditions.checkNotNull(scope, "`scope` property need to be specified inside the oidc tag");
+
+ return new OidcSASLConfiguration(jwksURL, claim, oidcConfigurationURL, scope);
+ }
+
+ private final URL jwksURL;
+ private final String claim;
+ private final URL oidcConfigurationURL;
+ private final String scope;
+
+ public OidcSASLConfiguration(URL jwksURL, String claim, URL oidcConfigurationURL, String scope) {
+ this.jwksURL = jwksURL;
+ this.claim = claim;
+ this.oidcConfigurationURL = oidcConfigurationURL;
+ this.scope = scope;
+ }
+
+ public OidcSASLConfiguration(String jwksURL, String claim, String oidcConfigurationURL, String scope) throws MalformedURLException {
+ this(new URL(jwksURL), claim, new URL(oidcConfigurationURL), scope);
+ }
+
+ public URL getJwksURL() {
+ return jwksURL;
+ }
+
+ public String getClaim() {
+ return claim;
+ }
+
+ public URL getOidcConfigurationURL() {
+ return oidcConfigurationURL;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+}
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenHelper.java b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java
similarity index 52%
rename from server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenHelper.java
rename to protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java
index a97ad09..1436649 100644
--- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenHelper.java
+++ b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java
@@ -17,34 +17,17 @@
* under the License. *
****************************************************************/
-package org.apache.james.jwt;
+package org.apache.james.protocols.api;
-import java.util.Optional;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
-import io.jsonwebtoken.Claims;
-import io.jsonwebtoken.Header;
-import io.jsonwebtoken.Jwt;
-import io.jsonwebtoken.JwtException;
-import io.jsonwebtoken.Jwts;
-import io.jsonwebtoken.MalformedJwtException;
+import com.google.common.collect.ImmutableList;
-public class JwtTokenHelper {
-
- public static <T> Optional<T> getClaimWithoutSignatureVerification(String token, String claimName, Class<T> returnType) {
- int signatureIndex = token.lastIndexOf('.');
- if (signatureIndex <= 0) {
- return Optional.empty();
- }
- String nonSignedToken = token.substring(0, signatureIndex + 1);
- try {
- Jwt<Header, Claims> headerClaims = Jwts.parser().parseClaimsJwt(nonSignedToken);
- T claim = (T) headerClaims.getHeader().get(claimName);
- if (claim == null) {
- throw new MalformedJwtException("'" + claimName + "' field in token is mandatory");
- }
- return Optional.of(claim);
- } catch (JwtException e) {
- return Optional.empty();
- }
+public class OIDCSASLHelper {
+ public static String generateOauthBearer(String username, String token) {
+ return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR,
+ ImmutableList.of("user=" + username, "auth=Bearer " + token, "", ""))
+ .getBytes(StandardCharsets.US_ASCII));
}
}
diff --git a/protocols/imap/pom.xml b/protocols/imap/pom.xml
index d462ac7..9642012 100644
--- a/protocols/imap/pom.xml
+++ b/protocols/imap/pom.xml
@@ -62,6 +62,10 @@
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
+ <artifactId>james-server-jwt</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>${james.groupId}</groupId>
<artifactId>james-server-util</artifactId>
</dependency>
<dependency>
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
index 31293bc..6c3ac4a 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java
@@ -27,6 +27,7 @@ import org.apache.james.core.Username;
import org.apache.james.imap.api.ImapSessionState;
import org.apache.james.mailbox.MailboxSession;
import org.apache.james.protocols.api.CommandDetectionSession;
+import org.apache.james.protocols.api.OidcSASLConfiguration;
/**
* Encapsulates all state held for an ongoing Imap session, which commences when
@@ -219,6 +220,10 @@ public interface ImapSession extends CommandDetectionSession {
*/
boolean isPlainAuthEnabled();
+ boolean supportsOAuth();
+
+ Optional<OidcSASLConfiguration> oidcSaslConfiguration();
+
default void setMailboxSession(MailboxSession mailboxSession) {
setAttribute(MAILBOX_SESSION_ATTRIBUTE_SESSION_KEY, mailboxSession);
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
index 83c2c67..153f615 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java
@@ -19,12 +19,14 @@
package org.apache.james.imap.encode;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.james.imap.api.ImapSessionState;
import org.apache.james.imap.api.process.ImapLineHandler;
import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.api.process.SelectedMailbox;
+import org.apache.james.protocols.api.OidcSASLConfiguration;
public class FakeImapSession implements ImapSession {
@@ -155,6 +157,16 @@ public class FakeImapSession implements ImapSession {
}
@Override
+ public boolean supportsOAuth() {
+ return false;
+ }
+
+ @Override
+ public Optional<OidcSASLConfiguration> oidcSaslConfiguration() {
+ return Optional.empty();
+ }
+
+ @Override
public boolean isTLSActive() {
return false;
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
index f8af8a1..cf4deec 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java
@@ -166,6 +166,12 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr
return new AuthenticationAttempt(Optional.empty(), authenticationId, password);
}
+ protected void authSuccess(Username username, ImapSession session, ImapRequest request, Responder responder) {
+ session.authenticated();
+ session.setMailboxSession(getMailboxManager().createSystemSession(username));
+ okComplete(request, responder);
+ }
+
protected static class AuthenticationAttempt {
private final Optional<Username> delegateUserName;
private final Username authenticationId;
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
index 6ce9764..c5303c7 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java
@@ -24,8 +24,12 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
+import java.util.Optional;
import java.util.StringTokenizer;
+import javax.mail.internet.AddressException;
+
+import org.apache.james.core.MailAddress;
import org.apache.james.core.Username;
import org.apache.james.imap.api.display.HumanReadableText;
import org.apache.james.imap.api.message.Capability;
@@ -36,8 +40,10 @@ import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.message.request.AuthenticateRequest;
import org.apache.james.imap.message.request.IRAuthenticateRequest;
import org.apache.james.imap.message.response.AuthenticateResponse;
+import org.apache.james.jwt.OidcJwtTokenVerifier;
import org.apache.james.mailbox.MailboxManager;
import org.apache.james.metrics.api.MetricFactory;
+import org.apache.james.protocols.api.OIDCSASLParser;
import org.apache.james.util.MDCBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -49,17 +55,21 @@ import com.google.common.collect.ImmutableList;
*/
public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateRequest> implements CapabilityImplementingProcessor {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class);
- private static final String PLAIN = "PLAIN";
-
+ private static final String AUTH_TYPE_PLAIN = "PLAIN";
+ private static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER";
+ private static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2";
+ private static final List<Capability> OAUTH_CAPABILITIES = ImmutableList.of(Capability.of("AUTH=" + AUTH_TYPE_OAUTHBEARER), Capability.of("AUTH=" + AUTH_TYPE_XOAUTH2));
+
public AuthenticateProcessor(ImapProcessor next, MailboxManager mailboxManager, StatusResponseFactory factory,
- MetricFactory metricFactory) {
+ MetricFactory metricFactory) {
super(AuthenticateRequest.class, next, mailboxManager, factory, metricFactory);
}
@Override
protected void processRequest(AuthenticateRequest request, ImapSession session, final Responder responder) {
final String authType = request.getAuthType();
- if (authType.equalsIgnoreCase(PLAIN)) {
+
+ if (authType.equalsIgnoreCase(AUTH_TYPE_PLAIN)) {
// See if AUTH=PLAIN is allowed. See IMAP-304
if (session.isPlainAuthDisallowed()) {
no(request, responder, HumanReadableText.DISABLED_LOGIN);
@@ -70,16 +80,23 @@ public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq
} else {
responder.respond(new AuthenticateResponse());
session.pushLineHandler((requestSession, data) -> {
- // cut of the CRLF
- String initialClientResponse = new String(data, 0, data.length - 2, StandardCharsets.US_ASCII);
-
- doPlainAuth(initialClientResponse, requestSession, request, responder);
-
+ doPlainAuth(extractInitialClientResponse(data), requestSession, request, responder);
// remove the handler now
requestSession.popLineHandler();
});
}
}
+ } else if (authType.equalsIgnoreCase(AUTH_TYPE_OAUTHBEARER) || authType.equalsIgnoreCase(AUTH_TYPE_XOAUTH2)) {
+ if (request instanceof IRAuthenticateRequest) {
+ IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request;
+ doOAuth(irRequest.getInitialClientResponse(), session, request, responder);
+ } else {
+ responder.respond(new AuthenticateResponse());
+ session.pushLineHandler((requestSession, data) -> {
+ doOAuth(extractInitialClientResponse(data), requestSession, request, responder);
+ requestSession.popLineHandler();
+ });
+ }
} else {
LOGGER.debug("Unsupported authentication mechanism '{}'", authType);
no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM);
@@ -145,6 +162,9 @@ public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq
}
// Support for SASL-IR. See RFC4959
caps.add(Capability.of("SASL-IR"));
+ if (session.supportsOAuth()) {
+ caps.addAll(OAUTH_CAPABILITIES);
+ }
return ImmutableList.copyOf(caps);
}
@@ -155,4 +175,32 @@ public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq
.addToContext("authType", request.getAuthType())
.build();
}
+
+ private void doOAuth(String initialResponse, ImapSession session, ImapRequest request, Responder responder) {
+ if (!session.supportsOAuth()) {
+ no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM);
+ } else {
+ OIDCSASLParser.parse(initialResponse)
+ .flatMap(oidcInitialResponseValue -> session.oidcSaslConfiguration()
+ .flatMap(configuration -> new OidcJwtTokenVerifier().verifyAndExtractClaim(oidcInitialResponseValue.getToken(), configuration.getJwksURL(), configuration.getClaim())))
+ .flatMap(this::extractUserFromClaim)
+ .ifPresentOrElse(username -> authSuccess(username, session, request, responder),
+ () -> manageFailureCount(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED));
+ }
+ session.stopDetectingCommandInjection();
+ }
+
+ private Optional<Username> extractUserFromClaim(String claimValue) {
+ try {
+ return Optional.of(Username.fromMailAddress(new MailAddress(claimValue)));
+ } catch (AddressException e) {
+ return Optional.empty();
+ }
+ }
+
+ private static String extractInitialClientResponse(byte[] data) {
+ // cut of the CRLF
+ return new String(data, 0, data.length - 2, StandardCharsets.US_ASCII);
+ }
+
}
diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessorChain.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessorChain.java
index 41a2422..9ad9ea6 100644
--- a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessorChain.java
+++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessorChain.java
@@ -130,7 +130,7 @@ public class DefaultProcessorChain {
// announce the COMPRESS extension. Sew RFC4978
capabilityProcessor.addProcessor(compressProcessor);
- // add to announnce AUTH=PLAIN
+ // add to announnce AUTH=PLAIN, AUTH=OAUTHBEARER
capabilityProcessor.addProcessor(authenticateProcessor);
// add to announnce ENABLE
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwksPublicKeyProvider.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java
similarity index 87%
rename from server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwksPublicKeyProvider.java
rename to server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java
index 5d0d59a..0074981 100644
--- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwksPublicKeyProvider.java
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java
@@ -35,21 +35,21 @@ import com.github.fge.lambdas.Throwing;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
-public class OidcJwksPublicKeyProvider implements PublicKeyProvider {
- private static final Logger LOGGER = LoggerFactory.getLogger(OidcJwksPublicKeyProvider.class);
+public class JwksPublicKeyProvider implements PublicKeyProvider {
+ private static final Logger LOGGER = LoggerFactory.getLogger(JwksPublicKeyProvider.class);
- public static OidcJwksPublicKeyProvider of(URL jwksURL, String kid) {
- return new OidcJwksPublicKeyProvider(jwksURL, Optional.of(kid));
+ public static JwksPublicKeyProvider of(URL jwksURL, String kid) {
+ return new JwksPublicKeyProvider(jwksURL, Optional.of(kid));
}
- public static OidcJwksPublicKeyProvider of(URL jwksURL) {
- return new OidcJwksPublicKeyProvider(jwksURL, Optional.empty());
+ public static JwksPublicKeyProvider of(URL jwksURL) {
+ return new JwksPublicKeyProvider(jwksURL, Optional.empty());
}
private final UrlJwkProvider jwkProvider;
private final Optional<String> kid;
- private OidcJwksPublicKeyProvider(URL jwksURL, Optional<String> kid) {
+ private JwksPublicKeyProvider(URL jwksURL, Optional<String> kid) {
Preconditions.checkNotNull(jwksURL);
Preconditions.checkNotNull(kid);
this.jwkProvider = new UrlJwkProvider(jwksURL);
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 b1a1923..c3d4541 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
@@ -22,12 +22,37 @@ package org.apache.james.jwt;
import java.net.URL;
import java.util.Optional;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Header;
+import io.jsonwebtoken.Jwt;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+
public class OidcJwtTokenVerifier {
public Optional<String> verifyAndExtractClaim(String jwtToken, URL jwksURL, String claimName) {
- PublicKeyProvider jwksPublicKeyProvider = JwtTokenHelper.getClaimWithoutSignatureVerification(jwtToken, "kid", String.class)
- .map(kidValue -> OidcJwksPublicKeyProvider.of(jwksURL, kidValue))
- .orElse(OidcJwksPublicKeyProvider.of(jwksURL));
+ PublicKeyProvider jwksPublicKeyProvider = getClaimWithoutSignatureVerification(jwtToken, "kid", String.class)
+ .map(kidValue -> JwksPublicKeyProvider.of(jwksURL, kidValue))
+ .orElse(JwksPublicKeyProvider.of(jwksURL));
return new JwtTokenVerifier(jwksPublicKeyProvider).verifyAndExtractClaim(jwtToken, claimName, String.class);
}
+
+ public static <T> Optional<T> getClaimWithoutSignatureVerification(String token, String claimName, Class<T> returnType) {
+ int signatureIndex = token.lastIndexOf('.');
+ if (signatureIndex <= 0) {
+ return Optional.empty();
+ }
+ String nonSignedToken = token.substring(0, signatureIndex + 1);
+ try {
+ Jwt<Header, Claims> headerClaims = Jwts.parser().parseClaimsJwt(nonSignedToken);
+ T claim = (T) headerClaims.getHeader().get(claimName);
+ if (claim == null) {
+ throw new MalformedJwtException("'" + claimName + "' field in token is mandatory");
+ }
+ return Optional.of(claim);
+ } catch (JwtException e) {
+ return Optional.empty();
+ }
+ }
}
diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwksPublicKeyProviderTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java
similarity index 93%
rename from server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwksPublicKeyProviderTest.java
rename to server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java
index 6c6d613..4d1033b 100644
--- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwksPublicKeyProviderTest.java
+++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java
@@ -36,7 +36,7 @@ import org.mockserver.integration.ClientAndServer;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
-class OidcJwksPublicKeyProviderTest {
+class JwksPublicKeyProviderTest {
private static final String JWKS_URI_PATH = "/auth/realms/realm1/protocol/openid-connect/certs";
private static final String JWKS_JSON = "{" +
" \"keys\": [" +
@@ -91,7 +91,7 @@ class OidcJwksPublicKeyProviderTest {
@Test
void getShouldSuccessWhenKeyProvided() {
- PublicKeyProvider testee = OidcJwksPublicKeyProvider.of(getJwksURL(), "wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q");
+ PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL(), "wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q");
List<PublicKey> publicKeys = testee.get();
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(publicKeys).hasSize(1);
@@ -101,7 +101,7 @@ class OidcJwksPublicKeyProviderTest {
@Test
void getShouldReturnEmptyWhenKeyNotProvided() {
- PublicKeyProvider testee = OidcJwksPublicKeyProvider.of(getJwksURL(), "notfound");
+ PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL(), "notfound");
assertThat(testee.get()).isEmpty();
}
@@ -112,7 +112,7 @@ class OidcJwksPublicKeyProviderTest {
.respond(HttpResponse.response().withStatusCode(200)
.withBody("invalid body", StandardCharsets.UTF_8));
- PublicKeyProvider testee = OidcJwksPublicKeyProvider.of(new URL(String.format("http://127.0.0.1:%s/invalid", mockServer.getLocalPort())),
+ PublicKeyProvider testee = JwksPublicKeyProvider.of(new URL(String.format("http://127.0.0.1:%s/invalid", mockServer.getLocalPort())),
"wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q");
assertThatThrownBy(testee::get)
.isInstanceOf(MissingOrInvalidKeyException.class);
@@ -120,7 +120,7 @@ class OidcJwksPublicKeyProviderTest {
@Test
void getShouldReturnAllPublicKeyWhenKidNoProvided() {
- PublicKeyProvider testee = OidcJwksPublicKeyProvider.of(getJwksURL());
+ PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL());
assertThat(testee.get())
.hasSize(2);
}
diff --git a/server/protocols/protocols-imap4/pom.xml b/server/protocols/protocols-imap4/pom.xml
index 851cb44..0308f96 100644
--- a/server/protocols/protocols-imap4/pom.xml
+++ b/server/protocols/protocols-imap4/pom.xml
@@ -67,6 +67,12 @@
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
+ <artifactId>james-server-jwt</artifactId>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>${james.groupId}</groupId>
<artifactId>james-server-protocols-library</artifactId>
</dependency>
<dependency>
@@ -134,6 +140,11 @@
<artifactId>commons-configuration2</artifactId>
</dependency>
<dependency>
+ <groupId>org.mock-server</groupId>
+ <artifactId>mockserver-netty</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
index 09feae4..e3cc52e 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java
@@ -20,6 +20,7 @@ package org.apache.james.imapserver.netty;
import static org.jboss.netty.channel.Channels.pipeline;
+import java.net.MalformedURLException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -34,6 +35,7 @@ import org.apache.james.imap.api.process.ImapProcessor;
import org.apache.james.imap.decode.ImapDecoder;
import org.apache.james.imap.encode.ImapEncoder;
import org.apache.james.protocols.api.Encryption;
+import org.apache.james.protocols.api.OidcSASLConfiguration;
import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer;
import org.apache.james.protocols.netty.ChannelGroupHandler;
import org.apache.james.protocols.netty.ChannelHandlerFactory;
@@ -65,11 +67,26 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC
public static class AuthenticationConfiguration {
private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true;
private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true;
-
- public static AuthenticationConfiguration parse(HierarchicalConfiguration<ImmutableNode> configuration) {
- return new AuthenticationConfiguration(
- configuration.getBoolean("auth.requireSSL", fallback(configuration)),
- configuration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT));
+ private static final String OAUTH_PATH = "auth.oauth";
+
+ public static AuthenticationConfiguration parse(HierarchicalConfiguration<ImmutableNode> configuration) throws ConfigurationException {
+ boolean isRequireSSL = configuration.getBoolean("auth.requireSSL", fallback(configuration));
+ boolean isPlainAuthEnabled = configuration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT);
+
+ if (configuration.immutableConfigurationsAt(OAUTH_PATH).isEmpty()) {
+ return new AuthenticationConfiguration(
+ isRequireSSL,
+ isPlainAuthEnabled);
+ } else {
+ try {
+ return new AuthenticationConfiguration(
+ isRequireSSL,
+ isPlainAuthEnabled,
+ OidcSASLConfiguration.parse(configuration.configurationAt(OAUTH_PATH)));
+ } catch (MalformedURLException | NullPointerException exception) {
+ throw new ConfigurationException("Failed to retrieve oauth component", exception);
+ }
+ }
}
private static boolean fallback(HierarchicalConfiguration<ImmutableNode> configuration) {
@@ -78,10 +95,18 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC
private final boolean isSSLRequired;
private final boolean plainAuthEnabled;
+ private final Optional<OidcSASLConfiguration> oidcSASLConfiguration;
public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled) {
this.isSSLRequired = isSSLRequired;
this.plainAuthEnabled = plainAuthEnabled;
+ this.oidcSASLConfiguration = Optional.empty();
+ }
+
+ public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled, OidcSASLConfiguration oidcSASLConfiguration) {
+ this.isSSLRequired = isSSLRequired;
+ this.plainAuthEnabled = plainAuthEnabled;
+ this.oidcSASLConfiguration = Optional.of(oidcSASLConfiguration);
}
public boolean isSSLRequired() {
@@ -91,6 +116,10 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC
public boolean isPlainAuthEnabled() {
return plainAuthEnabled;
}
+
+ public Optional<OidcSASLConfiguration> getOidcSASLConfiguration() {
+ return oidcSASLConfiguration;
+ }
}
private static final String softwaretype = "JAMES " + VERSION + " Server ";
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
index 65949ab..1f87867 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java
@@ -145,7 +145,8 @@ public class ImapChannelUpstreamHandler extends SimpleChannelUpstreamHandler imp
@Override
public void channelBound(final ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception {
ImapSession imapsession = new NettyImapSession(ctx.getChannel(), secure, compress, authenticationConfiguration.isSSLRequired(),
- authenticationConfiguration.isPlainAuthEnabled(), SessionId.generate());
+ authenticationConfiguration.isPlainAuthEnabled(), SessionId.generate(),
+ authenticationConfiguration.getOidcSASLConfiguration());
MDCBuilder boundMDC = IMAPMDCContext.boundMDC(ctx);
imapsession.setAttribute(MDC_KEY, boundMDC);
attributes.set(ctx.getChannel(), imapsession);
diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
index 0148a6c..b582066 100644
--- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
+++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java
@@ -20,12 +20,14 @@ package org.apache.james.imapserver.netty;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.apache.james.imap.api.ImapSessionState;
import org.apache.james.imap.api.process.ImapLineHandler;
import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.api.process.SelectedMailbox;
import org.apache.james.protocols.api.Encryption;
+import org.apache.james.protocols.api.OidcSASLConfiguration;
import org.jboss.netty.channel.Channel;
import org.jboss.netty.handler.codec.compression.ZlibDecoder;
import org.jboss.netty.handler.codec.compression.ZlibEncoder;
@@ -44,8 +46,11 @@ public class NettyImapSession implements ImapSession, NettyConstants {
private final boolean plainAuthEnabled;
private final SessionId sessionId;
private boolean needsCommandInjectionDetection;
+ private Optional<OidcSASLConfiguration> oidcSASLConfiguration;
+ private boolean supportsOAuth;
- public NettyImapSession(Channel channel, Encryption secure, boolean compress, boolean requiredSSL, boolean plainAuthEnabled, SessionId sessionId) {
+ public NettyImapSession(Channel channel, Encryption secure, boolean compress, boolean requiredSSL, boolean plainAuthEnabled, SessionId sessionId,
+ Optional<OidcSASLConfiguration> oidcSASLConfiguration) {
this.channel = channel;
this.secure = secure;
this.compress = compress;
@@ -53,6 +58,8 @@ public class NettyImapSession implements ImapSession, NettyConstants {
this.plainAuthEnabled = plainAuthEnabled;
this.sessionId = sessionId;
this.needsCommandInjectionDetection = true;
+ this.oidcSASLConfiguration = oidcSASLConfiguration;
+ this.supportsOAuth = oidcSASLConfiguration.isPresent();
}
@Override
@@ -208,6 +215,16 @@ public class NettyImapSession implements ImapSession, NettyConstants {
}
@Override
+ public boolean supportsOAuth() {
+ return supportsOAuth;
+ }
+
+ @Override
+ public Optional<OidcSASLConfiguration> oidcSaslConfiguration() {
+ return oidcSASLConfiguration;
+ }
+
+ @Override
public boolean isTLSActive() {
return channel.getPipeline().get(SSL_HANDLER) != null;
}
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 abddd7c..5fa8cb6 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
@@ -26,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.EOFException;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.Properties;
import javax.mail.Folder;
@@ -39,13 +40,17 @@ import javax.mail.search.RecipientStringTerm;
import javax.mail.search.SearchTerm;
import javax.mail.search.SubjectTerm;
+import org.apache.commons.configuration2.HierarchicalConfiguration;
import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.commons.net.imap.AuthenticatingIMAPClient;
import org.apache.commons.net.imap.IMAPReply;
import org.apache.commons.net.imap.IMAPSClient;
import org.apache.james.core.Username;
import org.apache.james.imap.encode.main.DefaultImapEncoderFactory;
import org.apache.james.imap.main.DefaultImapDecoderFactory;
import org.apache.james.imap.processor.main.DefaultImapProcessorFactory;
+import org.apache.james.jwt.OidcTokenFixture;
import org.apache.james.mailbox.MailboxSession;
import org.apache.james.mailbox.MessageManager;
import org.apache.james.mailbox.inmemory.InMemoryMailboxManager;
@@ -55,6 +60,7 @@ import org.apache.james.mailbox.store.FakeAuthenticator;
import org.apache.james.mailbox.store.FakeAuthorizator;
import org.apache.james.mailbox.store.StoreSubscriptionManager;
import org.apache.james.metrics.tests.RecordingMetricFactory;
+import org.apache.james.protocols.api.OIDCSASLHelper;
import org.apache.james.protocols.api.utils.BogusSslContextFactory;
import org.apache.james.protocols.api.utils.BogusTrustManagerFactory;
import org.apache.james.protocols.lib.mock.ConfigLoader;
@@ -69,6 +75,9 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.HttpRequest;
+import org.mockserver.model.HttpResponse;
import nl.altindag.ssl.exception.GenericKeyStoreException;
import nl.altindag.ssl.exception.PrivateKeyParseException;
@@ -85,7 +94,7 @@ class IMAPServerTest {
@RegisterExtension
public TestIMAPClient testIMAPClient = new TestIMAPClient();
- private IMAPServer createImapServer(String configurationFile) throws Exception {
+ private IMAPServer createImapServer(HierarchicalConfiguration<ImmutableNode> config) throws Exception {
FakeAuthenticator authenticator = new FakeAuthenticator();
authenticator.addUser(USER, USER_PASS);
authenticator.addUser(USER2, USER_PASS);
@@ -121,12 +130,16 @@ class IMAPServerTest {
FileSystemImpl fileSystem = new FileSystemImpl(configuration.directories());
imapServer.setFileSystem(fileSystem);
- imapServer.configure(ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream(configurationFile)));
+ imapServer.configure(config);
imapServer.init();
return imapServer;
}
+ private IMAPServer createImapServer(String configurationFile) throws Exception {
+ return createImapServer(ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream(configurationFile)));
+ }
+
@Nested
class NoLimit {
IMAPServer imapServer;
@@ -616,6 +629,137 @@ class IMAPServerTest {
}
@Nested
+ class Oauth {
+ String JWKS_URI_PATH = "/jwks";
+ ClientAndServer authServer;
+ IMAPServer imapServer;
+ int port;
+
+ @BeforeEach
+ void authSetup() throws Exception {
+ authServer = ClientAndServer.startClientAndServer(0);
+ 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<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("oauth.xml"));
+ config.addProperty("auth.oauth.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oauth.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oauth.oidcConfigurationURL", "https://example.com/jwks");
+ config.addProperty("auth.oauth.scope", "email");
+ imapServer = createImapServer(config);
+ port = imapServer.getListenAddresses().get(0).getPort();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (imapServer != null) {
+ imapServer.destroy();
+ }
+ authServer.stop();
+ }
+
+ @Test
+ void oauthShouldSuccessWhenValidToken() throws Exception {
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed.");
+ }
+
+ @Test
+ void oauthShouldFailWhenInValidToken() throws Exception {
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER invalidtoken");
+ assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed.");
+ }
+
+ @Test
+ void oauthShouldFailWhenConfigIsNotProvided() throws Exception {
+ imapServer.destroy();
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("imapServerRequireSSLIsTrueAndStartSSLIsTrue.xml"));
+ imapServer = createImapServer(config);
+ int port = imapServer.getListenAddresses().get(0).getPort();
+
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + OidcTokenFixture.VALID_TOKEN);
+ assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed. Authentication mechanism is unsupported.");
+ }
+
+ @Test
+ void capabilityShouldAdvertiseOAUTHBEARERWhenConfigIsProvided() throws Exception {
+ IMAPSClient client = imapsClient(port);
+ client.capability();
+ assertThat(client.getReplyString()).contains("AUTH=OAUTHBEARER");
+ }
+
+ @Test
+ void capabilityShouldAdvertiseXOAUTH2WhenConfigIsProvided() throws Exception {
+ IMAPSClient client = imapsClient(port);
+ client.capability();
+ assertThat(client.getReplyString()).contains("AUTH=XOAUTH2");
+ }
+
+ @Test
+ void oauthShouldSupportOAUTH2Type() throws Exception {
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE XOAUTH2 " + oauthBearer);
+ assertThat(client.getReplyString()).contains("OK AUTHENTICATE completed.");
+ }
+
+ @Test
+ void capabilityShouldNotAdvertiseOAUTHBEARERWhenConfigIsNotProvided() throws Exception {
+ imapServer.destroy();
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("imapServerRequireSSLIsTrueAndStartSSLIsTrue.xml"));
+ imapServer = createImapServer(config);
+ int port = imapServer.getListenAddresses().get(0).getPort();
+
+ IMAPSClient client = imapsClient(port);
+ client.capability();
+ assertThat(client.getReplyString()).doesNotContain("AUTH=OAUTHBEARER");
+ assertThat(client.getReplyString()).doesNotContain("AUTH=XOAUTH2");
+ }
+
+ @Test
+ void shouldNotOauthWhenAuthIsReady() throws Exception {
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed. Command not valid in this state.");
+ }
+
+ @Test
+ void appendShouldSuccessWhenAuthenticated() throws Exception {
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient imapsClient = imapsClient(port);
+ imapsClient.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ imapsClient.create("INBOX");
+ imapsClient.append("INBOX", null, null, SMALL_MESSAGE);
+
+ assertThat(imapsClient.getReplyString()).contains("APPEND completed.");
+ }
+
+ @Test
+ void appendShouldFailWhenNotAuthenticated() throws Exception {
+ IMAPSClient imapsClient = imapsClient(port);
+ imapsClient.create("INBOX");
+ assertThat(imapsClient.getReplyString()).contains("Command not valid in this state.");
+ }
+ }
+
+ private AuthenticatingIMAPClient imapsClient(int port) throws Exception {
+ AuthenticatingIMAPClient client = new AuthenticatingIMAPClient(false, BogusSslContextFactory.getClientContext());
+ client.setTrustManager(BogusTrustManagerFactory.getTrustManagers()[0]);
+ client.connect("127.0.0.1", port);
+ client.execTLS();
+ return client;
+ }
+
+ @Nested
class Search {
IMAPServer imapServer;
private int port;
diff --git a/server/protocols/protocols-imap4/src/test/resources/oauth.xml b/server/protocols/protocols-imap4/src/test/resources/oauth.xml
new file mode 100644
index 0000000..50a720a
--- /dev/null
+++ b/server/protocols/protocols-imap4/src/test/resources/oauth.xml
@@ -0,0 +1,13 @@
+<imapserver enabled="true">
+ <jmxName>imapserver</jmxName>
+ <bind>0.0.0.0:9993</bind>
+ <tls socketTLS="false" startTLS="true">
+ <privateKey>private.key</privateKey>
+ <certificates>certs.self-signed.csr</certificates>
+ <secret>123456</secret>
+ </tls>
+ <auth>
+ <plainAuthEnabled>true</plainAuthEnabled>
+ <requireSSL>true</requireSSL>
+ </auth>
+</imapserver>
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org