From 559565ded7d7f77661c9b8a63bb1d711fb80920f Mon Sep 17 00:00:00 2001 From: klouds27 Date: Tue, 26 May 2026 01:42:45 +0200 Subject: [PATCH] Add DelegatingSecurityContextThreadFactory Adds DelegatingSecurityContextThreadFactory to propagate the SecurityContext to threads created via ThreadFactory, following the same pattern as DelegatingSecurityContextExecutor. This enables SecurityContext propagation for APIs that accept a ThreadFactory directly, such as structured concurrency (JEP 533). Closes gh-19075 Signed-off-by: klouds27 --- ...elegatingSecurityContextThreadFactory.java | 81 +++++++++++++ ...tingSecurityContextThreadFactoryTests.java | 107 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 core/src/main/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactory.java create mode 100644 core/src/test/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactoryTests.java diff --git a/core/src/main/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactory.java b/core/src/main/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactory.java new file mode 100644 index 00000000000..9e9847ff841 --- /dev/null +++ b/core/src/main/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactory.java @@ -0,0 +1,81 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.concurrent; + +import java.util.concurrent.ThreadFactory; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.util.Assert; + +/** + * A {@link ThreadFactory} which wraps each {@link Runnable} in a + * {@link DelegatingSecurityContextRunnable}. + * + * @author klouds27 (Adolfo G) + * @since 6.5 + */ +public final class DelegatingSecurityContextThreadFactory extends AbstractDelegatingSecurityContextSupport + implements ThreadFactory { + + private final ThreadFactory delegate; + + /** + * Creates a new {@link DelegatingSecurityContextThreadFactory} that uses the + * specified {@link SecurityContext}. + * @param delegateThreadFactory the {@link ThreadFactory} to delegate to. Cannot be + * null. + * @param securityContext the {@link SecurityContext} to use for each + * {@link DelegatingSecurityContextRunnable} or null to default to the current + * {@link SecurityContext} + */ + public DelegatingSecurityContextThreadFactory(ThreadFactory delegateThreadFactory, + @Nullable SecurityContext securityContext) { + super(securityContext); + Assert.notNull(delegateThreadFactory, "delegateThreadFactory cannot be null"); + this.delegate = delegateThreadFactory; + } + + /** + * Creates a new {@link DelegatingSecurityContextThreadFactory} that uses the current + * {@link SecurityContext} from the {@link SecurityContextHolder} at the time each + * thread is created. + * @param delegate the {@link ThreadFactory} to delegate to. Cannot be null. + */ + public DelegatingSecurityContextThreadFactory(ThreadFactory delegate) { + this(delegate, null); + } + + @Override + public Thread newThread(Runnable r) { + return this.delegate.newThread(wrap(r)); + } + + /** + * Sets the {@link SecurityContextHolderStrategy} to use. The default action is to use + * the {@link SecurityContextHolderStrategy} stored in {@link SecurityContextHolder}. + * + * @since 6.5 + */ + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + super.setSecurityContextHolderStrategy(securityContextHolderStrategy); + } + +} diff --git a/core/src/test/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactoryTests.java b/core/src/test/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactoryTests.java new file mode 100644 index 00000000000..40113fb16da --- /dev/null +++ b/core/src/test/java/org/springframework/security/concurrent/DelegatingSecurityContextThreadFactoryTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.concurrent; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnJre; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.core.task.VirtualThreadTaskExecutor; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author klouds27 (Adolfo G) + */ +public class DelegatingSecurityContextThreadFactoryTests { + + @AfterEach + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void constructorWhenNullDelegateThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new DelegatingSecurityContextThreadFactory(null, null)); + } + + @Test + public void newThreadWhenPlatformThreadThenSecurityContextPropagated() throws Exception { + SecurityContext context = propagateAndCapture(Executors.defaultThreadFactory()); + assertThat(context.getAuthentication()).isNotNull(); + } + + @Test + @DisabledOnJre(JRE.JAVA_17) + public void newThreadWhenVirtualThreadThenSecurityContextPropagated() throws Exception { + SecurityContext context = propagateAndCapture(new VirtualThreadTaskExecutor().getVirtualThreadFactory()); + assertThat(context.getAuthentication()).isNotNull(); + } + + @Test + public void newThreadWhenExplicitSecurityContextThenUsesThatContext() throws Exception { + SecurityContext explicit = SecurityContextHolder.createEmptyContext(); + explicit.setAuthentication(new TestingAuthenticationToken("explicit", null)); + DelegatingSecurityContextThreadFactory factory = new DelegatingSecurityContextThreadFactory( + Executors.defaultThreadFactory(), explicit); + SecurityContext captured = runAndCapture(factory); + assertThat(captured.getAuthentication().getName()).isEqualTo("explicit"); + } + + @Test + public void newThreadWhenCurrentContextThenPropagatesCallerContext() throws Exception { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new TestingAuthenticationToken("caller", null)); + SecurityContextHolder.setContext(context); + DelegatingSecurityContextThreadFactory factory = new DelegatingSecurityContextThreadFactory( + Executors.defaultThreadFactory()); + SecurityContext captured = runAndCapture(factory); + assertThat(captured.getAuthentication().getName()).isEqualTo("caller"); + } + + private SecurityContext propagateAndCapture(ThreadFactory threadFactory) throws Exception { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new TestingAuthenticationToken("user", null)); + DelegatingSecurityContextThreadFactory factory = new DelegatingSecurityContextThreadFactory(threadFactory, + context); + return runAndCapture(factory); + } + + private SecurityContext runAndCapture(DelegatingSecurityContextThreadFactory factory) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + Thread thread = factory.newThread(() -> { + result.set(SecurityContextHolder.getContext()); + latch.countDown(); + }); + thread.start(); + latch.await(); + return result.get(); + } + +}