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

Croway 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 495c5ad39a0f CAMEL-24437: camel-oauth - bind the authorization code 
callback to the flow with a state parameter (#25821)
495c5ad39a0f is described below

commit 495c5ad39a0f95f823da512b38da5c19609e2a1c
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Aug 28 12:09:55 2026 +0200

    CAMEL-24437: camel-oauth - bind the authorization code callback to the flow 
with a state parameter (#25821)
    
    buildCodeFlowAuthRequestUrl() sent only a redirect URI and scopes - no 
state - and
    OAuthCodeFlowCallback redeemed whatever code arrived and bound the 
resulting profile to
    the caller's session. Nothing tied the callback to a flow that session had 
started, which
    is the login CSRF that RFC 6749 section 10.12 and OpenID Connect Core 
require the state
    binding to prevent. OAuthCodeFlowParams already carried a state field; no 
processor set
    it. The hardcoded SameSite=None; Secure session cookie makes it reachable 
cross-site.
    
    OAuthCodeFlowProcessor now generates a 32-byte random state, stores it in 
the OAuth
    session, and passes it through both URL builders - VertxOAuth via
    OAuth2AuthorizationURL.setState, ServletOAuth as a state query parameter.
    OAuthCodeFlowCallback removes the stored value, so it is single use, and 
compares it with
    the callback's state using MessageDigest.isEqual. A callback with no flow 
in progress, or
    with a state that does not match, is answered with 400 and stops the route.
    
    Scope: state only. nonce needs ID-token validation to be worth sending, 
PKCE needs a
    code_verifier carried through AuthCodeCredentials and both authenticate()
    implementations, and the session cookie's SameSite is a separate change - 
all three are
    noted on the issue.
    
    Not verified end to end: OAuthCodeFlowVertxTest and 
OAuthCodeFlowServletTest are gated on
    an externally running Keycloak at https://oauth.localtest.me/kc, 
provisioned by the
    module's Helm chart, and were skipped here. They are what would confirm the 
provider
    echoes state back as a state message header.
    
    Signed-off-by: Andrea Cosentino <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: Federico Mariani <[email protected]>
---
 .../apache/camel/oauth/OAuthCodeFlowCallback.java  | 21 ++++++
 .../apache/camel/oauth/OAuthCodeFlowProcessor.java | 19 ++++-
 .../java/org/apache/camel/oauth/OAuthSession.java  |  6 ++
 .../apache/camel/oauth/jakarta/ServletOAuth.java   |  3 +
 .../org/apache/camel/oauth/vertx/VertxOAuth.java   |  8 +-
 .../camel/oauth/OAuthProcessorFailClosedTest.java  | 85 ++++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 23 ++++++
 7 files changed, 162 insertions(+), 3 deletions(-)

diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowCallback.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowCallback.java
index 677305dd5cf2..c80eda2a7763 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowCallback.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowCallback.java
@@ -16,12 +16,16 @@
  */
 package org.apache.camel.oauth;
 
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
 import org.apache.camel.Exchange;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_REDIRECT_URI;
 import static org.apache.camel.oauth.OAuthProperties.getRequiredProperty;
+import static org.apache.camel.oauth.OAuthSession.OAUTH_STATE;
 
 public class OAuthCodeFlowCallback extends AbstractOAuthProcessor {
 
@@ -48,6 +52,23 @@ public class OAuthCodeFlowCallback extends 
AbstractOAuthProcessor {
         var oauth = findOAuthOrThrow(context);
         var session = oauth.getOrCreateSession(exchange);
 
+        // The state must match the one this session sent to the identity 
provider, and it is single use.
+        // Without that binding any authorization code, obtained in any 
browser, could be redeemed here and
+        // bound to whichever session presented it - which is login CSRF.
+        var expectedState = session.<String> 
removeValue(OAUTH_STATE).orElse(null);
+        var actualState = msg.getHeader("state", String.class);
+        if (expectedState == null) {
+            log.error("No authorization code flow is in progress for this 
session");
+            reject(exchange, 400, "No authorization code flow in progress");
+            return;
+        }
+        if (actualState == null || !MessageDigest.isEqual(
+                expectedState.getBytes(StandardCharsets.UTF_8), 
actualState.getBytes(StandardCharsets.UTF_8))) {
+            log.error("Authorization state does not match the flow started by 
this session");
+            reject(exchange, 400, "Authorization state mismatch");
+            return;
+        }
+
         // Exchange the authorization code for access/refresh/id tokens
         //
         String redirectUri = getRequiredProperty(exchange.getContext(), 
CAMEL_OAUTH_REDIRECT_URI);
diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
index 2de2d8969b28..0ef855c34c04 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
@@ -16,6 +16,9 @@
  */
 package org.apache.camel.oauth;
 
+import java.security.SecureRandom;
+import java.util.Base64;
+
 import org.apache.camel.Exchange;
 import org.apache.camel.Message;
 import org.slf4j.Logger;
@@ -23,9 +26,12 @@ import org.slf4j.LoggerFactory;
 
 import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_REDIRECT_URI;
 import static org.apache.camel.oauth.OAuthProperties.getRequiredProperty;
+import static org.apache.camel.oauth.OAuthSession.OAUTH_STATE;
 
 public class OAuthCodeFlowProcessor extends AbstractOAuthProcessor {
 
+    private static final SecureRandom RANDOM = new SecureRandom();
+
     private final Logger log = LoggerFactory.getLogger(getClass());
 
     @Override
@@ -65,8 +71,13 @@ public class OAuthCodeFlowProcessor extends 
AbstractOAuthProcessor {
         log.info("Register post login url: {}", postLoginUrl);
         session.putValue("OAuthPostLoginUrl", postLoginUrl);
 
+        // RFC 6749 section 10.12: bind the callback to a flow this session 
actually started, so an
+        // authorization code obtained elsewhere cannot be replayed into this 
session's callback.
+        var state = newState();
+        session.putValue(OAUTH_STATE, state);
+
         var redirectUri = getRequiredProperty(exchange.getContext(), 
CAMEL_OAUTH_REDIRECT_URI);
-        var params = new OAuthCodeFlowParams().setRedirectUri(redirectUri);
+        var params = new 
OAuthCodeFlowParams().setRedirectUri(redirectUri).setState(state);
         var authRequestUrl = oauth.buildCodeFlowAuthRequestUrl(params);
 
         sendRedirect(msg, authRequestUrl);
@@ -75,6 +86,12 @@ public class OAuthCodeFlowProcessor extends 
AbstractOAuthProcessor {
         exchange.setRouteStop(true);
     }
 
+    private static String newState() {
+        var bytes = new byte[32];
+        RANDOM.nextBytes(bytes);
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
     private String getPostLoginUrl(Message msg) {
         String postLoginUrl;
         var xProto = msg.getHeader("X-Forwarded-Proto", String.class);
diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSession.java 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSession.java
index 30a986f5e504..30a7b6ef3816 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSession.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSession.java
@@ -20,6 +20,12 @@ import java.util.Optional;
 
 public interface OAuthSession {
 
+    /**
+     * Session key holding the {@code state} value of an in-flight 
authorization code flow. The callback accepts a code
+     * only when the request carries the same value, which is what ties the 
callback to a flow this session started.
+     */
+    String OAUTH_STATE = "OAuthState";
+
     String getSessionId();
 
     <T> Optional<T> getValue(String key, Class<T> clazz);
diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/jakarta/ServletOAuth.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/jakarta/ServletOAuth.java
index 09446ae56c5b..044f0eee5bfb 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/jakarta/ServletOAuth.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/jakarta/ServletOAuth.java
@@ -106,6 +106,9 @@ public class ServletOAuth extends OAuth {
             uriBuilder.addParameter("response_type", 
params.getResponseType().toString().toLowerCase());
             uriBuilder.addParameter("client_id", params.getClientId());
             uriBuilder.addParameter("redirect_uri", params.getRedirectUri());
+            if (params.getState() != null) {
+                uriBuilder.addParameter("state", params.getState());
+            }
             var requestUrl = uriBuilder.build().toString();
             return requestUrl;
         } catch (URISyntaxException ex) {
diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/vertx/VertxOAuth.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/vertx/VertxOAuth.java
index 8d89b55cb02a..f27d8fe0b2b0 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/vertx/VertxOAuth.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/vertx/VertxOAuth.java
@@ -97,9 +97,13 @@ public class VertxOAuth extends OAuth {
         if (params.getScopes() == null) {
             params.setScope("openid");
         }
-        return oauth2.authorizeURL(new OAuth2AuthorizationURL()
+        var url = new OAuth2AuthorizationURL()
                 .setRedirectUri(params.getRedirectUri())
-                .setScopes(params.getScopes()));
+                .setScopes(params.getScopes());
+        if (params.getState() != null) {
+            url.setState(params.getState());
+        }
+        return oauth2.authorizeURL(url);
     }
 
     @Override
diff --git 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthProcessorFailClosedTest.java
 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthProcessorFailClosedTest.java
index 72a8d13ac5cf..d0e59a3d6bfa 100644
--- 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthProcessorFailClosedTest.java
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthProcessorFailClosedTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.oauth;
 
+import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.impl.DefaultCamelContext;
 import org.apache.camel.support.DefaultExchange;
@@ -67,4 +68,88 @@ class OAuthProcessorFailClosedTest {
             assertThat(exchange.isRouteStop()).isTrue();
         }
     }
+
+    /**
+     * The state check runs before the authorization code is redeemed, so 
these paths are reachable without an identity
+     * provider. Without the check, an authorization code obtained in any 
browser could be redeemed at this callback and
+     * bound to whichever session presented it.
+     */
+    @Test
+    void aCallbackWithNoFlowInProgressStopsTheRoute() throws Exception {
+        try (DefaultCamelContext context = new DefaultCamelContext()) {
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getMessage().setHeader("code", "an-authorization-code");
+            exchange.getMessage().setHeader("state", 
"a-state-we-never-issued");
+
+            bindTestOAuth(context);
+            new OAuthCodeFlowCallback().process(exchange);
+
+            
assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400);
+            assertThat(exchange.getMessage().getBody()).isEqualTo("No 
authorization code flow in progress");
+            assertThat(exchange.isRouteStop()).isTrue();
+        }
+    }
+
+    @Test
+    void aCallbackWithTheWrongStateStopsTheRoute() throws Exception {
+        try (DefaultCamelContext context = new DefaultCamelContext()) {
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getMessage().setHeader("code", "an-authorization-code");
+            exchange.getMessage().setHeader("state", "not-the-issued-state");
+
+            OAuth oauth = bindTestOAuth(context);
+            
oauth.getOrCreateSession(exchange).putValue(OAuthSession.OAUTH_STATE, 
"the-issued-state");
+            new OAuthCodeFlowCallback().process(exchange);
+
+            
assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400);
+            
assertThat(exchange.getMessage().getBody()).isEqualTo("Authorization state 
mismatch");
+            assertThat(exchange.isRouteStop()).isTrue();
+        }
+    }
+
+    @Test
+    void aCallbackWithNoStateAtAllStopsTheRoute() throws Exception {
+        try (DefaultCamelContext context = new DefaultCamelContext()) {
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getMessage().setHeader("code", "an-authorization-code");
+
+            OAuth oauth = bindTestOAuth(context);
+            
oauth.getOrCreateSession(exchange).putValue(OAuthSession.OAUTH_STATE, 
"the-issued-state");
+            new OAuthCodeFlowCallback().process(exchange);
+
+            
assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400);
+            
assertThat(exchange.getMessage().getBody()).isEqualTo("Authorization state 
mismatch");
+            assertThat(exchange.isRouteStop()).isTrue();
+        }
+    }
+
+    /**
+     * The state check runs before the authorization code is redeemed, so none 
of the abstract operations are reached -
+     * which is part of what these tests assert.
+     */
+    private static OAuth bindTestOAuth(DefaultCamelContext context) {
+        OAuth oauth = new OAuth() {
+            @Override
+            public void discoverOAuthConfig(CamelContext ctx) {
+                throw new UnsupportedOperationException();
+            }
+
+            @Override
+            public UserProfile authenticate(Credentials creds) {
+                throw new UnsupportedOperationException("the authorization 
code must not be redeemed");
+            }
+
+            @Override
+            public String buildLogoutRequestUrl(OAuthLogoutParams params) {
+                throw new UnsupportedOperationException();
+            }
+
+            @Override
+            public String buildCodeFlowAuthRequestUrl(OAuthCodeFlowParams 
params) {
+                throw new UnsupportedOperationException();
+            }
+        };
+        context.getRegistry().bind(OAuth.class.getName(), oauth);
+        return oauth;
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 2bb1c4713a1c..30e8dfb1d3ec 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -703,6 +703,29 @@ inherit values set on `defaultInstance`, and 
`defaultInstance` itself remains un
 Routes that compared unmarshalled bodies by identity, or that mutated one body 
expecting the change to
 be visible on another, must be updated.
 
+=== camel-oauth
+
+The authorization code flow now sends a `state` parameter and requires it back 
on the callback.
+
+`OAuthCodeFlowProcessor` generates a random `state`, stores it in the OAuth 
session and includes it in
+the authorization request. `OAuthCodeFlowCallback` then accepts an 
authorization code only when the
+callback carries the same value, consuming it so it cannot be replayed. 
Previously no `state` was sent and
+the callback redeemed whatever `code` arrived, binding the resulting profile 
to the caller's session with
+nothing tying the callback to a flow that session had started — the login CSRF 
that RFC 6749 section
+10.12 and OpenID Connect Core require this binding to prevent.
+
+Two callbacks that used to succeed are now answered with `400` and stop the 
route:
+
+* no authorization code flow is in progress for the session — `No 
authorization code flow in progress`
+* the `state` is absent or does not match — `Authorization state mismatch`
+
+Deployments where the session is not sticky across the redirect will see the 
second case, because the
+session holding the `state` has to be the one that returns. Sessions must 
survive the round trip to the
+identity provider.
+
+Note that `nonce` and PKCE (`code_challenge`) are still not sent, and the 
session cookie is still
+`SameSite=None; Secure`.
+
 === camel-platform-http-vertx
 
 The CORS handler used to send `Access-Control-Allow-Credentials: true` on 
every response to a request

Reply via email to