This is an automated email from the ASF dual-hosted git repository.

quantranhong1999 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit f60a26efbd091044c6f55ff4b9709844e1e7418e
Author: Quan Tran <[email protected]>
AuthorDate: Thu Jul 2 16:25:40 2026 +0700

    JAMES-4195 Add JMAP OIDC backchannel logout route
    
    Adds the non-Guice WebAdmin JMAP route for OIDC backchannel logout and its 
Guice route binding module.
---
 .../oidc/OidcBackchannelLogoutRoutesModule.java    |  34 ++++
 server/protocols/webadmin/webadmin-jmap/pom.xml    |   4 +
 .../webadmin/oidc/OidcBackchannelLogoutRoutes.java | 117 +++++++++++++
 .../oidc/OidcBackchannelLogoutRoutesTest.java      | 191 +++++++++++++++++++++
 4 files changed, 346 insertions(+)

diff --git 
a/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/oidc/OidcBackchannelLogoutRoutesModule.java
 
b/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/oidc/OidcBackchannelLogoutRoutesModule.java
new file mode 100644
index 0000000000..a4e148d092
--- /dev/null
+++ 
b/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/oidc/OidcBackchannelLogoutRoutesModule.java
@@ -0,0 +1,34 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.modules.server.oidc;
+
+import org.apache.james.webadmin.Routes;
+import org.apache.james.webadmin.oidc.OidcBackchannelLogoutRoutes;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
+
+public class OidcBackchannelLogoutRoutesModule extends AbstractModule {
+    @Override
+    protected void configure() {
+        Multibinder<Routes> routesMultibinder = 
Multibinder.newSetBinder(binder(), Routes.class);
+        routesMultibinder.addBinding().to(OidcBackchannelLogoutRoutes.class);
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-jmap/pom.xml 
b/server/protocols/webadmin/webadmin-jmap/pom.xml
index 6003d730e2..c53352436b 100644
--- a/server/protocols/webadmin/webadmin-jmap/pom.xml
+++ b/server/protocols/webadmin/webadmin-jmap/pom.xml
@@ -71,6 +71,10 @@
             <artifactId>james-server-data-memory</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-jmap</artifactId>
+        </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
             <artifactId>james-server-jmap-rfc-8621</artifactId>
diff --git 
a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutes.java
 
b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutes.java
new file mode 100644
index 0000000000..31af7d771a
--- /dev/null
+++ 
b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutes.java
@@ -0,0 +1,117 @@
+/****************************************************************
+ * 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.webadmin.oidc;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.james.jmap.oidc.OidcTokenCache;
+import org.apache.james.jmap.oidc.Sid;
+import org.apache.james.webadmin.Constants;
+import org.apache.james.webadmin.PublicRoutes;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.eclipse.jetty.http.HttpStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.io.BaseEncoding;
+
+import spark.Route;
+import spark.Service;
+
+public class OidcBackchannelLogoutRoutes implements PublicRoutes {
+    public static final String BASE_PATH = "/add-revoked-token";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(OidcBackchannelLogoutRoutes.class);
+    private static final String APPLICATION_FORM_URLENCODED_VALUE = 
"application/x-www-form-urlencoded";
+    private static final String TOKEN_PARAM = "logout_token";
+    private static final String SID_PROPERTY = "sid";
+
+    private final OidcTokenCache oidcTokenCache;
+    private final ObjectMapper objectMapper;
+    private final JsonTransformer jsonTransformer;
+
+    @Inject
+    public OidcBackchannelLogoutRoutes(OidcTokenCache oidcTokenCache, 
JsonTransformer jsonTransformer) {
+        this.oidcTokenCache = oidcTokenCache;
+        this.jsonTransformer = jsonTransformer;
+        this.objectMapper = new ObjectMapper();
+    }
+
+    @Override
+    public String getBasePath() {
+        return BASE_PATH;
+    }
+
+    @Override
+    public void define(Service service) {
+        service.post(BASE_PATH, addRevokedToken(), jsonTransformer);
+    }
+
+    public Route addRevokedToken() {
+        return (request, response) -> {
+            if (!StringUtils.startsWith(request.contentType(), 
APPLICATION_FORM_URLENCODED_VALUE)) {
+                response.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE_415);
+                return "Unsupported Content-Type";
+            }
+
+            String token = request.queryParams(TOKEN_PARAM);
+            Preconditions.checkArgument(StringUtils.isNotEmpty(token), 
"Missing logout token");
+
+            Sid sid = extractSidFromLogoutToken(token);
+            LOGGER.debug("Add new revoked token has sid: {}", sid);
+            oidcTokenCache.invalidate(sid).block();
+            return Constants.EMPTY_BODY;
+        };
+    }
+
+    private Sid extractSidFromLogoutToken(String token) {
+        try {
+            List<String> parts = Splitter.on('.')
+                .trimResults()
+                .omitEmptyStrings()
+                .splitToList(token);
+            if (parts.size() < 2) {
+                throw new IllegalArgumentException("JWT does not contain the 
mandatory 2 parts");
+            }
+
+            String payloadJson = new 
String(BaseEncoding.base64Url().decode(parts.get(1)), StandardCharsets.UTF_8);
+            Map<String, Object> payloadMap = 
objectMapper.readValue(payloadJson, new TypeReference<>() {
+            });
+
+            return Optional.ofNullable(payloadMap.getOrDefault(SID_PROPERTY, 
null))
+                .map(s -> (String) s)
+                .filter(sid -> !sid.isEmpty())
+                .map(Sid::new)
+                .orElseThrow(() -> new IllegalArgumentException("Unable to 
extract Sid from logout token"));
+        } catch (Exception exception) {
+            throw new IllegalArgumentException("Unable to extract Sid from 
logout token", exception);
+        }
+    }
+}
diff --git 
a/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutesTest.java
 
b/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutesTest.java
new file mode 100644
index 0000000000..e689d70707
--- /dev/null
+++ 
b/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/oidc/OidcBackchannelLogoutRoutesTest.java
@@ -0,0 +1,191 @@
+/****************************************************************
+ * 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.webadmin.oidc;
+
+import static io.restassured.RestAssured.given;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import org.apache.james.jmap.oidc.OidcTokenCache;
+import org.apache.james.jmap.oidc.Sid;
+import org.apache.james.webadmin.WebAdminServer;
+import org.apache.james.webadmin.WebAdminUtils;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import reactor.core.publisher.Mono;
+
+class OidcBackchannelLogoutRoutesTest {
+    private WebAdminServer webAdminServer;
+    private OidcTokenCache oidcTokenCache;
+
+    @BeforeEach
+    void setUp() {
+        oidcTokenCache = mock(OidcTokenCache.class);
+        
Mockito.when(oidcTokenCache.invalidate(any())).thenReturn(Mono.empty());
+        webAdminServer = WebAdminUtils.createWebAdminServer(new 
OidcBackchannelLogoutRoutes(oidcTokenCache, new JsonTransformer())).start();
+
+        RestAssured.requestSpecification = 
WebAdminUtils.buildRequestSpecification(webAdminServer)
+            .setBasePath(OidcBackchannelLogoutRoutes.BASE_PATH)
+            .build();
+    }
+
+    @AfterEach
+    void tearDown() {
+        webAdminServer.destroy();
+    }
+
+    @Test
+    void postShouldReturn200WhenSidIsExtractedSuccessfully() {
+        String validToken = generateJwtLikeTokenWithSid("sid1");
+
+        given().log().ifValidationFails()
+            .contentType(ContentType.URLENC)
+            .formParam("logout_token", validToken)
+        .when()
+            .post()
+        .then()
+            .statusCode(200);
+    }
+
+    @Test
+    void postShouldInvalidateCacheWhenSidIsPresent() {
+        String validToken = generateJwtLikeTokenWithSid("sid1");
+
+        given().log().ifValidationFails()
+            .contentType(ContentType.URLENC)
+            .formParam("logout_token", validToken)
+        .when()
+            .post()
+        .then()
+            .statusCode(200);
+
+        verify(oidcTokenCache, times(1)).invalidate(new Sid("sid1"));
+    }
+
+    @Test
+    void postShouldReturn400WhenLogoutTokenIsMissing() {
+        given()
+            .log().ifValidationFails()
+            .contentType(ContentType.URLENC)
+        .when()
+            .post()
+        .then()
+            .statusCode(400);
+    }
+
+    @Test
+    void postShouldReturn415WhenContentTypeIsInvalid() {
+        String validToken = generateJwtLikeTokenWithSid("sid1");
+
+        given()
+            .log().ifValidationFails()
+            .contentType(ContentType.JSON)
+            .body("{\"logout_token\":\"" + validToken + "\"}")
+        .when()
+            .post()
+        .then()
+            .statusCode(415);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "invalidtoken123456",
+        "eyJblablabla",
+    })
+    void postShouldReturn400WhenCanNotExtractSidFromToken(String invalidToken) 
{
+        given().log().ifValidationFails()
+            .contentType(ContentType.URLENC)
+            .formParam("logout_token", invalidToken)
+        .when()
+            .post()
+        .then()
+            .log().ifValidationFails()
+            .statusCode(400);
+    }
+
+    @Test
+    void postShouldReturn400WhenJwtHasNoSidClaim() {
+        String payload = Base64.getUrlEncoder().withoutPadding()
+            
.encodeToString(("{\"notSid\":\"abc\"}").getBytes(StandardCharsets.UTF_8));
+        String tokenWithoutSid = "eyJblablabla." + payload + ".signature";
+
+        given()
+            .log().ifValidationFails()
+            .contentType(ContentType.URLENC)
+            .formParam("logout_token", tokenWithoutSid)
+        .when()
+            .post()
+        .then()
+            .statusCode(400);
+    }
+
+    @Test
+    void postShouldReturn400WhenSidIsEmpty() {
+        String payload = Base64.getUrlEncoder().withoutPadding()
+            .encodeToString("{\"sid\":\"\"}".getBytes(StandardCharsets.UTF_8));
+        String tokenWithEmptySid = "header." + payload + ".signature";
+
+        given()
+            .contentType(ContentType.URLENC)
+            .formParam("logout_token", tokenWithEmptySid)
+        .when()
+            .post()
+        .then()
+            .statusCode(400);
+    }
+
+    @Test
+    void postShouldBeIdempotentWhenSameTokenIsPostedMultipleTimes() {
+        String sidValue = "sid123";
+        String token = generateJwtLikeTokenWithSid(sidValue);
+
+        for (int i = 0; i < 3; i++) {
+            given()
+                .contentType(ContentType.URLENC)
+                .formParam("logout_token", token)
+            .when()
+                .post()
+            .then()
+                .statusCode(200);
+        }
+
+        verify(oidcTokenCache, times(3)).invalidate(new Sid(sidValue));
+    }
+
+    private String generateJwtLikeTokenWithSid(String sid) {
+        String header = "eyJblablabla";
+        String payload = Base64.getUrlEncoder().withoutPadding()
+            .encodeToString(("{\"sid\":\"" + sid + 
"\"}").getBytes(StandardCharsets.UTF_8));
+        return header + "." + payload + ".signature";
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to