This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/28512-to-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 57d1bc4b091ba9a189f3cf75197d85b7950e353c Author: Guillaume Nodet <[email protected]> AuthorDate: Wed Oct 28 10:22:14 2026 +0000 [backport camel-4.18.x] CAMEL-26319: camel-oauth: InMemorySessionStore should not log session identifiers and OAuthCodeFlowCallback should rotate session identifiers on login --- .../org/apache/camel/oauth/InMemorySession.java | 10 ++ .../apache/camel/oauth/InMemorySessionStore.java | 42 ++++-- .../main/java/org/apache/camel/oauth/OAuth.java | 11 ++ .../apache/camel/oauth/OAuthCodeFlowCallback.java | 1 + .../org/apache/camel/oauth/OAuthSessionStore.java | 16 +++ .../oauth/AbstractOAuthRequestHeadersTest.java | 4 + .../oauth/OAuthCodeFlowSessionRotationTest.java | 150 +++++++++++++++++++++ 7 files changed, 226 insertions(+), 8 deletions(-) diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySession.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySession.java index 7f96495aa515..373b57db66c1 100644 --- a/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySession.java +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/InMemorySession.java @@ -34,6 +34,16 @@ public class InMemorySession implements OAuthSession { this.sessionId = sessionId; } + Map<String, Object> getValues() { + return new HashMap<>(values); + } + + void putAll(Map<String, Object> newValues) { + if (newValues != null) { + this.values.putAll(newValues); + } + } + @Override public String getSessionId() { return sessionId; 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 70c10047455c..7b6ee0e809e0 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 @@ -17,10 +17,10 @@ package org.apache.camel.oauth; import java.util.Arrays; -import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.apache.camel.Exchange; @@ -35,7 +35,7 @@ public class InMemorySessionStore implements OAuthSessionStore { protected final Logger log = LoggerFactory.getLogger(getClass()); - private final Map<String, OAuthSession> sessions = new HashMap<>(); + private final Map<String, OAuthSession> sessions = new ConcurrentHashMap<>(); @Override public Optional<OAuthSession> getSession(Exchange exchange) { @@ -56,12 +56,13 @@ public class InMemorySessionStore implements OAuthSessionStore { var maybeSession = Optional.ofNullable(sessions.get(sessionId)); if (maybeSession.isEmpty()) { - log.warn("No OAuthSession for: {}", sessionId); + log.warn("No OAuthSession found for cookie"); } return maybeSession; } + @Override public OAuthSession createSession(Exchange exchange) { var sessionId = UUID.randomUUID().toString(); @@ -69,7 +70,7 @@ public class InMemorySessionStore implements OAuthSessionStore { sessions.put(sessionId, session); var msg = exchange.getMessage(); - log.info("New OAuthSession: {}", sessionId); + log.debug("Created OAuthSession"); setSessionCookie(msg, session); msg.setHeader(OAuth.CAMEL_OAUTH_SESSION_ID, sessionId); @@ -77,6 +78,30 @@ public class InMemorySessionStore implements OAuthSessionStore { return session; } + @Override + public OAuthSession rotateSession(Exchange exchange, OAuthSession session) { + if (session == null) { + return createSession(exchange); + } + var oldSessionId = session.getSessionId(); + sessions.remove(oldSessionId); + + var newSessionId = UUID.randomUUID().toString(); + var newSession = new InMemorySession(newSessionId); + if (session instanceof InMemorySession inMemorySession) { + newSession.putAll(inMemorySession.getValues()); + } + sessions.put(newSessionId, newSession); + + var msg = exchange.getMessage(); + log.debug("Rotated OAuthSession"); + + setSessionCookie(msg, newSession); + msg.setHeader(OAuth.CAMEL_OAUTH_SESSION_ID, newSessionId); + + return newSession; + } + private Map<String, String> getCookies(Exchange exchange) { var msg = exchange.getMessage(); @@ -108,13 +133,14 @@ public class InMemorySessionStore implements OAuthSessionStore { } private void setSessionCookie(Message msg, OAuthSession session) { - var sessionId = session.getSessionId(); - var cookieId = "%s=%s".formatted(CAMEL_OAUTH_COOKIE, sessionId); - if (msg.getHeader("Set-Cookie") != null) { + var existing = msg.getHeader("Set-Cookie", String.class); + if (existing != null && !existing.startsWith(CAMEL_OAUTH_COOKIE + "=")) { throw new IllegalStateException("Duplicate 'Set-Cookie' header"); } + var sessionId = session.getSessionId(); + var cookieId = "%s=%s".formatted(CAMEL_OAUTH_COOKIE, sessionId); var cookie = cookieId + "; Path=/; HttpOnly; SameSite=None; Secure"; msg.setHeader("Set-Cookie", cookie); - log.debug("Set-Cookie: {}", cookie); + log.debug("Set {} cookie", CAMEL_OAUTH_COOKIE); } } 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 dfa894e2a1ba..e24674c105ff 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 @@ -98,4 +98,15 @@ public abstract class OAuth { public OAuthSession createSession(Exchange exchange) { return getSessionStore().createSession(exchange); } + + /** + * Rotates the session, delegating to {@link OAuthSessionStore#rotateSession(Exchange, OAuthSession)}. + * + * @param exchange the current exchange + * @param session the existing session to rotate + * @return the rotated session with a new session identifier + */ + public OAuthSession rotateSession(Exchange exchange, OAuthSession session) { + return getSessionStore().rotateSession(exchange, session); + } } 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 c80eda2a7763..813387395cfa 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 @@ -76,6 +76,7 @@ public class OAuthCodeFlowCallback extends AbstractOAuthProcessor { .setRedirectUri(redirectUri) .setCode(authCode)); + session = oauth.rotateSession(exchange, session); session.putUserProfile(userProfile); log.info("Authenticated {}", userProfile.subject()); userProfile.logDetails(); diff --git a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSessionStore.java b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSessionStore.java index 4220f2c4f88d..9da5928cf928 100644 --- a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSessionStore.java +++ b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthSessionStore.java @@ -25,4 +25,20 @@ public interface OAuthSessionStore { Optional<OAuthSession> getSession(Exchange exchange); OAuthSession createSession(Exchange exchange); + + /** + * Rotates the session, replacing the current session identifier with a newly generated one and transferring any + * session state. + * + * @implNote The default implementation is a no-op that returns the existing session unchanged. + * Implementors MUST override this method to prevent session fixation — failing to do so leaves + * the session identifier unchanged across privilege escalation (login). + * + * @param exchange the current exchange + * @param session the existing session to rotate + * @return the rotated session with a new session identifier + */ + default OAuthSession rotateSession(Exchange exchange, OAuthSession session) { + return session; + } } 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 index 2fa2eeb6643f..8b34ea4451b2 100644 --- 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 @@ -213,6 +213,10 @@ abstract class AbstractOAuthRequestHeadersTest { var callback = get("/auth?code=" + StubOAuth.BOB_CODE + "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8), cookie, null); assertEquals(302, callback.statusCode()); + var postLoginCookie = setCookie(callback); + if (postLoginCookie != null) { + cookie = postLoginCookie.substring(0, postLoginCookie.indexOf(';')); + } return cookie; } diff --git a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowSessionRotationTest.java b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowSessionRotationTest.java new file mode 100644 index 000000000000..e2c099fff54c --- /dev/null +++ b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowSessionRotationTest.java @@ -0,0 +1,150 @@ +/* + * 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.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_COOKIE; +import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_REDIRECT_URI; +import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_SESSION_ID; +import static org.apache.camel.oauth.OAuthSession.OAUTH_STATE; +import static org.assertj.core.api.Assertions.assertThat; + +class OAuthCodeFlowSessionRotationTest { + + private static final String REDIRECT_URI = "https://app.example.test/auth"; + private static final String POST_LOGIN_URL = "https://app.example.test/dashboard"; + + private DefaultCamelContext context; + private StubOAuth oauth; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + context.getPropertiesComponent().addInitialProperty(CAMEL_OAUTH_REDIRECT_URI, REDIRECT_URI); + oauth = new StubOAuth(); + context.getRegistry().bind(OAuth.class.getName(), oauth); + } + + @AfterEach + void tearDown() throws Exception { + context.close(); + } + + @Test + void inMemorySessionStoreRotateSessionReplacesIdAndTransfersValues() { + Exchange exchange = new DefaultExchange(context); + OAuthSession initialSession = oauth.createSession(exchange); + String oldSessionId = initialSession.getSessionId(); + + initialSession.putValue("customKey", "customValue"); + initialSession.putValue("anotherKey", 12345); + + OAuthSession rotatedSession = oauth.rotateSession(exchange, initialSession); + String newSessionId = rotatedSession.getSessionId(); + + assertThat(newSessionId).isNotEqualTo(oldSessionId); + assertThat(rotatedSession.getValue("customKey", String.class)).contains("customValue"); + assertThat(rotatedSession.getValue("anotherKey", Integer.class)).contains(12345); + + // Verify the old session is no longer accessible in the store + Exchange oldLookupExchange = new DefaultExchange(context); + oldLookupExchange.getMessage().setHeader(CAMEL_OAUTH_SESSION_ID, oldSessionId); + assertThat(oauth.getSession(oldLookupExchange)).isEmpty(); + + // Verify the new session is retrievable + Exchange newLookupExchange = new DefaultExchange(context); + newLookupExchange.getMessage().setHeader(CAMEL_OAUTH_SESSION_ID, newSessionId); + assertThat(oauth.getSession(newLookupExchange)).containsSame(rotatedSession); + + // Verify the exchange response cookie and header were updated + String setCookieHeader = exchange.getMessage().getHeader("Set-Cookie", String.class); + assertThat(setCookieHeader) + .isNotNull() + .contains(CAMEL_OAUTH_COOKIE + "=" + newSessionId) + .doesNotContain(oldSessionId); + assertThat(exchange.getMessage().getHeader(CAMEL_OAUTH_SESSION_ID)).isEqualTo(newSessionId); + } + + @Test + void callbackRotatesSessionUponSuccessfulAuthentication() throws Exception { + // Step 1: Pre-login initial request creates a pre-login session + Exchange preLoginExchange = new DefaultExchange(context); + OAuthSession preLoginSession = oauth.createSession(preLoginExchange); + String preLoginSessionId = preLoginSession.getSessionId(); + + String issuedState = "secure-random-state-1234"; + preLoginSession.putValue(OAUTH_STATE, issuedState); + preLoginSession.putValue("OAuthPostLoginUrl", POST_LOGIN_URL); + + // Step 2: Callback completes with valid authorization code and state + Exchange callbackExchange = new DefaultExchange(context); + callbackExchange.getMessage().setHeader("Cookie", CAMEL_OAUTH_COOKIE + "=" + preLoginSessionId); + callbackExchange.getMessage().setHeader("code", StubOAuth.BOB_CODE); + callbackExchange.getMessage().setHeader("state", issuedState); + + new OAuthCodeFlowCallback().process(callbackExchange); + + // Verify redirect to post-login URL + assertThat(callbackExchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(302); + assertThat(callbackExchange.getMessage().getHeader("Location")).isEqualTo(POST_LOGIN_URL); + + // Verify new session cookie is emitted with rotated session ID + String setCookieHeader = callbackExchange.getMessage().getHeader("Set-Cookie", String.class); + assertThat(setCookieHeader).isNotNull(); + assertThat(setCookieHeader).doesNotContain(preLoginSessionId); + + String prefix = CAMEL_OAUTH_COOKIE + "="; + int start = setCookieHeader.indexOf(prefix) + prefix.length(); + int end = setCookieHeader.indexOf(';', start); + String postLoginSessionId = end > 0 ? setCookieHeader.substring(start, end) : setCookieHeader.substring(start); + + assertThat(postLoginSessionId).isNotEmpty(); + assertThat(postLoginSessionId).isNotEqualTo(preLoginSessionId); + + // Step 3: Verify the pre-login session ID cannot be reused to access protected routes + Exchange replayExchange = new DefaultExchange(context); + replayExchange.getMessage().setHeader("Cookie", CAMEL_OAUTH_COOKIE + "=" + preLoginSessionId); + new OAuthCodeFlowProcessor().process(replayExchange); + + // The pre-login ID should not be authenticated, so OAuthCodeFlowProcessor redirects to IdP auth request + assertThat(replayExchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(302); + assertThat(replayExchange.getMessage().getHeader("Location", String.class)).contains("https://idp.example/auth?state="); + assertThat(replayExchange.isRouteStop()).isTrue(); + + // Step 4: Verify the rotated post-login session ID successfully authenticates the user + Exchange authExchange = new DefaultExchange(context); + authExchange.getMessage().setHeader("Cookie", CAMEL_OAUTH_COOKIE + "=" + postLoginSessionId); + new OAuthCodeFlowProcessor().process(authExchange); + + // The authenticated request should NOT be redirected and route should not be stopped + assertThat(authExchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull(); + assertThat(authExchange.isRouteStop()).isFalse(); + + // Stored session has bob's profile + Exchange verifyExchange = new DefaultExchange(context); + verifyExchange.getMessage().setHeader(CAMEL_OAUTH_SESSION_ID, postLoginSessionId); + OAuthSession storedSession = oauth.getSession(verifyExchange).orElseThrow(); + assertThat(storedSession.getUserProfile()).isPresent(); + assertThat(storedSession.getUserProfile().get().subject()).isEqualTo("bob"); + } +}
