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