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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new fd0dd2463435 CAMEL-25981: camel-knative - the OIDC check of the 
consumer compares the token in constant time and reads a renewed token (#29749)
fd0dd2463435 is described below

commit fd0dd24634355ebee1cd8924835b71dd304e5bb3
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 13 10:32:19 2026 +0100

    CAMEL-25981: camel-knative - the OIDC check of the consumer compares the 
token in constant time and reads a renewed token (#29749)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../src/main/docs/knative-http-component.adoc      |  33 ++++
 .../knative/http/KnativeHttpConsumer.java          |   3 +-
 .../knative/http/KnativeOidcServiceOptions.java    |  65 +++++--
 .../knative/http/KnativeHttpOidcConsumerTest.java  | 216 +++++++++++++++++++++
 4 files changed, 303 insertions(+), 14 deletions(-)

diff --git 
a/components/camel-knative/camel-knative-http/src/main/docs/knative-http-component.adoc
 
b/components/camel-knative/camel-knative-http/src/main/docs/knative-http-component.adoc
index fa8e78c2efa6..3b374e7374d0 100644
--- 
a/components/camel-knative/camel-knative-http/src/main/docs/knative-http-component.adoc
+++ 
b/components/camel-knative/camel-knative-http/src/main/docs/knative-http-component.adoc
@@ -245,3 +245,36 @@ camel.knative.client.oidc.token.path=oidc/token
 camel.knative.client.oidc.renew.tokens.on.forbidden=false
 camel.knative.client.oidc.cache.tokens=false
 ----
+
+== Knative Http consumer factory
+
+=== OIDC request verification
+
+With OIDC enabled on the service side, the Knative Http consumer accepts a 
request only when its `Authorization` header is `Bearer <token>` (`Bearer`, a 
space, and the token), where `<token>` is the token mounted at the token path. 
Any other request, also one without the header, gets a `401` response and does 
not reach the route.
+
+The consumer compares the token of the request with the mounted token. It does 
not validate the token as an OIDC token: the signature, the issuer, the 
audience and the expiry of the token are not checked.
+
+The service options use the property key prefix `camel.knative.service.oidc.` 
(or the environment variables `CAMEL_KNATIVE_SERVICE_OIDC_<PROPERTY_NAME>`):
+
+[width="100%",cols="2,1,5",options="header"]
+|===
+| Property | Default | Description
+| `camel.knative.service.oidc.enabled` | `false` | Whether the consumer 
verifies the token of each request.
+| `camel.knative.service.oidc.token.path` | | The location of the mounted 
token, such as `file:/oidc/token`.
+| `camel.knative.service.oidc.cache.tokens` | `true` | Whether the token is 
kept after it is read. With `false`, the token is read for each request.
+|===
+
+.application.properties
+[source,properties]
+----
+camel.knative.service.oidc.enabled=true
+camel.knative.service.oidc.token.path=file:/oidc/token
+
+camel.component.knative.consumerFactory.serviceOptions=#class:org.apache.camel.component.knative.http.KnativeOidcServiceOptions
+----
+
+This instantiates 
`org.apache.camel.component.knative.http.KnativeOidcServiceOptions` and 
configures it from the properties and environment variables above.
+
+==== Token renewal
+
+When Knative eventing renews the token, the volume mount is updated with the 
new token. When the token of a request does not match the cached token, the 
consumer reads the token from the token path again before it refuses the 
request. So a request with the renewed token is accepted, and from then on the 
token it replaces is refused.
diff --git 
a/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeHttpConsumer.java
 
b/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeHttpConsumer.java
index 0608e48c438c..c5f4b31df1d6 100644
--- 
a/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeHttpConsumer.java
+++ 
b/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeHttpConsumer.java
@@ -154,8 +154,7 @@ public class KnativeHttpConsumer extends DefaultConsumer {
                 route.handler(routingContext -> {
                     if 
(routingContext.request().headers().contains(HttpHeaders.AUTHORIZATION)) {
                         String auth = 
routingContext.request().getHeader(HttpHeaders.AUTHORIZATION);
-                        String token = oidcServiceOptions.retrieveOidcToken();
-                        if (("Bearer " + token).equals(auth)) {
+                        if (oidcServiceOptions.isAuthorized(auth)) {
                             routingContext.next();
                         } else {
                             routingContext.fail(401, new 
RuntimeCamelException("OIDC request verification failed - forbidden"));
diff --git 
a/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeOidcServiceOptions.java
 
b/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeOidcServiceOptions.java
index 1c2c54f274ad..6cf1b3bb30c9 100644
--- 
a/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeOidcServiceOptions.java
+++ 
b/components/camel-knative/camel-knative-http/src/main/java/org/apache/camel/component/knative/http/KnativeOidcServiceOptions.java
@@ -18,6 +18,8 @@
 package org.apache.camel.component.knative.http;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
 import java.util.Optional;
 
 import org.apache.camel.CamelContext;
@@ -28,6 +30,10 @@ import org.apache.camel.util.IOHelper;
 /**
  * OpenID connect service options implementation grants access to the OIDC 
token that should be used to verify client
  * requests.
+ * <p>
+ * The consumer accepts a request when its <code>Authorization</code> header 
is <code>Bearer</code> followed by the
+ * token mounted at the token path. The token is compared with the mounted 
one, it is not validated as an OIDC token
+ * (signature, issuer, audience or expiry).
  */
 public class KnativeOidcServiceOptions implements KnativeHttpServiceOptions {
     private static final String PROPERTY_PREFIX = 
"camel.knative.service.oidc.";
@@ -38,7 +44,7 @@ public class KnativeOidcServiceOptions implements 
KnativeHttpServiceOptions {
 
     private String oidcTokenPath;
 
-    private String oidcToken;
+    private volatile String oidcToken;
 
     private boolean cacheTokens = true;
 
@@ -81,17 +87,13 @@ public class KnativeOidcServiceOptions implements 
KnativeHttpServiceOptions {
      * Read OIDC token from given path. Caches the token for future usage.
      */
     public String retrieveOidcToken() {
-        if (oidcToken == null || !cacheTokens) {
-            try {
-                oidcToken = IOHelper
-                        
.loadText(ResourceHelper.resolveMandatoryResourceAsInputStream(getCamelContext(),
 oidcTokenPath))
-                        .trim();
-            } catch (IOException e) {
-                throw new RuntimeException(e);
-            }
+        String token = oidcToken;
+        if (token == null || !cacheTokens) {
+            token = loadOidcToken();
+            oidcToken = token;
         }
 
-        return oidcToken;
+        return token;
     }
 
     /**
@@ -99,8 +101,47 @@ public class KnativeOidcServiceOptions implements 
KnativeHttpServiceOptions {
      * update.
      */
     public String renewOidcToken() {
-        oidcToken = null;
-        return retrieveOidcToken();
+        String token = loadOidcToken();
+        oidcToken = token;
+        return token;
+    }
+
+    /**
+     * Whether the given value of the <code>Authorization</code> header is 
<code>Bearer</code> followed by the token at
+     * the token path. The comparison takes the same time wherever the first 
difference is, and its time depends only on
+     * the length of the token, not on the header of the request. When the 
cached token does not match, the token is
+     * read again, as Knative may have renewed the mounted token since it was 
read: the renewed token is then accepted,
+     * and the token it replaces is no longer accepted.
+     *
+     * @param  authorization the value of the <code>Authorization</code> 
header of the request, may be null
+     * @return               true when the request carries the token at the 
token path
+     */
+    public boolean isAuthorized(String authorization) {
+        if (authorization == null) {
+            return false;
+        }
+        if (matches(authorization, retrieveOidcToken())) {
+            return true;
+        }
+        // without the cache the token was just read
+        return cacheTokens && matches(authorization, renewOidcToken());
+    }
+
+    private static boolean matches(String authorization, String token) {
+        byte[] expected = ("Bearer " + token).getBytes(StandardCharsets.UTF_8);
+        // the expected value goes first: isEqual loops over the length of its 
first argument, also when the lengths
+        // differ, so the time does not depend on the header of the request
+        return MessageDigest.isEqual(expected, 
authorization.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private String loadOidcToken() {
+        try {
+            return IOHelper
+                    
.loadText(ResourceHelper.resolveMandatoryResourceAsInputStream(getCamelContext(),
 oidcTokenPath))
+                    .trim();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to load OIDC token from: " + 
oidcTokenPath, e);
+        }
     }
 
     public void setOidcEnabled(boolean oidcEnabled) {
diff --git 
a/components/camel-knative/camel-knative-http/src/test/java/org/apache/camel/component/knative/http/KnativeHttpOidcConsumerTest.java
 
b/components/camel-knative/camel-knative-http/src/test/java/org/apache/camel/component/knative/http/KnativeHttpOidcConsumerTest.java
new file mode 100644
index 000000000000..af26808aa82d
--- /dev/null
+++ 
b/components/camel-knative/camel-knative-http/src/test/java/org/apache/camel/component/knative/http/KnativeHttpOidcConsumerTest.java
@@ -0,0 +1,216 @@
+/*
+ * 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.knative.http;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import io.restassured.RestAssured;
+import io.restassured.specification.RequestSpecification;
+import io.vertx.core.http.HttpHeaders;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.cloudevents.CloudEvent;
+import org.apache.camel.cloudevents.CloudEvents;
+import org.apache.camel.component.knative.KnativeComponent;
+import org.apache.camel.component.knative.spi.Knative;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.PropertyBindingSupport;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+import static io.restassured.RestAssured.given;
+import static 
org.apache.camel.component.knative.KnativeEnvironmentSupport.sourceEndpoint;
+import static 
org.apache.camel.component.knative.http.KnativeHttpTestSupport.configurePlatformHttpComponent;
+import static 
org.apache.camel.component.knative.http.KnativeHttpTestSupport.httpAttribute;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The consumer with OIDC enabled accepts a request only when its 
Authorization header is the token mounted at the token
+ * path, and follows a renewal of that token: the renewed token is accepted 
and the one it replaces is refused.
+ */
+class KnativeHttpOidcConsumerTest {
+
+    private static final CloudEvent CE = CloudEvents.v1_0;
+
+    @RegisterExtension
+    AvailablePortFinder.Port platformHttpPort = AvailablePortFinder.find();
+
+    @TempDir
+    Path tempDir;
+
+    private CamelContext context;
+    private Path tokenFile;
+
+    @BeforeEach
+    void before() throws Exception {
+        context = new DefaultCamelContext();
+        configurePlatformHttpComponent(context, platformHttpPort.getPort());
+        RestAssured.port = platformHttpPort.getPort();
+
+        tokenFile = tempDir.resolve("token");
+        writeToken("the_mounted_token");
+    }
+
+    @AfterEach
+    void after() {
+        context.stop();
+    }
+
+    @Test
+    void acceptsTheMountedToken() throws Exception {
+        startConsumer(serviceOptions());
+
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+    }
+
+    @Test
+    void refusesAnotherToken() throws Exception {
+        startConsumer(serviceOptions());
+        MockEndpoint mock = context.getEndpoint("mock:result", 
MockEndpoint.class);
+        mock.expectedMessageCount(0);
+
+        assertThat(post("Bearer another_token")).isEqualTo(401);
+        // a prefix or a longer value of the token is not the token
+        assertThat(post("Bearer the_mounted_toke")).isEqualTo(401);
+        assertThat(post("Bearer the_mounted_token2")).isEqualTo(401);
+        assertThat(post("the_mounted_token")).isEqualTo(401);
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void refusesARequestWithoutAuthorization() throws Exception {
+        startConsumer(serviceOptions());
+
+        assertThat(post(null)).isEqualTo(401);
+    }
+
+    @Test
+    void acceptsARenewedTokenAndRefusesTheOldOne() throws Exception {
+        startConsumer(serviceOptions());
+
+        // the token is read and cached
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+
+        // Knative renews the token in the volume mount
+        writeToken("the_renewed_token");
+
+        assertThat(post("Bearer the_renewed_token")).isEqualTo(200);
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(401);
+        assertThat(post("Bearer the_renewed_token")).isEqualTo(200);
+    }
+
+    @Test
+    void acceptsTheOldTokenUntilTheRenewedOneIsUsed() throws Exception {
+        startConsumer(serviceOptions());
+
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+
+        writeToken("the_renewed_token");
+
+        // the cached token is read again when a comparison fails, so the old 
token is accepted until the sender
+        // uses the renewed one
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+        assertThat(post("Bearer the_renewed_token")).isEqualTo(200);
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(401);
+    }
+
+    @Test
+    void acceptsARenewedTokenWithoutTheCache() throws Exception {
+        KnativeOidcServiceOptions options = serviceOptions();
+        options.setCacheTokens(false);
+        startConsumer(options);
+
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+
+        writeToken("the_renewed_token");
+
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(401);
+        assertThat(post("Bearer the_renewed_token")).isEqualTo(200);
+    }
+
+    @Test
+    void configuredWithProperties() throws Exception {
+        
context.getPropertiesComponent().addInitialProperty("camel.knative.service.oidc.enabled",
 "true");
+        
context.getPropertiesComponent().addInitialProperty("camel.knative.service.oidc.token.path",
 "file:" + tokenFile);
+
+        KnativeComponent component = configureKnativeComponent();
+        PropertyBindingSupport.build().bind(context, component,
+                "consumerFactory.serviceOptions", "#class:" + 
KnativeOidcServiceOptions.class.getName());
+        startRoute();
+
+        assertThat(post("Bearer the_mounted_token")).isEqualTo(200);
+        assertThat(post("Bearer another_token")).isEqualTo(401);
+    }
+
+    private KnativeOidcServiceOptions serviceOptions() {
+        KnativeOidcServiceOptions options = new 
KnativeOidcServiceOptions(context);
+        options.setOidcEnabled(true);
+        options.setOidcTokenPath("file:" + tokenFile);
+        return options;
+    }
+
+    private void writeToken(String token) throws Exception {
+        // the mounted token file ends with a new line, which is not part of 
the token
+        Files.writeString(tokenFile, token + "\n", StandardCharsets.UTF_8);
+    }
+
+    private void startConsumer(KnativeOidcServiceOptions options) throws 
Exception {
+        KnativeComponent component = configureKnativeComponent();
+        if (component.getConsumerFactory() instanceof 
KnativeHttpConsumerFactory consumerFactory) {
+            consumerFactory.setServiceOptions(options);
+        }
+        startRoute();
+    }
+
+    private KnativeComponent configureKnativeComponent() throws Exception {
+        return KnativeHttpTestSupport.configureKnativeComponent(context, CE,
+                sourceEndpoint("from", Map.of(
+                        Knative.KNATIVE_CLOUD_EVENT_TYPE, 
"org.apache.camel.event",
+                        Knative.CONTENT_TYPE, "text/plain")));
+    }
+
+    private void startRoute() throws Exception {
+        RouteBuilder.addRoutes(context, b -> b.from("knative:endpoint/from")
+                .to("mock:result")
+                .setBody().constant("reply"));
+        context.start();
+    }
+
+    private int post(String authorization) {
+        RequestSpecification request = given()
+                .body("test")
+                .header(Exchange.CONTENT_TYPE, "text/plain")
+                .header(httpAttribute(CE, 
CloudEvent.CAMEL_CLOUD_EVENT_VERSION), CE.version())
+                .header(httpAttribute(CE, CloudEvent.CAMEL_CLOUD_EVENT_TYPE), 
"org.apache.camel.event")
+                .header(httpAttribute(CE, CloudEvent.CAMEL_CLOUD_EVENT_ID), 
"myEventID")
+                .header(httpAttribute(CE, 
CloudEvent.CAMEL_CLOUD_EVENT_SOURCE), "/somewhere");
+        if (authorization != null) {
+            request.header(HttpHeaders.AUTHORIZATION.toString(), 
authorization);
+        }
+        return request.when().post().statusCode();
+    }
+}

Reply via email to