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/05/24 01:55:09 UTC
[james-project] branch master updated: JAMES-3755 IMAP/SMTP OIDC token introspection (#1006)
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
The following commit(s) were added to refs/heads/master by this push:
new f1622736a6 JAMES-3755 IMAP/SMTP OIDC token introspection (#1006)
f1622736a6 is described below
commit f1622736a671221b63e21f7c8a9f7f5c5dee66a3
Author: vttran <vt...@linagora.com>
AuthorDate: Tue May 24 08:55:05 2022 +0700
JAMES-3755 IMAP/SMTP OIDC token introspection (#1006)
---
examples/oidc/james/imapserver.xml | 4 +
examples/oidc/james/smtpserver.xml | 4 +
.../james/protocols/api/OidcSASLConfiguration.java | 34 +++-
.../imap/processor/AuthenticateProcessor.java | 17 +-
.../docs/modules/ROOT/pages/configure/smtp.adoc | 9 +
server/protocols/jwt/pom.xml | 35 ++++
.../org/apache/james/jwt/OidcJwtTokenVerifier.java | 24 ++-
.../introspection/DefaultIntrospectionClient.java | 78 ++++++++
.../jwt/introspection/IntrospectionClient.java | 29 +++
.../jwt/introspection/IntrospectionEndpoint.java | 58 ++++++
.../introspection/TokenIntrospectionException.java | 31 +++
.../introspection/TokenIntrospectionResponse.java | 131 +++++++++++++
.../apache/james/jwt/OidcJwtTokenVerifierTest.java | 13 +-
.../DefaultIntrospectionClientTest.java | 213 +++++++++++++++++++++
.../james/imapserver/netty/IMAPServerTest.java | 80 ++++++++
.../james/smtpserver/UsersRepositoryAuthHook.java | 17 +-
.../org/apache/james/smtpserver/SMTPSaslTest.java | 80 ++++++++
src/site/xdoc/server/config-smtp-lmtp.xml | 7 +
18 files changed, 844 insertions(+), 20 deletions(-)
diff --git a/examples/oidc/james/imapserver.xml b/examples/oidc/james/imapserver.xml
index 226ae6c0a3..e590c7dd5e 100644
--- a/examples/oidc/james/imapserver.xml
+++ b/examples/oidc/james/imapserver.xml
@@ -22,6 +22,10 @@
<jwksURL>http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs</jwksURL>
<claim>email</claim>
<scope>openid profile email</scope>
+ <introspection>
+ <url>http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect</url>
+ <auth>Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c=</auth>
+ </introspection>
</oidc>
</auth>
</imapserver>
diff --git a/examples/oidc/james/smtpserver.xml b/examples/oidc/james/smtpserver.xml
index 23ea253c99..6af07c4554 100644
--- a/examples/oidc/james/smtpserver.xml
+++ b/examples/oidc/james/smtpserver.xml
@@ -22,6 +22,10 @@
<jwksURL>http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs</jwksURL>
<claim>email</claim>
<scope>openid profile email</scope>
+ <introspection>
+ <url>http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect</url>
+ <auth>Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c=</auth>
+ </introspection>
</oidc>
</auth>
<authorizedAddresses>127.0.0.0/8</authorizedAddresses>
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
index 313bca2bb0..5ef7f689cd 100644
--- 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
@@ -21,10 +21,12 @@ package org.apache.james.protocols.api;
import java.net.MalformedURLException;
import java.net.URL;
+import java.util.Optional;
import org.apache.commons.configuration2.HierarchicalConfiguration;
import org.apache.commons.configuration2.tree.ImmutableNode;
+import com.github.fge.lambdas.Throwing;
import com.google.common.base.Preconditions;
public class OidcSASLConfiguration {
@@ -40,23 +42,31 @@ public class OidcSASLConfiguration {
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);
+ String introspectionUrl = configuration.getString("introspection.url", null);
+
+ return new OidcSASLConfiguration(new URL(jwksURL), claim, new URL(oidcConfigurationURL), scope, Optional.ofNullable(introspectionUrl)
+ .map(Throwing.function(URL::new)), Optional.ofNullable(configuration.getString("introspection.auth", null)));
}
private final URL jwksURL;
private final String claim;
private final URL oidcConfigurationURL;
private final String scope;
+ private final Optional<URL> introspectionEndpoint;
+ private final Optional<String> introspectionEndpointAuthorization;
- public OidcSASLConfiguration(URL jwksURL, String claim, URL oidcConfigurationURL, String scope) {
+ public OidcSASLConfiguration(URL jwksURL,
+ String claim,
+ URL oidcConfigurationURL,
+ String scope,
+ Optional<URL> introspectionEndpoint,
+ Optional<String> introspectionEndpointAuthorization) {
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);
+ this.introspectionEndpoint = introspectionEndpoint;
+ this.introspectionEndpointAuthorization = introspectionEndpointAuthorization;
}
public URL getJwksURL() {
@@ -74,4 +84,16 @@ public class OidcSASLConfiguration {
public String getScope() {
return scope;
}
+
+ public Optional<URL> getIntrospectionEndpoint() {
+ return introspectionEndpoint;
+ }
+
+ public boolean introspectionEndpointEnable() {
+ return getIntrospectionEndpoint().isPresent();
+ }
+
+ public Optional<String> getIntrospectionEndpointAuthorization() {
+ return introspectionEndpointAuthorization;
+ }
}
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 590242dfa3..945cadb071 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
@@ -39,15 +39,19 @@ 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.jwt.introspection.IntrospectionEndpoint;
import org.apache.james.mailbox.MailboxManager;
import org.apache.james.metrics.api.MetricFactory;
import org.apache.james.protocols.api.OIDCSASLParser;
+import org.apache.james.protocols.api.OidcSASLConfiguration;
import org.apache.james.util.MDCBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.ImmutableList;
+import reactor.core.publisher.Mono;
+
/**
* Processor which handles the AUTHENTICATE command. Only authtype of PLAIN is supported ATM.
*/
@@ -184,14 +188,23 @@ public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq
} else {
OIDCSASLParser.parse(initialResponse)
.flatMap(oidcInitialResponseValue -> session.oidcSaslConfiguration()
- .flatMap(configuration -> new OidcJwtTokenVerifier().verifyAndExtractClaim(oidcInitialResponseValue.getToken(), configuration.getJwksURL(), configuration.getClaim())))
- .flatMap(this::extractUserFromClaim)
+ .flatMap(configure -> validateToken(configure, oidcInitialResponseValue.getToken())))
.ifPresentOrElse(username -> authSuccess(username, session, request, responder),
() -> manageFailureCount(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED));
}
session.stopDetectingCommandInjection();
}
+ private Optional<Username> validateToken(OidcSASLConfiguration oidcSASLConfiguration, String token) {
+ return Mono.from(OidcJwtTokenVerifier.verifyWithMaybeIntrospection(token,
+ oidcSASLConfiguration.getJwksURL(),
+ oidcSASLConfiguration.getClaim(),
+ oidcSASLConfiguration.getIntrospectionEndpoint()
+ .map(endpoint -> new IntrospectionEndpoint(endpoint, oidcSASLConfiguration.getIntrospectionEndpointAuthorization()))))
+ .blockOptional()
+ .flatMap(this::extractUserFromClaim);
+ }
+
private Optional<Username> extractUserFromClaim(String claimValue) {
try {
return Optional.of(Username.fromMailAddress(new MailAddress(claimValue)));
diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc
index 33d4e4536d..fc7dbd883f 100644
--- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc
+++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc
@@ -103,6 +103,15 @@ can be used to enforce strong authentication mechanisms.
| auth.oidc.scope
| An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate SMTP server using a OIDC provider.
+| auth.oidc.introspection.url
+| Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662).
+Only configure this when you want to validate the revocation token by the OIDC provider.
+Note that James always verifies the signature of the token even whether this configuration is provided or not.
+
+| auth.oidc.introspection.auth
+| Optional. Provide Authorization in header request when introspecting token.
+Eg: `Basic xyz`
+
| authorizedAddresses
| Authorize specific addresses/networks.
diff --git a/server/protocols/jwt/pom.xml b/server/protocols/jwt/pom.xml
index 729f3ad63d..ae8d4c6e6c 100644
--- a/server/protocols/jwt/pom.xml
+++ b/server/protocols/jwt/pom.xml
@@ -42,6 +42,10 @@
<artifactId>jwks-rsa</artifactId>
<version>0.20.0</version>
</dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ </dependency>
<dependency>
<groupId>com.github.fge</groupId>
<artifactId>throwing-lambdas</artifactId>
@@ -72,6 +76,14 @@
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
+ <dependency>
+ <groupId>io.projectreactor</groupId>
+ <artifactId>reactor-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.projectreactor.netty</groupId>
+ <artifactId>reactor-netty</artifactId>
+ </dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
@@ -84,6 +96,15 @@
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>net.javacrumbs.json-unit</groupId>
+ <artifactId>json-unit-assertj</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-configuration2</artifactId>
+ </dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
@@ -96,6 +117,20 @@
<groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId>
<scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-common</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-transport</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-handler</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
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 c3d4541265..5c153b4d25 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,16 +22,24 @@ package org.apache.james.jwt;
import java.net.URL;
import java.util.Optional;
+import org.apache.james.jwt.introspection.DefaultIntrospectionClient;
+import org.apache.james.jwt.introspection.IntrospectionClient;
+import org.apache.james.jwt.introspection.IntrospectionEndpoint;
+import org.apache.james.jwt.introspection.TokenIntrospectionResponse;
+import org.reactivestreams.Publisher;
+
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 reactor.core.publisher.Mono;
public class OidcJwtTokenVerifier {
+ public static final IntrospectionClient INTROSPECTION_CLIENT = new DefaultIntrospectionClient();
- public Optional<String> verifyAndExtractClaim(String jwtToken, URL jwksURL, String claimName) {
+ public static Optional<String> verifySignatureAndExtractClaim(String jwtToken, URL jwksURL, String claimName) {
PublicKeyProvider jwksPublicKeyProvider = getClaimWithoutSignatureVerification(jwtToken, "kid", String.class)
.map(kidValue -> JwksPublicKeyProvider.of(jwksURL, kidValue))
.orElse(JwksPublicKeyProvider.of(jwksURL));
@@ -55,4 +63,18 @@ public class OidcJwtTokenVerifier {
return Optional.empty();
}
}
+
+ public static Publisher<String> verifyWithMaybeIntrospection(String jwtToken, URL jwksURL, String claimName, Optional<IntrospectionEndpoint> introspectionEndpoint) {
+ return Mono.fromCallable(() -> verifySignatureAndExtractClaim(jwtToken, jwksURL, claimName))
+ .flatMap(optional -> optional.map(Mono::just).orElseGet(Mono::empty))
+ .flatMap(claimResult -> {
+ if (introspectionEndpoint.isEmpty()) {
+ return Mono.just(claimResult);
+ }
+ return Mono.justOrEmpty(introspectionEndpoint)
+ .flatMap(endpoint -> Mono.from(INTROSPECTION_CLIENT.introspect(endpoint, jwtToken)))
+ .filter(TokenIntrospectionResponse::active)
+ .map(activeToken -> claimResult);
+ });
+ }
}
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/DefaultIntrospectionClient.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/DefaultIntrospectionClient.java
new file mode 100644
index 0000000000..58af4a7e62
--- /dev/null
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/DefaultIntrospectionClient.java
@@ -0,0 +1,78 @@
+/****************************************************************
+ * 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.jwt.introspection;
+
+import java.nio.charset.StandardCharsets;
+
+import org.reactivestreams.Publisher;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.fge.lambdas.Throwing;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import reactor.core.publisher.Mono;
+import reactor.netty.ByteBufMono;
+import reactor.netty.http.client.HttpClient;
+import reactor.netty.http.client.HttpClientResponse;
+import reactor.netty.resources.ConnectionProvider;
+
+public class DefaultIntrospectionClient implements IntrospectionClient {
+
+ public static final String TOKEN_ATTRIBUTE = "token";
+ private final HttpClient httpClient;
+ private final ObjectMapper deserializer;
+
+ public DefaultIntrospectionClient() {
+ this.httpClient = HttpClient.create(ConnectionProvider.builder(this.getClass().getName())
+ .build())
+ .disableRetry(true)
+ .headers(builder -> {
+ builder.add("Accept", "application/json");
+ builder.add("Content-Type", "application/x-www-form-urlencoded");
+ });
+ this.deserializer = new ObjectMapper();
+ }
+
+ @Override
+ public Publisher<TokenIntrospectionResponse> introspect(IntrospectionEndpoint introspectionEndpoint, String token) {
+ return httpClient
+ .headers(builder -> introspectionEndpoint.getAuthorizationHeader()
+ .ifPresent(auth -> builder.add("Authorization", auth)))
+ .post()
+ .uri(introspectionEndpoint.getUrl().toString())
+ .sendForm((req, form) -> form.multipart(false)
+ .attr(TOKEN_ATTRIBUTE, token))
+ .responseSingle(this::afterHTTPResponseHandler);
+ }
+
+ private Mono<TokenIntrospectionResponse> afterHTTPResponseHandler(HttpClientResponse httpClientResponse, ByteBufMono dataBuf) {
+ return Mono.just(httpClientResponse.status())
+ .filter(httpStatus -> httpStatus.equals(HttpResponseStatus.OK))
+ .flatMap(httpStatus -> dataBuf.asByteArray())
+ .map(Throwing.function(deserializer::readTree))
+ .map(TokenIntrospectionResponse::parse)
+ .onErrorResume(error -> Mono.error(new TokenIntrospectionException("Error when introspecting token.", error)))
+ .switchIfEmpty(dataBuf.asString(StandardCharsets.UTF_8)
+ .switchIfEmpty(Mono.just(""))
+ .flatMap(errorResponse -> Mono.error(new TokenIntrospectionException(
+ String.format("Error when introspecting token. \nResponse Status = %s,\n Response Body = %s",
+ httpClientResponse.status().code(), errorResponse)))));
+ }
+}
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionClient.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionClient.java
new file mode 100644
index 0000000000..a82797d838
--- /dev/null
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionClient.java
@@ -0,0 +1,29 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jwt.introspection;
+
+import org.reactivestreams.Publisher;
+
+public interface IntrospectionClient {
+
+ Publisher<TokenIntrospectionResponse> introspect(IntrospectionEndpoint introspectionEndpoint, String token);
+
+}
+
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionEndpoint.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionEndpoint.java
new file mode 100644
index 0000000000..7ee44b2f3b
--- /dev/null
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/IntrospectionEndpoint.java
@@ -0,0 +1,58 @@
+/****************************************************************
+ * 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.jwt.introspection;
+
+import java.net.URL;
+import java.util.Objects;
+import java.util.Optional;
+
+public class IntrospectionEndpoint {
+ private final URL url;
+ private final Optional<String> authorizationHeader;
+
+ public IntrospectionEndpoint(URL url, Optional<String> authorizationHeader) {
+ this.url = url;
+ this.authorizationHeader = authorizationHeader;
+ }
+
+ public URL getUrl() {
+ return url;
+ }
+
+ public Optional<String> getAuthorizationHeader() {
+ return authorizationHeader;
+ }
+
+ @Override
+ public final boolean equals(Object o) {
+ if (o instanceof IntrospectionEndpoint) {
+ IntrospectionEndpoint that = (IntrospectionEndpoint) o;
+
+ return Objects.equals(this.url, that.url)
+ && Objects.equals(this.authorizationHeader, that.authorizationHeader);
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(url, authorizationHeader);
+ }
+}
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionException.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionException.java
new file mode 100644
index 0000000000..0ac71632b8
--- /dev/null
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionException.java
@@ -0,0 +1,31 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jwt.introspection;
+
+public class TokenIntrospectionException extends RuntimeException {
+
+ public TokenIntrospectionException(String message) {
+ super(message);
+ }
+
+ public TokenIntrospectionException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionResponse.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionResponse.java
new file mode 100644
index 0000000000..6fd6a712db
--- /dev/null
+++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/introspection/TokenIntrospectionResponse.java
@@ -0,0 +1,131 @@
+/****************************************************************
+ * 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.jwt.introspection;
+
+import java.util.Optional;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.BooleanNode;
+import com.google.common.base.Preconditions;
+
+/**
+ * https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
+ */
+public class TokenIntrospectionResponse {
+ public static TokenIntrospectionResponse parse(JsonNode json) {
+ return new TokenIntrospectionResponse(json);
+ }
+
+ private final boolean active;
+ private final Optional<String> scope;
+ private final Optional<String> clientId;
+ private final Optional<String> username;
+ private final Optional<String> tokenType;
+ private final Optional<Integer> exp;
+ private final Optional<Integer> iat;
+ private final Optional<Integer> nbf;
+ private final Optional<String> sub;
+ private final Optional<String> aud;
+ private final Optional<String> iss;
+ private final Optional<String> jti;
+ private final JsonNode json;
+
+ public TokenIntrospectionResponse(JsonNode json) {
+ Preconditions.checkNotNull(json);
+ JsonNode activeNode = json.get("active");
+ Preconditions.checkArgument(activeNode instanceof BooleanNode, "Missing / invalid boolean 'active' parameter");
+ this.active = activeNode.asBoolean();
+ this.scope = Optional.ofNullable(json.get("scope"))
+ .map(JsonNode::asText);
+ this.clientId = Optional.ofNullable(json.get("client_id"))
+ .map(JsonNode::asText);
+ this.username = Optional.ofNullable(json.get("username"))
+ .map(JsonNode::asText);
+ this.tokenType = Optional.ofNullable(json.get("token_type"))
+ .map(JsonNode::asText);
+ this.exp = Optional.ofNullable(json.get("exp"))
+ .map(JsonNode::asInt);
+ this.iat = Optional.ofNullable(json.get("iat"))
+ .map(JsonNode::asInt);
+ this.nbf = Optional.ofNullable(json.get("nbf"))
+ .map(JsonNode::asInt);
+ this.sub = Optional.ofNullable(json.get("sub"))
+ .map(JsonNode::asText);
+ this.aud = Optional.ofNullable(json.get("aud"))
+ .map(JsonNode::asText);
+ this.iss = Optional.ofNullable(json.get("iss"))
+ .map(JsonNode::asText);
+ this.jti = Optional.ofNullable(json.get("jti"))
+ .map(JsonNode::asText);
+ this.json = json;
+ }
+
+ public boolean active() {
+ return active;
+ }
+
+ public Optional<String> scope() {
+ return scope;
+ }
+
+ public JsonNode json() {
+ return json;
+ }
+
+ public Optional<String> clientId() {
+ return clientId;
+ }
+
+ public Optional<String> username() {
+ return username;
+ }
+
+ public Optional<String> tokenType() {
+ return tokenType;
+ }
+
+ public Optional<Integer> exp() {
+ return exp;
+ }
+
+ public Optional<Integer> iat() {
+ return iat;
+ }
+
+ public Optional<Integer> nbf() {
+ return nbf;
+ }
+
+ public Optional<String> sub() {
+ return sub;
+ }
+
+ public Optional<String> aud() {
+ return aud;
+ }
+
+ public Optional<String> iss() {
+ return iss;
+ }
+
+ public Optional<String> jti() {
+ return jti;
+ }
+}
diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java
index 50c361353a..bc370081a7 100644
--- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java
+++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java
@@ -38,8 +38,6 @@ class OidcJwtTokenVerifierTest {
private static final String JWKS_URI_PATH = "/auth/realms/realm1/protocol/openid-connect/certs";
ClientAndServer mockServer;
- OidcJwtTokenVerifier testee;
-
@BeforeEach
public void setUp() {
mockServer = ClientAndServer.startClientAndServer(0);
@@ -48,12 +46,11 @@ class OidcJwtTokenVerifierTest {
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8));
- testee = new OidcJwtTokenVerifier();
}
@Test
void verifyAndClaimShouldReturnClaimValueWhenValidTokenHasKid() {
- Optional<String> email_address = testee.verifyAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address");
+ Optional<String> email_address = OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address");
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(email_address.isPresent()).isTrue();
softly.assertThat(email_address.get()).isEqualTo("user@domain.org");
@@ -62,7 +59,7 @@ class OidcJwtTokenVerifierTest {
@Test
void verifyAndClaimShouldReturnClaimValueWhenValidTokenHasNotKid() {
- Optional<String> email_address = testee.verifyAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_KID, getJwksURL(), "email_address");
+ Optional<String> email_address = OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_KID, getJwksURL(), "email_address");
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(email_address.isPresent()).isTrue();
softly.assertThat(email_address.get()).isEqualTo("user@domain.org");
@@ -71,20 +68,20 @@ class OidcJwtTokenVerifierTest {
@Test
void verifyAndClaimShouldReturnEmptyWhenValidTokenHasNotFoundKid() {
- assertThat(testee.verifyAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_FOUND_KID, getJwksURL(), "email_address"))
+ assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_FOUND_KID, getJwksURL(), "email_address"))
.isEmpty();
}
@Test
void verifyAndClaimShouldReturnEmptyWhenClaimNameNotFound() {
- assertThat(testee.verifyAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "not_found"))
+ assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "not_found"))
.isEmpty();
}
@Test
void verifyAndClaimShouldReturnEmptyWhenInvalidToken() {
- assertThat(testee.verifyAndExtractClaim(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address"))
+ assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address"))
.isEmpty();
}
diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/introspection/DefaultIntrospectionClientTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/introspection/DefaultIntrospectionClientTest.java
new file mode 100644
index 0000000000..c5077fdc97
--- /dev/null
+++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/introspection/DefaultIntrospectionClientTest.java
@@ -0,0 +1,213 @@
+/****************************************************************
+ * 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.jwt.introspection;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.HttpRequest;
+import org.mockserver.model.HttpResponse;
+import org.mockserver.verify.VerificationTimes;
+
+import reactor.core.publisher.Mono;
+
+public class DefaultIntrospectionClientTest {
+ private static final String INTROSPECTION_TOKEN_URI_PATH = "/token/introspect";
+
+ private ClientAndServer mockServer;
+
+ @BeforeEach
+ public void setUp() {
+ mockServer = ClientAndServer.startClientAndServer(0);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockServer.stop();
+ }
+
+ private IntrospectionEndpoint getIntrospectionTokenEndpoint() {
+ try {
+ return new IntrospectionEndpoint(new URL(String.format("http://127.0.0.1:%s%s", mockServer.getLocalPort(), INTROSPECTION_TOKEN_URI_PATH)),
+ Optional.empty());
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private DefaultIntrospectionClient testee() {
+ return new DefaultIntrospectionClient();
+ }
+
+ private void updateMockerServerSpecifications(String response, int statusResponse) {
+ mockServer
+ .when(HttpRequest.request().withPath(INTROSPECTION_TOKEN_URI_PATH))
+ .respond(HttpResponse.response().withStatusCode(statusResponse)
+ .withHeader("Content-Type", "application/json")
+ .withBody(response, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void introspectShouldSuccessWhenValidRequest() {
+ String activeResponse = "{" +
+ " \"exp\": 1652868271," +
+ " \"nbf\": 0," +
+ " \"iat\": 1652867971," +
+ " \"jti\": \"41ee3cc3-b908-4870-bff2-34b895b9fadf\"," +
+ " \"aud\": \"account\"," +
+ " \"typ\": \"Bearer\"," +
+ " \"acr\": \"1\"," +
+ " \"scope\": \"email\"," +
+ " \"active\": true" +
+ "}";
+
+ updateMockerServerSpecifications(activeResponse, 200);
+
+ TokenIntrospectionResponse introspectionResponse = Mono.from(testee().introspect(getIntrospectionTokenEndpoint(), "abc"))
+ .block();
+ assertThat(introspectionResponse).isNotNull();
+
+ assertSoftly(softly -> {
+ softly.assertThat(introspectionResponse.active()).isTrue();
+ softly.assertThat(introspectionResponse.scope()).isEqualTo(Optional.of("email"));
+ assertThatJson(introspectionResponse.json().toString()).isEqualTo(activeResponse);
+ softly.assertThat(introspectionResponse.exp()).isEqualTo(Optional.of(1652868271));
+ softly.assertThat(introspectionResponse.nbf()).isEqualTo(Optional.of(0));
+ softly.assertThat(introspectionResponse.iat()).isEqualTo(Optional.of(1652867971));
+ softly.assertThat(introspectionResponse.jti()).isEqualTo(Optional.of("41ee3cc3-b908-4870-bff2-34b895b9fadf"));
+ softly.assertThat(introspectionResponse.aud()).isEqualTo(Optional.of("account"));
+ softly.assertThat(introspectionResponse.iss()).isEmpty();
+ softly.assertThat(introspectionResponse.sub()).isEmpty();
+ });
+ }
+
+ @Test
+ void introspectShouldPostValidRequest() {
+ String activeResponse = "{" +
+ " \"exp\": 1652868271," +
+ " \"nbf\": 0," +
+ " \"iat\": 1652867971," +
+ " \"jti\": \"41ee3cc3-b908-4870-bff2-34b895b9fadf\"," +
+ " \"aud\": \"account\"," +
+ " \"typ\": \"Bearer\"," +
+ " \"acr\": \"1\"," +
+ " \"scope\": \"email\"," +
+ " \"active\": true" +
+ "}";
+
+ updateMockerServerSpecifications(activeResponse, 200);
+
+ Mono.from(testee().introspect(getIntrospectionTokenEndpoint(), "abc"))
+ .block();
+ mockServer.verify(HttpRequest.request()
+ .withPath(INTROSPECTION_TOKEN_URI_PATH)
+ .withMethod("POST")
+ .withHeader("Accept", "application/json")
+ .withHeader("Content-Type", "application/x-www-form-urlencoded")
+ .withBody("token=abc"),
+ VerificationTimes.atLeast(1));
+ }
+
+ @Test
+ void introspectShouldFailWhenNotAuthorized() {
+ String serverResponse = "{" +
+ " \"error\": \"invalid_request\"," +
+ " \"error_description\": \"Authentication failed.\"" +
+ "}";
+
+ updateMockerServerSpecifications(serverResponse, 401);
+
+ assertThatThrownBy(() -> Mono.from(testee().introspect(getIntrospectionTokenEndpoint(), "abc"))
+ .block())
+ .isInstanceOf(TokenIntrospectionException.class)
+ .hasMessageContaining("Authentication failed")
+ .hasMessageContaining("401");
+ }
+
+ @Test
+ void introspectShouldFailWhenCanNotDeserializeResponse() {
+ String serverResponse = "invalid";
+
+ updateMockerServerSpecifications(serverResponse, 200);
+
+ assertThatThrownBy(() -> Mono.from(testee().introspect(getIntrospectionTokenEndpoint(), "abc"))
+ .block())
+ .isInstanceOf(TokenIntrospectionException.class)
+ .hasMessageContaining("Error when introspecting token");
+ }
+
+ @Test
+ void introspectShouldFailWhenResponseMissingActiveProperty() {
+ String serverResponse = "{" +
+ " \"exp\": 1652868271," +
+ " \"nbf\": 0," +
+ " \"iat\": 1652867971," +
+ " \"jti\": \"41ee3cc3-b908-4870-bff2-34b895b9fadf\"," +
+ " \"aud\": \"account\"," +
+ " \"typ\": \"Bearer\"," +
+ " \"acr\": \"1\"," +
+ " \"scope\": \"email\"" +
+ "}";
+
+ updateMockerServerSpecifications(serverResponse, 200);
+
+ assertThatThrownBy(() -> Mono.from(testee().introspect(getIntrospectionTokenEndpoint(), "abc"))
+ .block())
+ .isInstanceOf(TokenIntrospectionException.class)
+ .hasMessageContaining("Error when introspecting token");
+ }
+
+ @Test
+ void introspectShouldReturnUpdatedResponse() {
+ String activeResponse = "{" +
+ " \"active\": true" +
+ "}";
+
+ updateMockerServerSpecifications(activeResponse, 200);
+ DefaultIntrospectionClient testee = testee();
+ String token = "token1bc";
+
+ assertThat(Mono.from(testee.introspect(getIntrospectionTokenEndpoint(), token))
+ .block()).isNotNull()
+ .satisfies(x -> assertThat(x.active()).isTrue());
+
+ String updatedResponse = "{" +
+ " \"active\": false" +
+ "}";
+ mockServer.reset();
+ updateMockerServerSpecifications(updatedResponse, 200);
+
+ assertThat(Mono.from(testee.introspect(getIntrospectionTokenEndpoint(), token))
+ .block()).isNotNull()
+ .satisfies(x -> assertThat(x.active()).isFalse());
+ }
+
+}
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 33a8f60cba..7ac2a1e9ef 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
@@ -1013,6 +1013,7 @@ class IMAPServerTest {
@Nested
class Oidc {
String JWKS_URI_PATH = "/jwks";
+ String INTROSPECT_TOKEN_URI_PATH = "/introspect";
ClientAndServer authServer;
IMAPServer imapServer;
int port;
@@ -1131,6 +1132,85 @@ class IMAPServerTest {
imapsClient.create("INBOX");
assertThat(imapsClient.getReplyString()).contains("Command not valid in this state.");
}
+
+ @Test
+ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception {
+ imapServer.destroy();
+
+ authServer
+ .when(HttpRequest.request().withPath(INTROSPECT_TOKEN_URI_PATH))
+ .respond(HttpResponse.response().withStatusCode(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"active\": false}", StandardCharsets.UTF_8));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("oauth.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", "https://example.com/jwks");
+ config.addProperty("auth.oidc.scope", "email");
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH));
+
+ imapServer = createImapServer(config);
+
+ int port = imapServer.getListenAddresses().get(0).getPort();
+
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed.");
+ }
+
+ @Test
+ void oauthShouldSuccessWhenIntrospectTokenReturnActiveIsTrue() throws Exception {
+ imapServer.destroy();
+
+ authServer
+ .when(HttpRequest.request().withPath(INTROSPECT_TOKEN_URI_PATH))
+ .respond(HttpResponse.response().withStatusCode(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"active\": true}", StandardCharsets.UTF_8));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("oauth.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", "https://example.com/jwks");
+ config.addProperty("auth.oidc.scope", "email");
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH));
+
+ imapServer = createImapServer(config);
+
+ int port = imapServer.getListenAddresses().get(0).getPort();
+
+ 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 oauthShouldFailWhenIntrospectTokenServerError() throws Exception {
+ imapServer.destroy();
+ String invalidURI = "/invalidURI";
+ authServer
+ .when(HttpRequest.request().withPath(invalidURI))
+ .respond(HttpResponse.response().withStatusCode(401));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("oauth.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", "https://example.com/jwks");
+ config.addProperty("auth.oidc.scope", "email");
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), invalidURI));
+
+ imapServer = createImapServer(config);
+
+ int port = imapServer.getListenAddresses().get(0).getPort();
+
+ String oauthBearer = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN);
+ IMAPSClient client = imapsClient(port);
+ client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer);
+ assertThat(client.getReplyString()).contains("NO AUTHENTICATE processing failed.");
+ }
}
private AuthenticatingIMAPClient imapsClient(int port) throws Exception {
diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
index 695025a0b1..786c6e21fc 100644
--- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
+++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java
@@ -26,6 +26,7 @@ import javax.mail.internet.AddressException;
import org.apache.james.core.MailAddress;
import org.apache.james.core.Username;
import org.apache.james.jwt.OidcJwtTokenVerifier;
+import org.apache.james.jwt.introspection.IntrospectionEndpoint;
import org.apache.james.protocols.api.OIDCSASLParser;
import org.apache.james.protocols.api.OidcSASLConfiguration;
import org.apache.james.protocols.smtp.SMTPSession;
@@ -37,6 +38,8 @@ import org.apache.james.user.api.UsersRepositoryException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+
/**
* This Auth hook can be used to authenticate against the james user repository
*/
@@ -70,9 +73,7 @@ public class UsersRepositoryAuthHook implements AuthHook {
@Override
public HookResult doSasl(SMTPSession session, OidcSASLConfiguration configuration, String initialResponse) {
return OIDCSASLParser.parse(initialResponse)
- .flatMap(value -> new OidcJwtTokenVerifier()
- .verifyAndExtractClaim(value.getToken(), configuration.getJwksURL(), configuration.getClaim()))
- .flatMap(this::extractUserFromClaim)
+ .flatMap(value -> validateToken(configuration, value.getToken()))
.map(username -> {
try {
users.assertValid(username);
@@ -90,6 +91,16 @@ public class UsersRepositoryAuthHook implements AuthHook {
.orElse(HookResult.DECLINED);
}
+ private Optional<Username> validateToken(OidcSASLConfiguration oidcSASLConfiguration, String token) {
+ return Mono.from(OidcJwtTokenVerifier.verifyWithMaybeIntrospection(token,
+ oidcSASLConfiguration.getJwksURL(),
+ oidcSASLConfiguration.getClaim(),
+ oidcSASLConfiguration.getIntrospectionEndpoint()
+ .map(endpoint -> new IntrospectionEndpoint(endpoint, oidcSASLConfiguration.getIntrospectionEndpointAuthorization()))))
+ .blockOptional()
+ .flatMap(this::extractUserFromClaim);
+ }
+
private Optional<Username> extractUserFromClaim(String claimValue) {
try {
return Optional.of(Username.fromMailAddress(new MailAddress(claimValue)));
diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
index 56f66f8583..10e9e382f8 100644
--- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
+++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java
@@ -92,6 +92,7 @@ class SMTPSaslTest {
public static final Username USER = Username.of("user@domain.org");
public static final String PASSWORD = "userpassword";
public static final String JWKS_URI_PATH = "/jwks";
+ public static final String INTROSPECT_TOKEN_URI_PATH = "/introspect";
public static final String OIDC_URL = "https://example.com/jwks";
public static final String SCOPE = "scope";
public static final String FAIL_RESPONSE_TOKEN = Base64.getEncoder().encodeToString(
@@ -375,4 +376,83 @@ class SMTPSaslTest {
});
}
+ @Test
+ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception {
+ smtpServer.destroy();
+ authServer
+ .when(HttpRequest.request().withPath(INTROSPECT_TOKEN_URI_PATH))
+ .respond(HttpResponse.response().withStatusCode(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"active\": false}", StandardCharsets.UTF_8));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
+ config.addProperty("auth.oidc.scope", SCOPE);
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH));
+ smtpServer.configure(config);
+ smtpServer.init();
+
+ SMTPSClient client = initSMTPSClient();
+
+ client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN);
+
+ assertThat(client.getReplyString()).contains("334 " + FAIL_RESPONSE_TOKEN);
+
+ client.sendCommand("AQ==");
+ assertThat(client.getReplyString()).contains("535 Authentication Failed");
+
+ }
+
+ @Test
+ void oauthShouldFailWhenIntrospectTokenReturnActiveIsTrue() throws Exception {
+ smtpServer.destroy();
+ authServer
+ .when(HttpRequest.request().withPath(INTROSPECT_TOKEN_URI_PATH))
+ .respond(HttpResponse.response().withStatusCode(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"active\": true}", StandardCharsets.UTF_8));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
+ config.addProperty("auth.oidc.scope", SCOPE);
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH));
+ smtpServer.configure(config);
+ smtpServer.init();
+
+ SMTPSClient client = initSMTPSClient();
+
+ client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN);
+
+ assertThat(client.getReplyString()).contains("235 Authentication successful.");
+ }
+
+ @Test
+ void oauthShouldFailWhenIntrospectTokenServerError() throws Exception {
+ smtpServer.destroy();
+ String invalidURI = "/invalidURI";
+ authServer
+ .when(HttpRequest.request().withPath(invalidURI))
+ .respond(HttpResponse.response().withStatusCode(503)
+ .withHeader("Content-Type", "application/json"));
+
+ HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml"));
+ config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH));
+ config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM);
+ config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL);
+ config.addProperty("auth.oidc.scope", SCOPE);
+ config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), invalidURI));
+ smtpServer.configure(config);
+ smtpServer.init();
+
+ SMTPSClient client = initSMTPSClient();
+
+ client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN);
+
+ assertThat(client.getReplyString()).contains("451 Unable to process request");
+ }
+
}
diff --git a/src/site/xdoc/server/config-smtp-lmtp.xml b/src/site/xdoc/server/config-smtp-lmtp.xml
index 8a9593b06a..d684c783af 100644
--- a/src/site/xdoc/server/config-smtp-lmtp.xml
+++ b/src/site/xdoc/server/config-smtp-lmtp.xml
@@ -111,6 +111,13 @@
<dd>Claim string uses to identify user. E.g: "email_address". Only configure this when you want to authenticate SMTP server using a OIDC provider.</dd>
<dt><strong>auth.oidc.scope</strong></dt>
<dd>An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate SMTP server using a OIDC provider.</dd>
+ <dt><strong>auth.oidc.introspection.url</strong></dt>
+ <dd>Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662).
+ Only configure this when you want to validate the revocation token by the OIDC provider.
+ Note that James always verifies the signature of the token even whether this configuration is provided or not.</dd>
+ <dt><strong>auth.oidc.introspection.auth</strong></dt>
+ <dd>Optional. Provide Authorization in header request when introspecting token.
+ Eg: `Basic xyz`</dd>
<dt><strong>handler.authorizedAddresses</strong></dt>
<dd>Authorize specific addresses/networks.
If you use SMTP AUTH, addresses that match those specified here will
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org