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