This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/27718-to-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 46c01f0c1e280afe0cdead8a68708da1a404b709 Author: Guillaume Nodet <[email protected]> AuthorDate: Tue Oct 13 11:38:19 2026 +0000 [backport camel-4.18.x] CAMEL-25568: camel-oauth - make OAuthBearerTokenProcessor stateless and read credentials from HTTP request headers --- components/camel-oauth/pom.xml | 22 ++ .../org/apache/camel/oauth/HttpRequestHeaders.java | 124 ++++++++++ .../apache/camel/oauth/InMemorySessionStore.java | 14 +- .../camel/oauth/NettyHttpRequestHeaders.java | 38 ++++ .../main/java/org/apache/camel/oauth/OAuth.java | 7 + .../camel/oauth/OAuthBearerTokenProcessor.java | 25 ++- .../camel/oauth/ServletHttpRequestHeaders.java | 81 +++++++ .../camel/oauth/VertxHttpRequestHeaders.java | 38 ++++ .../http/springboot/PlatformHttpMessage.java | 43 ++++ .../oauth/AbstractOAuthRequestHeadersTest.java | 249 +++++++++++++++++++++ .../apache/camel/oauth/HttpRequestHeadersTest.java | 168 ++++++++++++++ .../camel/oauth/OAuthBearerTokenProcessorTest.java | 85 +++++++ .../oauth/OAuthRequestHeadersNettyHttpTest.java | 43 ++++ .../oauth/OAuthRequestHeadersServletTest.java | 66 ++++++ .../oauth/OAuthRequestHeadersUndertowTest.java | 59 +++++ .../camel/oauth/OAuthRequestHeadersVertxTest.java | 45 ++++ .../java/org/apache/camel/oauth/StubOAuth.java | 74 ++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc | 20 ++ 18 files changed, 1193 insertions(+), 8 deletions(-) diff --git a/components/camel-oauth/pom.xml b/components/camel-oauth/pom.xml index e92f65709e4a..616bfce360f6 100644 --- a/components/camel-oauth/pom.xml +++ b/components/camel-oauth/pom.xml @@ -66,6 +66,23 @@ <artifactId>camel-platform-http-main</artifactId> <scope>provided</scope> </dependency> + <!-- to read the request headers of the servlet based and the netty http consumers --> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-http-common</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>jakarta.servlet</groupId> + <artifactId>jakarta.servlet-api</artifactId> + <version>${jakarta-servlet-api-version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-netty-http</artifactId> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.httpcomponents.core5</groupId> <artifactId>httpcore5</artifactId> @@ -89,6 +106,11 @@ <artifactId>camel-servlet</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-undertow</artifactId> + <scope>test</scope> + </dependency> <dependency> <groupId>io.undertow</groupId> <artifactId>undertow-servlet</artifactId> diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/HttpRequestHeaders.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/HttpRequestHeaders.java new file mode 100644 index 000000000000..3cf1fe56e80a --- /dev/null +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/HttpRequestHeaders.java @@ -0,0 +1,124 @@ +/* + * 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.camel.oauth; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads a header of the HTTP request a message was created from. + * <p> + * HTTP consumers also map query and form parameters into the message headers, so a message header is not necessarily + * the request header of that name. Credentials are therefore read from the HTTP request itself whenever the message + * carries it: platform-http on Vert.x and on Spring Boot, the servlet based consumers (camel-servlet, camel-jetty) and + * camel-netty-http. The message types are matched by name, and a transport specific class is only loaded for a message + * of its own type, so none of these transports has to be on the classpath. + * <p> + * Any other message falls back to the message headers, leaving out a value that also appears in the query string of the + * request: the consumer may have mapped it from a query parameter, and a credential does not belong in a url anyway + * (RFC 6750 section 5.3). + */ +final class HttpRequestHeaders { + + private static final Logger LOG = LoggerFactory.getLogger(HttpRequestHeaders.class); + + private static final String VERTX_HTTP_MESSAGE = "org.apache.camel.component.platform.http.vertx.HttpMessage"; + private static final String SERVLET_HTTP_MESSAGE = "org.apache.camel.http.common.HttpMessage"; + private static final String NETTY_HTTP_MESSAGE = "org.apache.camel.component.netty.http.NettyHttpMessage"; + // from camel-platform-http-starter, which camel-oauth does not depend on; it gives the servlet request + private static final String SPRING_BOOT_PLATFORM_HTTP_MESSAGE + = "org.apache.camel.component.platform.http.springboot.PlatformHttpMessage"; + + private HttpRequestHeaders() { + } + + /** + * The values of the named header of the HTTP request that the message comes from. + * + * @param message the message + * @param name the header name, matched ignoring case + * @return the header values, possibly none + */ + static List<String> values(Message message, String name) { + return requestHeaderValues(message, name).orElseGet(() -> messageHeaderValues(message, name)); + } + + private static Optional<List<String>> requestHeaderValues(Message message, String name) { + for (Class<?> type = message.getClass(); type != null; type = type.getSuperclass()) { + if (VERTX_HTTP_MESSAGE.equals(type.getName())) { + return VertxHttpRequestHeaders.values(message, name); + } + if (SERVLET_HTTP_MESSAGE.equals(type.getName())) { + return ServletHttpRequestHeaders.values(message, name); + } + if (NETTY_HTTP_MESSAGE.equals(type.getName())) { + return NettyHttpRequestHeaders.values(message, name); + } + if (SPRING_BOOT_PLATFORM_HTTP_MESSAGE.equals(type.getName())) { + return ServletHttpRequestHeaders.valuesOfSpringBootMessage(message, name); + } + } + return Optional.empty(); + } + + private static List<String> messageHeaderValues(Message message, String name) { + List<String> values = new ArrayList<>(); + Object header = message.getHeader(name); + if (header instanceof Iterable<?> iterable) { + iterable.forEach(value -> { + if (value != null) { + values.add(value.toString()); + } + }); + } else if (header != null) { + values.add(header.toString()); + } + + String query = message.getHeader(Exchange.HTTP_RAW_QUERY, String.class); + if (query == null) { + query = message.getHeader(Exchange.HTTP_QUERY, String.class); + } + if (query != null && !query.isEmpty() && !values.isEmpty()) { + String rawQuery = query; + String decodedQuery = decode(rawQuery); + if (decodedQuery == null) { + // a query string that cannot be decoded cannot be checked either, so none of the values is used + LOG.debug("The {} message header is not used: the query string of the request cannot be decoded", name); + values.clear(); + } else if (values.removeIf(value -> rawQuery.contains(value) || decodedQuery.contains(value))) { + LOG.debug("A {} message header value is not used: the query string of the request holds it too", name); + } + } + return values; + } + + private static String decode(String query) { + try { + return URLDecoder.decode(query, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySessionStore.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySessionStore.java index 22244eae6c90..70c10047455c 100644 --- a/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySessionStore.java +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySessionStore.java @@ -80,13 +80,13 @@ public class InMemorySessionStore implements OAuthSessionStore { private Map<String, String> getCookies(Exchange exchange) { var msg = exchange.getMessage(); - var maybeCookie = Optional.ofNullable(msg.getHeader("Cookie")); + var maybeCookie = getCookieHeader(msg); if (maybeCookie.isEmpty()) { log.warn("No Cookie in HTTP request"); return Map.of(); } - var value = maybeCookie.get().toString(); + var value = maybeCookie.get(); var cookieMap = Arrays.stream(value.split(";")) .map(String::trim) .map(s -> s.split("=", 2)) @@ -97,6 +97,16 @@ public class InMemorySessionStore implements OAuthSessionStore { return cookieMap; } + /** + * The Cookie header of the request, read with {@link HttpRequestHeaders} because the message headers also hold the + * query and form parameters of the request. + */ + private static Optional<String> getCookieHeader(Message msg) { + var values = HttpRequestHeaders.values(msg, "Cookie"); + // HTTP/2 may send the cookies in several Cookie headers (RFC 9113 section 8.2.3) + return values.isEmpty() ? Optional.empty() : Optional.of(String.join("; ", values)); + } + private void setSessionCookie(Message msg, OAuthSession session) { var sessionId = session.getSessionId(); var cookieId = "%s=%s".formatted(CAMEL_OAUTH_COOKIE, sessionId); diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/NettyHttpRequestHeaders.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/NettyHttpRequestHeaders.java new file mode 100644 index 000000000000..914e3e6f9420 --- /dev/null +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/NettyHttpRequestHeaders.java @@ -0,0 +1,38 @@ +/* + * 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.camel.oauth; + +import java.util.List; +import java.util.Optional; + +import org.apache.camel.Message; +import org.apache.camel.component.netty.http.NettyHttpMessage; + +/** + * Reads request headers from a camel-netty-http message. Only loaded for a message of that type, see + * {@link HttpRequestHeaders}. + */ +final class NettyHttpRequestHeaders { + + private NettyHttpRequestHeaders() { + } + + static Optional<List<String>> values(Message message, String name) { + var request = ((NettyHttpMessage) message).getHttpRequest(); + return request != null ? Optional.of(request.headers().getAll(name)) : Optional.empty(); + } +} diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuth.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuth.java index 93bc587a52fc..dfa894e2a1ba 100644 --- a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuth.java +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuth.java @@ -43,6 +43,13 @@ public abstract class OAuth { // public static final String CAMEL_OAUTH_COOKIE = "camel.oauth.session"; + // Camel OAuth Exchange Properties + // + /** + * The {@link UserProfile} that {@link OAuthBearerTokenProcessor} authenticated for the current request. + */ + public static final String CAMEL_OAUTH_USER_PROFILE = "CamelOAuthUserProfile"; + protected OAuthConfig config; protected OAuthSessionStore sessionStore; diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthBearerTokenProcessor.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthBearerTokenProcessor.java index 337a29968946..cf9ec132a470 100644 --- a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthBearerTokenProcessor.java +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthBearerTokenProcessor.java @@ -20,6 +20,14 @@ import org.apache.camel.Exchange; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_USER_PROFILE; + +/** + * Authenticates the bearer token of the request, as sent in its {@code Authorization} header (RFC 6750 section 2.1). + * <p> + * Bearer authentication is per request: the authenticated {@link UserProfile} is set as the + * {@link OAuth#CAMEL_OAUTH_USER_PROFILE} exchange property, and no {@link OAuthSession} is created or updated. + */ public class OAuthBearerTokenProcessor extends AbstractOAuthProcessor { private final Logger log = LoggerFactory.getLogger(getClass()); @@ -33,14 +41,20 @@ public class OAuthBearerTokenProcessor extends AbstractOAuthProcessor { // Validate Authorization header // - var authHeader = msg.getHeader("Authorization", String.class); - if (authHeader == null) { + var authHeaders = HttpRequestHeaders.values(msg, "Authorization"); + if (authHeaders.isEmpty()) { log.error("No Authorization header in request"); rejectUnauthorized(exchange, "Authorization header"); return; } - var toks = authHeader.split(" "); + if (authHeaders.size() > 1) { + log.error("Invalid Authorization header: expected exactly one, got {}", authHeaders.size()); + rejectUnauthorized(exchange, "Invalid Authorization header"); + return; + } + + var toks = authHeaders.get(0).split(" "); if (toks.length != 2 || !"Bearer".equals(toks[0])) { log.error("Invalid Authorization header"); rejectUnauthorized(exchange, "Invalid Authorization header"); @@ -59,10 +73,9 @@ public class OAuthBearerTokenProcessor extends AbstractOAuthProcessor { var access_token = toks[1]; var userProfile = oauth.authenticate(new TokenCredentials(access_token)); - // Get or create the OAuthSession + // The token authenticates this request only, so the profile goes on the exchange and not into an OAuthSession // - var session = oauth.getOrCreateSession(exchange); - session.putUserProfile(userProfile); + exchange.setProperty(CAMEL_OAUTH_USER_PROFILE, userProfile); log.info("Authenticated {}", userProfile.subject()); userProfile.logDetails(); diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/ServletHttpRequestHeaders.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/ServletHttpRequestHeaders.java new file mode 100644 index 000000000000..d9c0947413ca --- /dev/null +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/ServletHttpRequestHeaders.java @@ -0,0 +1,81 @@ +/* + * 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.camel.oauth; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.http.common.HttpMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads request headers from a message of the servlet based HTTP consumers. Only loaded for a message of such a type, + * see {@link HttpRequestHeaders}. + */ +final class ServletHttpRequestHeaders { + + private static final Logger LOG = LoggerFactory.getLogger(ServletHttpRequestHeaders.class); + + private ServletHttpRequestHeaders() { + } + + /** + * Reads the headers of the request of a camel-http-common {@link HttpMessage} (camel-servlet, camel-jetty). + */ + static Optional<List<String>> values(Message message, String name) { + return values(((HttpMessage) message).getRequest(), name); + } + + /** + * Reads the headers of the request of a platform-http message on Spring Boot. The request comes from the + * {@code getRequest()} method of the message, which camel-oauth does not depend on, or else from the + * {@link Exchange#HTTP_SERVLET_REQUEST} header that the message also sets, but that a route may remove. Without a + * request no value is read at all, rather than the message headers, which also hold the form parameters. + */ + static Optional<List<String>> valuesOfSpringBootMessage(Message message, String name) { + var request = springBootRequest(message); + if (request == null) { + LOG.debug("No HTTP request on the platform-http message, so no {} header is read", name); + return Optional.of(List.of()); + } + return Optional.of(values(request, name).orElse(List.of())); + } + + private static HttpServletRequest springBootRequest(Message message) { + try { + if (message.getClass().getMethod("getRequest").invoke(message) instanceof HttpServletRequest request) { + return request; + } + } catch (ReflectiveOperationException | SecurityException e) { + LOG.debug("Cannot call getRequest() on {}, reading the {} header instead", message.getClass().getName(), + Exchange.HTTP_SERVLET_REQUEST, e); + } + return message.getHeader(Exchange.HTTP_SERVLET_REQUEST) instanceof HttpServletRequest request ? request : null; + } + + private static Optional<List<String>> values(HttpServletRequest request, String name) { + // a container that does not allow access to the headers returns null + var values = request != null ? request.getHeaders(name) : null; + return values != null ? Optional.of(Collections.list(values)) : Optional.empty(); + } +} diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/VertxHttpRequestHeaders.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/VertxHttpRequestHeaders.java new file mode 100644 index 000000000000..48c23920bd43 --- /dev/null +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/VertxHttpRequestHeaders.java @@ -0,0 +1,38 @@ +/* + * 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.camel.oauth; + +import java.util.List; +import java.util.Optional; + +import org.apache.camel.Message; +import org.apache.camel.component.platform.http.vertx.HttpMessage; + +/** + * Reads request headers from a platform-http message on Vert.x. Only loaded for a message of that type, see + * {@link HttpRequestHeaders}. + */ +final class VertxHttpRequestHeaders { + + private VertxHttpRequestHeaders() { + } + + static Optional<List<String>> values(Message message, String name) { + var request = ((HttpMessage) message).getRequest(); + return request != null ? Optional.of(request.headers().getAll(name)) : Optional.empty(); + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/component/platform/http/springboot/PlatformHttpMessage.java b/components/camel-oauth/src/test/java/org/apache/camel/component/platform/http/springboot/PlatformHttpMessage.java new file mode 100644 index 000000000000..5f9aa36db903 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/component/platform/http/springboot/PlatformHttpMessage.java @@ -0,0 +1,43 @@ +/* + * 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.camel.component.platform.http.springboot; + +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.support.DefaultMessage; + +/** + * Test stand-in for the message of platform-http on Spring Boot (camel-platform-http-starter, in camel-spring-boot), + * which camel-oauth does not depend on. It has the same name and, like the real one, gives the servlet request from + * {@link #getRequest()} and holds it in the {@link Exchange#HTTP_SERVLET_REQUEST} header. + */ +public class PlatformHttpMessage extends DefaultMessage { + + private final HttpServletRequest request; + + public PlatformHttpMessage(CamelContext camelContext, HttpServletRequest request) { + super(camelContext); + this.request = request; + setHeader(Exchange.HTTP_SERVLET_REQUEST, request); + } + + public HttpServletRequest getRequest() { + return request; + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/AbstractOAuthRequestHeadersTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/AbstractOAuthRequestHeadersTest.java new file mode 100644 index 000000000000..2fa2eeb6643f --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/AbstractOAuthRequestHeadersTest.java @@ -0,0 +1,249 @@ +/* + * 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.camel.oauth; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.test.AvailablePortFinder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * The OAuth processors behind a real HTTP consumer: the bearer token and the session cookie must come from the + * {@code Authorization} and {@code Cookie} request headers, although the consumer also maps the query and form + * parameters of the request into the message headers. The identity provider is {@link StubOAuth}. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class AbstractOAuthRequestHeadersTest { + + private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + + private int port; + private CamelContext context; + private OAuth oauth; + + /** + * Starts the HTTP server, before the routes are added to the context. + */ + abstract void startServer(CamelContext context, int port) throws Exception; + + abstract void stopServer() throws Exception; + + /** + * The consumer endpoint uri for the given path. + */ + abstract String endpointUri(String path); + + /** + * Whether the processors can read the HTTP request of this consumer. When they cannot, they read the message + * headers, without the values that also appear in the query string. + */ + boolean readsTheRequestHeaders() { + return true; + } + + @BeforeAll + void start() throws Exception { + port = AvailablePortFinder.getNextAvailable(); + context = new DefaultCamelContext(); + context.getPropertiesComponent().addInitialProperty(OAuth.CAMEL_OAUTH_REDIRECT_URI, "https://app.example/auth"); + oauth = new StubOAuth(); + context.getRegistry().bind(OAuth.class.getName(), oauth); + startServer(context, port); + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from(endpointUri("/api")) + .process(new OAuthBearerTokenProcessor()) + .process(e -> e.getMessage().setBody("api:" + bearerSubject(e))); + from(endpointUri("/auth")) + .process(new OAuthCodeFlowCallback()); + from(endpointUri("/app")) + .process(new OAuthCodeFlowProcessor()) + .process(e -> e.getMessage().setBody("app:" + sessionSubject(e))); + } + }); + context.start(); + } + + @AfterAll + void stop() throws Exception { + if (context != null) { + context.stop(); + } + stopServer(); + } + + @Test + void bearerTokenIsReadFromTheAuthorizationHeader() throws Exception { + var response = get("/api", null, "Bearer " + StubOAuth.ALICE_TOKEN); + + assertEquals(200, response.statusCode()); + assertEquals("api:alice", response.body()); + assertNull(setCookie(response), "a bearer request does not start a session"); + } + + @Test + void bearerTokenIsNotReadFromAQueryParameter() throws Exception { + var response = get("/api?Authorization=Bearer%20" + StubOAuth.ALICE_TOKEN, null, null); + + assertEquals(401, response.statusCode()); + assertNull(setCookie(response)); + } + + @Test + void bearerTokenIsNotReadFromAFormParameter() throws Exception { + assumeTrue(readsTheRequestHeaders(), + "the request of this consumer cannot be read from the route, so its form parameters are message headers"); + + var request = request("/api") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("Authorization=Bearer%20" + StubOAuth.ALICE_TOKEN)) + .build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode()); + assertNull(setCookie(response)); + } + + @Test + void authorizationHeaderIsReadWhateverTheQueryStringHolds() throws Exception { + assumeTrue(readsTheRequestHeaders(), + "the request of this consumer cannot be read from the route, so a value in the query string is left out"); + + var response = get("/api?note=Bearer%20" + StubOAuth.ALICE_TOKEN, null, "Bearer " + StubOAuth.ALICE_TOKEN); + + assertEquals(200, response.statusCode()); + assertEquals("api:alice", response.body()); + } + + @Test + void aQueryParameterDoesNotReplaceTheAuthorizationHeader() throws Exception { + var response = get("/api?Authorization=Bearer%20" + StubOAuth.BOB_TOKEN, null, "Bearer " + StubOAuth.ALICE_TOKEN); + + assertEquals(200, response.statusCode()); + assertEquals("api:alice", response.body()); + } + + @Test + void severalAuthorizationHeadersAreRejected() throws Exception { + var request = request("/api") + .header("Authorization", "Bearer " + StubOAuth.ALICE_TOKEN) + .header("Authorization", "Bearer " + StubOAuth.BOB_TOKEN) + .GET() + .build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode()); + } + + @Test + void sessionCookieIsReadFromTheCookieHeader() throws Exception { + var cookie = loginAsBob(); + + var response = get("/app", cookie, null); + + assertEquals(200, response.statusCode()); + assertEquals("app:bob", response.body()); + } + + @Test + void sessionCookieIsNotReadFromAQueryParameter() throws Exception { + var cookie = loginAsBob(); + + var response = get("/app?Cookie=" + URLEncoder.encode(cookie, StandardCharsets.UTF_8), null, null); + + assertEquals(302, response.statusCode(), "a request without a session cookie is sent to the login"); + } + + @Test + void bearerRequestLeavesTheSessionUnchanged() throws Exception { + var cookie = loginAsBob(); + + var response = get("/api", cookie, "Bearer " + StubOAuth.ALICE_TOKEN); + + assertEquals(200, response.statusCode()); + assertEquals("api:alice", response.body()); + assertNull(setCookie(response)); + assertEquals("app:bob", get("/app", cookie, null).body()); + } + + /** + * Runs the authorization code flow and returns the session cookie the browser holds afterwards. + */ + private String loginAsBob() throws Exception { + var start = get("/app", null, null); + assertEquals(302, start.statusCode()); + var sessionCookie = setCookie(start); + assertNotNull(sessionCookie); + var cookie = sessionCookie.substring(0, sessionCookie.indexOf(';')); + var location = start.headers().firstValue("Location").orElseThrow(); + var state = location.substring(location.indexOf("state=") + "state=".length()); + + var callback = get("/auth?code=" + StubOAuth.BOB_CODE + "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8), + cookie, null); + assertEquals(302, callback.statusCode()); + return cookie; + } + + HttpResponse<String> get(String pathAndQuery, String cookie, String authorization) throws Exception { + var builder = request(pathAndQuery).GET(); + if (cookie != null) { + builder.header("Cookie", cookie); + } + if (authorization != null) { + builder.header("Authorization", authorization); + } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpRequest.Builder request(String pathAndQuery) { + return HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + pathAndQuery)); + } + + private static String setCookie(HttpResponse<?> response) { + return response.headers().firstValue("Set-Cookie").orElse(null); + } + + private static String bearerSubject(Exchange exchange) { + var profile = exchange.getProperty(OAuth.CAMEL_OAUTH_USER_PROFILE, UserProfile.class); + return profile != null ? profile.subject() : "none"; + } + + private String sessionSubject(Exchange exchange) { + return oauth.getSession(exchange) + .flatMap(OAuthSession::getUserProfile) + .map(UserProfile::subject) + .orElse("none"); + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/HttpRequestHeadersTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/HttpRequestHeadersTest.java new file mode 100644 index 000000000000..07fb18aa50b6 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/HttpRequestHeadersTest.java @@ -0,0 +1,168 @@ +/* + * 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.camel.oauth; + +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.component.platform.http.springboot.PlatformHttpMessage; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.DefaultExchange; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A message that gives no access to the HTTP request: the header values are read from the message, without the values + * that also appear in the query string of the request. And the message of platform-http on Spring Boot, which holds the + * servlet request in a header. + */ +class HttpRequestHeadersTest { + + @Test + void messageHeaderIsReadWhenThereIsNoQueryString() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Authorization", "Bearer token"); + + assertEquals(List.of("Bearer token"), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void messageHeaderIsReadWhenTheQueryStringDoesNotHoldIt() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Authorization", "Bearer token"); + message.setHeader(Exchange.HTTP_RAW_QUERY, "page=2&sort=name"); + + assertEquals(List.of("Bearer token"), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void valueThatTheQueryStringHoldsIsLeftOut() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Authorization", "Bearer token"); + message.setHeader(Exchange.HTTP_RAW_QUERY, "Authorization=Bearer%20token"); + + assertEquals(List.of(), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void valueThatTheUndecodedQueryStringHoldsIsLeftOut() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Cookie", "camel.oauth.session=abc"); + message.setHeader(Exchange.HTTP_QUERY, "Cookie=camel.oauth.session=abc"); + + assertEquals(List.of(), HttpRequestHeaders.values(message, "Cookie")); + } + } + + @Test + void onlyTheValueThatTheQueryStringHoldsIsLeftOut() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Authorization", List.of("Bearer header-token", "Bearer query-token")); + message.setHeader(Exchange.HTTP_RAW_QUERY, "Authorization=Bearer%20query-token"); + + assertEquals(List.of("Bearer header-token"), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void noValueIsReadWhenTheQueryStringCannotBeDecoded() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new DefaultExchange(context).getMessage(); + message.setHeader("Authorization", "Bearer token"); + message.setHeader(Exchange.HTTP_RAW_QUERY, "discount=100%"); + + assertEquals(List.of(), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void springBootMessageIsReadFromTheServletRequest() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new PlatformHttpMessage( + context, servletRequest(Map.of("authorization", List.of("Bearer request-token")))); + // what the binding maps from a form field of the same name + message.setHeader("Authorization", "Bearer form-token"); + + assertEquals(List.of("Bearer request-token"), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void springBootMessageHeaderIsNotReadWhenTheRequestHasNone() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new PlatformHttpMessage(context, servletRequest(Map.of())); + message.setHeader("Authorization", "Bearer form-token"); + + assertEquals(List.of(), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void springBootMessageIsReadWhenTheRouteRemovedTheServletRequestHeader() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new PlatformHttpMessage( + context, servletRequest(Map.of("authorization", List.of("Bearer request-token")))); + message.setHeader("Authorization", "Bearer form-token"); + // as the hardening checklist of the security model advises for untrusted callers + message.removeHeaders("Camel*"); + + assertEquals(List.of("Bearer request-token"), HttpRequestHeaders.values(message, "Authorization")); + } + } + + @Test + void springBootMessageWithoutARequestReadsNothing() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + Message message = new PlatformHttpMessage(context, null); + message.setHeader("Authorization", "Bearer form-token"); + + assertEquals(List.of(), HttpRequestHeaders.values(message, "Authorization")); + } + } + + /** + * A servlet request with the given headers, keyed by lower case name, that only answers {@code getHeaders}. + */ + private static HttpServletRequest servletRequest(Map<String, List<String>> headers) { + return (HttpServletRequest) Proxy.newProxyInstance(HttpRequestHeadersTest.class.getClassLoader(), + new Class<?>[] { HttpServletRequest.class }, + (proxy, method, args) -> switch (method.getName()) { + case "getHeaders" -> Collections.enumeration( + headers.getOrDefault(((String) args[0]).toLowerCase(Locale.ROOT), List.of())); + case "toString" -> "HttpServletRequest" + headers; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthBearerTokenProcessorTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthBearerTokenProcessorTest.java new file mode 100644 index 000000000000..3ab41a6a3a83 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthBearerTokenProcessorTest.java @@ -0,0 +1,85 @@ +/* + * 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.camel.oauth; + +import org.apache.camel.Exchange; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.DefaultExchange; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Bearer authentication on a message that carries no HTTP request, so the processor reads the message headers. + */ +class OAuthBearerTokenProcessorTest { + + @Test + void authenticatedProfileIsSetOnTheExchange() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.getRegistry().bind(OAuth.class.getName(), new StubOAuth()); + Exchange exchange = new DefaultExchange(context); + exchange.getMessage().setHeader("Authorization", "Bearer " + StubOAuth.ALICE_TOKEN); + + new OAuthBearerTokenProcessor().process(exchange); + + UserProfile profile = exchange.getProperty(OAuth.CAMEL_OAUTH_USER_PROFILE, UserProfile.class); + assertNotNull(profile); + assertEquals("alice", profile.subject()); + assertFalse(exchange.isRouteStop()); + } + } + + @Test + void noSessionIsCreated() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + OAuth oauth = new StubOAuth(); + context.getRegistry().bind(OAuth.class.getName(), oauth); + Exchange exchange = new DefaultExchange(context); + exchange.getMessage().setHeader("Authorization", "Bearer " + StubOAuth.ALICE_TOKEN); + + new OAuthBearerTokenProcessor().process(exchange); + + assertTrue(oauth.getSession(exchange).isEmpty()); + assertNull(exchange.getMessage().getHeader("Set-Cookie")); + assertNull(exchange.getMessage().getHeader(OAuth.CAMEL_OAUTH_SESSION_ID)); + } + } + + @Test + void existingSessionIsLeftUnchanged() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + OAuth oauth = new StubOAuth(); + context.getRegistry().bind(OAuth.class.getName(), oauth); + OAuthSession session = oauth.createSession(new DefaultExchange(context)); + session.putUserProfile(oauth.authenticate(new TokenCredentials(StubOAuth.BOB_TOKEN))); + + Exchange exchange = new DefaultExchange(context); + exchange.getMessage().setHeader("Cookie", OAuth.CAMEL_OAUTH_COOKIE + "=" + session.getSessionId()); + exchange.getMessage().setHeader("Authorization", "Bearer " + StubOAuth.ALICE_TOKEN); + + new OAuthBearerTokenProcessor().process(exchange); + + assertEquals("alice", exchange.getProperty(OAuth.CAMEL_OAUTH_USER_PROFILE, UserProfile.class).subject()); + assertEquals("bob", session.getUserProfile().orElseThrow().subject()); + } + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersNettyHttpTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersNettyHttpTest.java new file mode 100644 index 000000000000..fc85816ee6b2 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersNettyHttpTest.java @@ -0,0 +1,43 @@ +/* + * 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.camel.oauth; + +import org.apache.camel.CamelContext; + +/** + * The OAuth processors behind camel-netty-http. + */ +class OAuthRequestHeadersNettyHttpTest extends AbstractOAuthRequestHeadersTest { + + private int port; + + @Override + void startServer(CamelContext context, int port) { + // the consumers start the server + this.port = port; + } + + @Override + void stopServer() { + // stopped with the context + } + + @Override + String endpointUri(String path) { + return "netty-http:http://127.0.0.1:" + port + path; + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersServletTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersServletTest.java new file mode 100644 index 000000000000..aa801d2c57a3 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersServletTest.java @@ -0,0 +1,66 @@ +/* + * 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.camel.oauth; + +import io.undertow.Handlers; +import io.undertow.Undertow; +import io.undertow.servlet.Servlets; +import io.undertow.servlet.api.DeploymentManager; +import org.apache.camel.CamelContext; +import org.apache.camel.component.servlet.CamelHttpTransportServlet; + +/** + * The OAuth processors behind camel-servlet. + */ +class OAuthRequestHeadersServletTest extends AbstractOAuthRequestHeadersTest { + + // a servlet name of its own, so that other servlet tests of the module do not share the servlet registry entry + private static final String SERVLET_NAME = "OAuthRequestHeadersServlet"; + + private DeploymentManager manager; + private Undertow server; + + @Override + void startServer(CamelContext context, int port) throws Exception { + var deploymentInfo = Servlets.deployment() + .setContextPath("/") + .setDeploymentName(SERVLET_NAME) + .setClassLoader(OAuthRequestHeadersServletTest.class.getClassLoader()) + .addServlet(Servlets.servlet(SERVLET_NAME, CamelHttpTransportServlet.class).addMapping("/*")); + manager = Servlets.newContainer().addDeployment(deploymentInfo); + manager.deploy(); + var path = Handlers.path(Handlers.redirect("/")).addPrefixPath("/", manager.start()); + server = Undertow.builder().addHttpListener(port, "127.0.0.1").setHandler(path).build(); + server.start(); + } + + @Override + void stopServer() throws Exception { + if (manager != null) { + manager.stop(); + manager.undeploy(); + } + if (server != null) { + server.stop(); + } + } + + @Override + String endpointUri(String path) { + return "servlet:" + path + "?servletName=" + SERVLET_NAME; + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersUndertowTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersUndertowTest.java new file mode 100644 index 000000000000..043bf2da45f2 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersUndertowTest.java @@ -0,0 +1,59 @@ +/* + * 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.camel.oauth; + +import org.apache.camel.CamelContext; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The OAuth processors behind camel-undertow, whose messages do not give access to the HTTP request: the processors + * read the message headers, without the values that also appear in the query string. + */ +class OAuthRequestHeadersUndertowTest extends AbstractOAuthRequestHeadersTest { + + private int port; + + @Override + void startServer(CamelContext context, int port) { + // the consumers start the server + this.port = port; + } + + @Override + void stopServer() { + // stopped with the context + } + + @Override + String endpointUri(String path) { + return "undertow:http://127.0.0.1:" + port + path; + } + + @Override + boolean readsTheRequestHeaders() { + return false; + } + + @Test + void authorizationHeaderThatTheQueryStringAlsoHoldsIsNotRead() throws Exception { + var response = get("/api?note=Bearer%20" + StubOAuth.ALICE_TOKEN, null, "Bearer " + StubOAuth.ALICE_TOKEN); + + assertEquals(401, response.statusCode()); + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersVertxTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersVertxTest.java new file mode 100644 index 000000000000..993c1ca04d24 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthRequestHeadersVertxTest.java @@ -0,0 +1,45 @@ +/* + * 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.camel.oauth; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration; + +/** + * The OAuth processors behind platform-http on Vert.x. + */ +class OAuthRequestHeadersVertxTest extends AbstractOAuthRequestHeadersTest { + + @Override + void startServer(CamelContext context, int port) throws Exception { + var conf = new VertxPlatformHttpServerConfiguration(); + conf.setBindHost("127.0.0.1"); + conf.setBindPort(port); + context.addService(new VertxPlatformHttpServer(conf)); + } + + @Override + void stopServer() { + // stopped with the context + } + + @Override + String endpointUri(String path) { + return "platform-http:" + path; + } +} diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/StubOAuth.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/StubOAuth.java new file mode 100644 index 000000000000..e078d22777b6 --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/StubOAuth.java @@ -0,0 +1,74 @@ +/* + * 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.camel.oauth; + +import com.google.gson.JsonObject; +import org.apache.camel.CamelContext; + +/** + * An identity provider stand-in: it knows the access tokens of alice and bob, and one authorization code that logs bob + * in. Everything else is rejected. + */ +class StubOAuth extends OAuth { + + static final String ALICE_TOKEN = "alice-token"; + static final String BOB_TOKEN = "bob-token"; + static final String BOB_CODE = "bob-code"; + + @Override + public void discoverOAuthConfig(CamelContext ctx) { + // nothing to discover + } + + @Override + public UserProfile authenticate(Credentials creds) { + String token; + if (creds instanceof TokenCredentials tokenCreds) { + token = tokenCreds.getToken(); + } else if (creds instanceof UserCredentials userCreds) { + token = userCreds.getUserProfile().accessToken().orElseThrow(); + } else if (creds instanceof AuthCodeCredentials codeCreds && BOB_CODE.equals(codeCreds.getCode())) { + token = BOB_TOKEN; + } else { + throw new OAuthException("Unsupported credentials"); + } + String user; + if (ALICE_TOKEN.equals(token)) { + user = "alice"; + } else if (BOB_TOKEN.equals(token)) { + user = "bob"; + } else { + throw new OAuthException("Unknown token"); + } + var principal = new JsonObject(); + principal.addProperty("access_token", token); + principal.addProperty("username", user); + var attributes = new JsonObject(); + attributes.addProperty("exp", System.currentTimeMillis() / 1000L + 3600); + return new UserProfile(principal, attributes); + } + + @Override + public String buildLogoutRequestUrl(OAuthLogoutParams params) { + return "https://idp.example/logout"; + } + + @Override + public String buildCodeFlowAuthRequestUrl(OAuthCodeFlowParams params) { + return "https://idp.example/auth?state=" + params.getState(); + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index 0f189294ba46..77aa80050875 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -1812,3 +1812,23 @@ link. Links resolving inside the starting directory work as before. Set `jailSta links to other directories (this also turns off the check of `../` in producer file names). A skipped file is logged at WARN the first time and at DEBUG on later polls; this log-level change also applies to the remote file consumers, which keep their existing lexical check. + +=== camel-oauth + +`OAuthBearerTokenProcessor` no longer creates or updates the OAuth session, and no longer sets the +`camel.oauth.session` cookie nor the `CamelOAuthSessionId` header: a bearer token authenticates the request that +carries it, and nothing else. The authenticated `UserProfile` is available in the `CamelOAuthUserProfile` exchange +property (`OAuth.CAMEL_OAUTH_USER_PROFILE`). A route that read the profile from the OAuth session after +`OAuthBearerTokenProcessor` must read it from that exchange property instead. + +When the message comes from platform-http (on Vert.x or on Spring Boot), from a servlet based HTTP consumer +(camel-servlet, camel-jetty) or from camel-netty-http, the bearer token and the session cookie are now read from the +`Authorization` and `Cookie` headers of the HTTP request only. A query or form parameter of the same name, which these +consumers also map into the message headers, is no longer used for them, and neither is an `Authorization` or `Cookie` +message header that the route sets itself. For example +`+.setHeader("Authorization", simple("Bearer ${header.X-Forwarded-Access-Token}"))+` before +`OAuthBearerTokenProcessor` now results in a 401 response. A route that takes the token from elsewhere can validate it +with `OAuthHelper.validateOAuthToken` from camel-support instead. + +For any other message, such as one from camel-undertow, the processors keep reading the message headers, but leave out +a value that also appears in the query string of the request.
