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();
+ }
+}