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]
