1
0

Split into per-article modules and add the method-security module

Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
This commit is contained in:
2026-08-25 02:01:29 +00:00
parent 9f950bffa9
commit 5e9e7f1b12
65 changed files with 4088 additions and 119 deletions

View File

@@ -0,0 +1,44 @@
package com.ankurm.vt;
// Explained in docs/01-inheritable-threadlocal.md -- run via scripts/run-all.sh, output captured in docs/output/
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/** Confirms, with no Spring involved, how InheritableThreadLocal behaves for
* (a) a fresh platform Thread, (b) a reused thread from a fixed pool, and
* (c) a fresh virtual thread. */
public class Demo1PlainThreadLocal {
static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>();
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 1: InheritableThreadLocal across thread models ===");
// (a) fresh platform Thread inherits at construction time
CTX.set("request-A");
Thread t = new Thread(() -> System.out.println("fresh platform thread sees: " + CTX.get()));
t.start();
t.join();
// (b) reused thread from a fixed pool: the SECOND task on the same worker
// still carries whatever was set when the pool thread was originally created
ExecutorService pool = Executors.newFixedThreadPool(1);
CTX.set("request-B");
pool.submit(() -> System.out.println("pool thread, task 1, sees: " + CTX.get())).get();
CTX.set("request-C"); // caller's context changed
pool.submit(() -> System.out.println("pool thread, task 2 (reused), sees: " + CTX.get()
+ " <-- stale, not request-C")).get();
pool.shutdown();
// (c) fresh virtual thread, never reused
CTX.set("request-D");
Thread vt = Thread.ofVirtual().start(() ->
System.out.println("fresh virtual thread sees: " + CTX.get()));
vt.join();
CTX.remove();
}
}

View File

@@ -0,0 +1,93 @@
package com.ankurm.vt;
// Explained in docs/02-async-virtual-threads.md -- run via scripts/run-all.sh, output captured in docs/output/
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
/** Reproduces the exact bean shape Boot 4.1 creates when
* spring.threads.virtual.enabled=true: a SimpleAsyncTaskExecutor backed by
* Thread.ofVirtual(). Shows what SecurityContextHolder.MODE_THREADLOCAL (the
* Spring Security default) does and does not propagate into it, and what
* three different fixes change. */
public class Demo2AsyncVirtualThreads {
static SimpleAsyncTaskExecutor bootStyleVirtualThreadExecutor() {
SimpleAsyncTaskExecutor exec = new SimpleAsyncTaskExecutor("vt-");
exec.setVirtualThreads(true); // what spring.threads.virtual.enabled=true wires up
return exec;
}
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
static void run(String label, Executor executor) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(label + ": " + (a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName())
+ " [" + Thread.currentThread() + "]");
latch.countDown();
});
latch.await(5, TimeUnit.SECONDS);
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 2: @Async-style virtual thread executor + SecurityContext ===");
// --- Scenario A: default MODE_THREADLOCAL, unwrapped virtual-thread executor ---
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("alice"));
SecurityContextHolder.setContext(ctx);
run("A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
SecurityContextHolder.clearContext();
// --- Scenario B: MODE_INHERITABLETHREADLOCAL, same raw executor ---
// The historical warning against this mode is about REUSED pool threads.
// SimpleAsyncTaskExecutor with virtual threads never reuses a thread, so
// the usual danger doesn't apply here -- verifying that directly.
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("bob"));
SecurityContextHolder.setContext(ctx);
run("B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
SecurityContextHolder.clearContext();
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset default
// --- Scenario C: DelegatingSecurityContextExecutor wrapping the virtual-thread executor ---
ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("carol"));
SecurityContextHolder.setContext(ctx);
Executor wrapped = new DelegatingSecurityContextExecutor(bootStyleVirtualThreadExecutor());
run("C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual)", wrapped);
SecurityContextHolder.clearContext();
// --- Scenario D: ContextPropagatingTaskDecorator + SecurityContextHolderThreadLocalAccessor ---
// Confirms the accessor is really registered with Micrometer's ContextRegistry
// (it self-registers via ServiceLoader when context-propagation is on the classpath).
System.out.println("SecurityContextHolderThreadLocalAccessor present: "
+ (new SecurityContextHolderThreadLocalAccessor() != null));
SimpleAsyncTaskExecutor decorated = bootStyleVirtualThreadExecutor();
decorated.setTaskDecorator(new ContextPropagatingTaskDecorator());
ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("dave"));
SecurityContextHolder.setContext(ctx);
run("D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper", decorated);
SecurityContextHolder.clearContext();
}
}

View File

@@ -0,0 +1,92 @@
package com.ankurm.vt;
// Explained in docs/03-structured-concurrency.md -- run via scripts/run-all.sh, output captured in docs/output/
import io.micrometer.context.ContextSnapshot;
import io.micrometer.context.ContextSnapshotFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.util.concurrent.Callable;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
/** Does a StructuredTaskScope subtask (a fresh virtual thread) see the parent's
* SecurityContext? Four scenarios, same question each time. Requires
* --enable-preview on JDK 25 (StructuredTaskScope is JEP 505, fifth preview). */
public class Demo3StructuredConcurrency {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
static void setAuth(String name) {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth(name));
SecurityContextHolder.setContext(ctx);
}
static String readAuthInSubtask() {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
return a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===");
// A) default MODE_THREADLOCAL, plain fork
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
setAuth("erin");
try (var scope = StructuredTaskScope.open()) {
Subtask<String> s = scope.fork(() -> "A) plain fork, MODE_THREADLOCAL: " + readAuthInSubtask());
scope.join();
System.out.println(s.get());
}
SecurityContextHolder.clearContext();
// B) MODE_INHERITABLETHREADLOCAL, plain fork
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
setAuth("frank");
try (var scope = StructuredTaskScope.open()) {
Subtask<String> s = scope.fork(() -> "B) plain fork, MODE_INHERITABLETHREADLOCAL: " + readAuthInSubtask());
scope.join();
System.out.println(s.get());
}
SecurityContextHolder.clearContext();
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset
// C) manual capture-and-restore around the forked Callable (portable, no reliance on mode)
setAuth("grace");
SecurityContext captured = SecurityContextHolder.getContext();
try (var scope = StructuredTaskScope.open()) {
Callable<String> task = () -> {
SecurityContextHolder.setContext(captured);
try {
return "C) manual capture/restore: " + readAuthInSubtask();
} finally {
SecurityContextHolder.clearContext();
}
};
Subtask<String> s = scope.fork(task);
scope.join();
System.out.println(s.get());
}
SecurityContextHolder.clearContext();
// D) Micrometer ContextSnapshot wrap (uses SecurityContextHolderThreadLocalAccessor)
setAuth("heidi");
ContextSnapshot snapshot = ContextSnapshotFactory.builder().build().captureAll();
try (var scope = StructuredTaskScope.open()) {
Callable<String> task = snapshot.wrap(
(Callable<String>) () -> "D) ContextSnapshot.wrap: " + readAuthInSubtask());
Subtask<String> s = scope.fork(task);
scope.join();
System.out.println(s.get());
}
SecurityContextHolder.clearContext();
}
}

View File

@@ -0,0 +1,167 @@
package com.ankurm.vt;
// Explained in docs/04-executor-wrapping.md -- run via scripts/run-all.sh, output captured in docs/output/
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* The pre-virtual-thread baseline this guide's "Using @Async" / "Using ExecutorService" /
* "Using CompletableFuture" sections describe: a fixed {@code ThreadPoolExecutor} whose
* workers are constructed once and reused for every task after that -- exactly the
* pooled-thread shape {@link Demo1PlainThreadLocal} showed going stale under
* {@code MODE_INHERITABLETHREADLOCAL}.
*
* <p>The point of this demo is the contrast Chapter 1 sets up but doesn't resolve: the
* {@code Delegating*} wrapper classes solve the exact staleness problem Chapter 1 found,
* but by a completely different mechanism. They capture the {@code SecurityContext} once,
* at wrapper-construction time (not at pool-worker-construction time, and not by thread
* inheritance at all), and push/pop it around each task's {@code run()}/{@code call()} on
* whichever thread actually executes it. A reused pool worker is irrelevant to them.
*
* <p>Four scenarios: raw pool (loses context, and later tasks race whichever context is
* active at submission time -- see the caveat printed for scenario A), then the three
* {@code Delegating*} classes named in the post's async section.
*/
public class Demo4ExecutorWrapping {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
static void setAuth(String name) {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth(name));
SecurityContextHolder.setContext(ctx);
}
static String describe(Authentication a) {
return a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 4: Executor/ExecutorService/AsyncTaskExecutor wrapping on a pooled platform thread ===");
// --- Scenario A: raw fixed ThreadPoolExecutor, no wrapper -- context lost per task ---
ThreadPoolExecutor rawPool = new ThreadPoolExecutor(
2, 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
setAuth("alice");
run("A) raw ThreadPoolExecutor, no wrapper", rawPool);
SecurityContextHolder.clearContext();
// --- Scenario B: DelegatingSecurityContextExecutorService wraps the whole ExecutorService ---
// Matches the post's TaskExecutionService pattern (a class literally named
// "ExecutorService" with a same-named-as-field constructor never compiled in the
// original draft; fixed here and named for what it does).
ExecutorService wrappedService = new DelegatingSecurityContextExecutorService(rawPool);
setAuth("bob");
run("B) DelegatingSecurityContextExecutorService.execute(...)", wrappedService);
// submit() goes through the same wrapper -- context still travels
CountDownLatch bLatch = new CountDownLatch(1);
wrappedService.submit(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println("B2) DelegatingSecurityContextExecutorService.submit(Callable): " + describe(a));
bLatch.countDown();
return null;
});
bLatch.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
// --- Edge case: two tasks submitted with DIFFERENT contexts to the SAME reused pool
// worker each keep their OWN context -- unlike Demo1's InheritableThreadLocal case,
// where the second task on a reused worker saw the FIRST task's stale value. The
// wrapper captures context per submission, not per thread.
setAuth("carol-task1");
CountDownLatch edgeLatch1 = new CountDownLatch(1);
wrappedService.execute(() -> {
System.out.println("EDGE) task 1 on possibly-reused worker: "
+ describe(SecurityContextHolder.getContext().getAuthentication()));
edgeLatch1.countDown();
});
edgeLatch1.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
setAuth("dave-task2");
CountDownLatch edgeLatch2 = new CountDownLatch(1);
wrappedService.execute(() -> {
System.out.println("EDGE) task 2, same pool, different caller context: "
+ describe(SecurityContextHolder.getContext().getAuthentication())
+ " <-- correct, NOT stale, unlike plain InheritableThreadLocal on a reused worker");
edgeLatch2.countDown();
});
edgeLatch2.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
// --- Scenario C: DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync ---
// Matches the post's CompletableFutureService. The common ForkJoinPool (the default
// CompletableFuture executor) never propagates context; supplying a wrapped custom
// executor fixes it.
setAuth("erin");
Executor delegatingExecutor = new DelegatingSecurityContextExecutor(rawPool);
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
return "C) DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync: " + describe(a);
}, delegatingExecutor);
System.out.println(cf.get(5, TimeUnit.SECONDS));
SecurityContextHolder.clearContext();
// Contrast: default CompletableFuture executor (common ForkJoinPool) -- unwrapped
setAuth("frank");
CompletableFuture<String> cfDefault = CompletableFuture.supplyAsync(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
return "C2) default CompletableFuture executor (common ForkJoinPool), no wrapper: " + describe(a);
});
System.out.println(cfDefault.get(5, TimeUnit.SECONDS));
SecurityContextHolder.clearContext();
// --- Scenario D: DelegatingSecurityContextAsyncTaskExecutor wraps a Spring
// ThreadPoolTaskExecutor -- the actual type AsyncConfigurer#getAsyncExecutor() returns,
// one level above the raw java.util.concurrent classes above. This is the object
// Spring's @Async infrastructure itself calls execute()/submit() on. ---
ThreadPoolTaskExecutor springExecutor = new ThreadPoolTaskExecutor();
springExecutor.setCorePoolSize(2);
springExecutor.setMaxPoolSize(2);
springExecutor.setThreadNamePrefix("Async-");
springExecutor.initialize();
DelegatingSecurityContextAsyncTaskExecutor asyncExecutor =
new DelegatingSecurityContextAsyncTaskExecutor(springExecutor);
setAuth("grace");
CountDownLatch dLatch = new CountDownLatch(1);
asyncExecutor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println("D) DelegatingSecurityContextAsyncTaskExecutor wrapping ThreadPoolTaskExecutor: " + describe(a));
dLatch.countDown();
});
dLatch.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
rawPool.shutdown();
springExecutor.shutdown();
}
static void run(String label, Executor executor) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(label + ": " + describe(a));
latch.countDown();
});
latch.await(5, TimeUnit.SECONDS);
}
}

View File

@@ -0,0 +1,102 @@
package com.ankurm.vt;
// Explained in docs/05-reactive-context.md -- run via scripts/run-all.sh, output captured in docs/output/
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
/**
* The post's {@code ReactiveController.getProfile()} example, without a running WebFlux
* server -- {@code ReactiveSecurityContextHolder} reads from Project Reactor's subscriber
* {@code Context}, which is not thread-bound, so it can be exercised with a plain
* {@code Mono} chain and no {@code @RestController} at all.
*
* <p>Four scenarios. A and B are the "why {@code ThreadLocal} doesn't work here" argument
* made concrete: the context is written with
* {@code SecurityContextHolder.setContext(...)} on the subscribing thread, then the chain
* is forced onto a <em>different</em> thread with {@code publishOn}, exactly as a real
* WebFlux event loop would. C and D are the fix, {@code ReactiveSecurityContextHolder}
* plus {@code contextWrite}, which the JEP note in the post's summary table calls out as
* "Reactor Context" rather than "Scheduler wrapping".
*/
public class Demo5ReactiveContext {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
/** The post's getProfile(), verbatim in spirit: reads ReactiveSecurityContextHolder,
* maps to a greeting, defaults to "Anonymous" if nothing was ever written. */
static Mono<String> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> "Hello, " + securityContext.getAuthentication().getName())
.defaultIfEmpty("Anonymous");
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 5: ReactiveSecurityContextHolder vs. ThreadLocal across a scheduler hop ===");
// --- Scenario A: plain ThreadLocal SecurityContextHolder, chain stays on caller thread ---
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("alice"));
SecurityContextHolder.setContext(ctx);
String a = Mono.fromSupplier(() -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + auth.getName();
})
.block(Duration.ofSeconds(5));
System.out.println("A) SecurityContextHolder (ThreadLocal), no scheduler hop: " + a);
SecurityContextHolder.clearContext();
// --- Scenario B: same ThreadLocal approach, but publishOn moves execution to a
// different thread before the read happens -- exactly what a real WebFlux event
// loop does between operators. The ThreadLocal set on the calling thread does not
// follow. ---
SecurityContextHolder.setContext(ctx);
String b = Mono.fromSupplier(() -> "irrelevant")
.publishOn(Schedulers.boundedElastic())
.map(ignored -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + auth.getName();
})
.block(Duration.ofSeconds(5));
System.out.println("B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: " + b
+ " [proves ThreadLocal doesn't survive a scheduler hop]");
SecurityContextHolder.clearContext();
// --- Scenario C: ReactiveSecurityContextHolder + contextWrite, no scheduler hop ---
String c = getProfile()
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("carol")))
.block(Duration.ofSeconds(5));
System.out.println("C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: " + c);
// --- Scenario D: same, but with a publishOn scheduler hop between the write and the
// read -- the Reactor Context travels with the subscription, not the thread, so this
// still resolves correctly where scenario B failed. ---
String d = Mono.just("ignored")
.publishOn(Schedulers.boundedElastic())
.then(Mono.defer(Demo5ReactiveContext::getProfile))
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("dave")))
.block(Duration.ofSeconds(5));
System.out.println("D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: " + d
+ " [Context travels with the stream, not the thread]");
// --- Edge case: no context was ever written -- defaultIfEmpty("Anonymous") fires,
// not a null-pointer, because ReactiveSecurityContextHolder.getContext() completes
// empty rather than emitting null when nothing was written upstream. ---
String anon = getProfile().block(Duration.ofSeconds(5));
System.out.println("E) getProfile() with no contextWrite() upstream at all: " + anon
+ " [defaultIfEmpty fires; getContext() completes empty, it does not error]");
Schedulers.shutdownNow();
}
}

View File

@@ -0,0 +1,123 @@
package com.ankurm.vt;
// Explained in docs/06-scheduled-tasks.md -- run via scripts/run-all.sh, output captured in docs/output/
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.scheduling.DelegatingSecurityContextTaskScheduler;
import java.time.Instant;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* There is no HTTP request behind a {@code @Scheduled} method, so unlike every other demo
* in this repository this one is not really asking "does the context survive a thread
* hand-off" -- it is asking "whose context, if anyone's, gets used at all".
*
* <p>Confirmed here by reading the actual bytecode of
* {@code DelegatingSecurityContextTaskScheduler} before writing this demo (see the repo's
* commit notes / project memory): the single-argument constructor stores a {@code null}
* captured context, and {@code DelegatingSecurityContextRunnable} resolves a {@code null}
* context lazily, inside {@code wrap()}, which runs synchronously on whatever thread calls
* {@code schedule(...)}. That means the capture happens <b>per call to
* {@code schedule()}</b>, not once when the wrapper is constructed -- scenario B below
* proves that two {@code schedule()} calls on the same wrapper, made from a thread whose
* context changed in between, capture two different contexts.
*/
public class Demo6ScheduledSystemIdentity {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
static SecurityContext systemContext() {
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM"));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(systemAuth);
return context;
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 6: DelegatingSecurityContextTaskScheduler and the synthetic system identity ===");
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("Scheduled-");
scheduler.initialize();
DelegatingSecurityContextTaskScheduler wrapped = new DelegatingSecurityContextTaskScheduler(scheduler);
// --- Scenario A: no SecurityContext on the calling thread at all when schedule() runs
// -- the realistic case, since app startup usually has no authenticated user. ---
SecurityContextHolder.clearContext();
String a = runOnceAndCapture(wrapped, "A");
System.out.println("A) schedule() called with NO context present on the caller thread: " + a
+ " [this is the realistic startup case the post warns about]");
// --- Scenario B: prove capture is per schedule()-call, not per-wrapper-construction.
// Two schedule() calls on the SAME wrapper instance, with the calling thread's
// context changed in between, must NOT see each other's value. ---
SecurityContext ctxX = SecurityContextHolder.createEmptyContext();
ctxX.setAuthentication(auth("registration-thread-X"));
SecurityContextHolder.setContext(ctxX);
String b1 = runOnceAndCapture(wrapped, "B1");
SecurityContext ctxY = SecurityContextHolder.createEmptyContext();
ctxY.setAuthentication(auth("registration-thread-Y"));
SecurityContextHolder.setContext(ctxY);
String b2 = runOnceAndCapture(wrapped, "B2");
SecurityContextHolder.clearContext();
System.out.println("B1) first schedule() call, caller context = registration-thread-X: " + b1);
System.out.println("B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: "
+ b2 + " [independent per-call capture, not frozen at wrapper construction]");
// --- Scenario C: the recommended pattern -- ignore whatever DelegatingSecurityContextTaskScheduler
// captured, and mint a narrow synthetic system identity inside the @Scheduled method body itself. ---
CountDownLatch cLatch = new CountDownLatch(1);
String[] cResult = new String[1];
wrapped.schedule(() -> {
SecurityContextHolder.setContext(systemContext());
try {
Authentication current = SecurityContextHolder.getContext().getAuthentication();
cResult[0] = current.getName() + " with authorities " + current.getAuthorities();
} finally {
SecurityContextHolder.clearContext();
}
cLatch.countDown();
}, Instant.now().plusMillis(50));
cLatch.await(5, TimeUnit.SECONDS);
System.out.println("C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: "
+ cResult[0]);
// --- Edge case: AuthenticationTrustResolver treats the synthetic SYSTEM principal as
// a real (non-anonymous) authentication, same as any other UsernamePasswordAuthenticationToken
// -- there is no built-in "system" concept in Spring Security, it's just a narrowly
// scoped Authentication like any other, which is exactly why keeping its authority
// list minimal matters. ---
boolean anonymous = new AuthenticationTrustResolverImpl().isAnonymous(systemContext().getAuthentication());
System.out.println("EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): " + anonymous
+ " [false -- SYSTEM is a normal authenticated principal, not Spring Security's anonymous concept]");
scheduler.shutdown();
}
private static String runOnceAndCapture(DelegatingSecurityContextTaskScheduler wrapped, String label) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
String[] result = new String[1];
wrapped.schedule(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
result[0] = a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
latch.countDown();
}, Instant.now().plusMillis(50));
latch.await(5, TimeUnit.SECONDS);
return result[0];
}
}

View File

@@ -0,0 +1,161 @@
package com.ankurm.vt;
// Explained in docs/07-servlet-filter-persistence.md -- run via scripts/run-all.sh, output captured in docs/output/
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpSession;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
import org.springframework.security.web.context.SecurityContextRepository;
import java.io.IOException;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
/**
* Direct verification of the post's claim in "Security Context Propagation in Servlet
* Environment": {@code SecurityContextPersistenceFilter} loads the context AND saves it
* back automatically at the end of the request; {@code SecurityContextHolderFilter} (the
* Security-6+ default) only loads -- it never calls
* {@code SecurityContextRepository.saveContext(...)} for you. Run against real filter
* instances and a real {@code HttpSession}, not asserted from memory of the docs.
*/
public class Demo7ServletFilterPersistence {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
/** Was a SecurityContext with this principal name actually written to the session? */
static boolean sessionHasContextFor(HttpSession session, String name) {
Object stored = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
if (!(stored instanceof SecurityContext sc)) return false;
Authentication a = sc.getAuthentication();
return a != null && name.equals(a.getName());
}
public static void main(String[] args) throws Exception {
System.out.println("=== Demo 7: SecurityContextHolderFilter vs SecurityContextPersistenceFilter -- load vs. load+save ===");
// --- Scenario A: SecurityContextPersistenceFilter -- the deprecated, pre-6.0 default.
// The controller sets an Authentication mid-chain; the filter is expected to persist
// it to the session automatically once the chain returns. ---
{
SecurityContextRepository repoA = new HttpSessionSecurityContextRepository();
SecurityContextPersistenceFilter filterA = new SecurityContextPersistenceFilter(repoA);
MockHttpServletRequest reqA = new MockHttpServletRequest();
MockHttpServletResponse respA = new MockHttpServletResponse();
FilterChain chainA = (req, resp) -> {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("alice"));
SecurityContextHolder.setContext(ctx);
};
filterA.doFilter(reqA, respA, chainA);
SecurityContextHolder.clearContext();
boolean saved = sessionHasContextFor(reqA.getSession(false), "alice");
System.out.println("A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: "
+ saved + " [true -- this filter saves for you]");
}
// --- Scenario B: SecurityContextHolderFilter -- the Security 6+ default. Same steps.
// No save call anywhere in its doFilter -- confirmed by reading its bytecode before
// writing this demo (the class has no reference to SecurityContextRepository.saveContext
// at all, only loadDeferredContext). ---
{
SecurityContextRepository repoB = new HttpSessionSecurityContextRepository();
SecurityContextHolderFilter filterB = new SecurityContextHolderFilter(repoB);
MockHttpServletRequest reqB = new MockHttpServletRequest();
MockHttpServletResponse respB = new MockHttpServletResponse();
FilterChain chainB = (req, resp) -> {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("bob"));
SecurityContextHolder.setContext(ctx);
};
filterB.doFilter(reqB, respB, chainB);
SecurityContextHolder.clearContext();
HttpSession sessionB = reqB.getSession(false);
boolean saved = sessionB != null && sessionHasContextFor(sessionB, "bob");
System.out.println("B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: "
+ saved + " [false -- requireExplicitSave's default; nothing persists unless you save it yourself]");
}
// --- Scenario C: SecurityContextHolderFilter, but the application code inside the
// chain calls SecurityContextRepository.saveContext(...) itself -- the fix the post
// describes for custom pre-authentication filters that set the context directly. ---
{
SecurityContextRepository repoC = new HttpSessionSecurityContextRepository();
SecurityContextHolderFilter filterC = new SecurityContextHolderFilter(repoC);
MockHttpServletRequest reqC = new MockHttpServletRequest();
MockHttpServletResponse respC = new MockHttpServletResponse();
FilterChain chainC = (req, resp) -> {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("carol"));
SecurityContextHolder.setContext(ctx);
repoC.saveContext(ctx, (jakarta.servlet.http.HttpServletRequest) req,
(jakarta.servlet.http.HttpServletResponse) resp);
};
filterC.doFilter(reqC, respC, chainC);
SecurityContextHolder.clearContext();
boolean saved = sessionHasContextFor(reqC.getSession(false), "carol");
System.out.println("C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: "
+ saved + " [true -- the workaround the post recommends actually works]");
}
// --- Scenario D (load side): a session already carries a saved context from an
// earlier "request" -- does SecurityContextHolderFilter load it back for the next one? ---
{
SecurityContextRepository repoD = new HttpSessionSecurityContextRepository();
MockHttpServletRequest seedReq = new MockHttpServletRequest();
MockHttpServletResponse seedResp = new MockHttpServletResponse();
SecurityContext seeded = SecurityContextHolder.createEmptyContext();
seeded.setAuthentication(auth("dave"));
repoD.saveContext(seeded, seedReq, seedResp);
HttpSession existingSession = seedReq.getSession();
SecurityContextHolderFilter filterD = new SecurityContextHolderFilter(repoD);
MockHttpServletRequest reqD = new MockHttpServletRequest();
reqD.setSession((org.springframework.mock.web.MockHttpSession) existingSession);
MockHttpServletResponse respD = new MockHttpServletResponse();
String[] seenInsideChain = new String[1];
FilterChain chainD = (req, resp) -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
seenInsideChain[0] = a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
};
filterD.doFilter(reqD, respD, chainD);
SecurityContextHolder.clearContext();
System.out.println("D) SecurityContextHolderFilter, context already saved in an existing session, next request: "
+ seenInsideChain[0] + " [it does load -- \"only loads, never saves\" describes the SAVE side, not the LOAD side]");
}
// --- Edge case: NEITHER filter has any effect if the request never populates a
// session at all AND nothing was ever saved -- SecurityContextHolder simply reflects
// an empty context, same as scenario A/B's baseline. Worth stating explicitly because
// it's easy to assume "no context" means the filter is broken rather than that nothing
// was ever authenticated on this request. ---
{
SecurityContextRepository repoE = new HttpSessionSecurityContextRepository();
SecurityContextHolderFilter filterE = new SecurityContextHolderFilter(repoE);
MockHttpServletRequest reqE = new MockHttpServletRequest();
MockHttpServletResponse respE = new MockHttpServletResponse();
String[] seen = new String[1];
FilterChain chainE = (req, resp) -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
seen[0] = a == null ? "NO AUTHENTICATION (empty context, not an error)" : "authenticated as " + a.getName();
};
filterE.doFilter(reqE, respE, chainE);
SecurityContextHolder.clearContext();
System.out.println("EDGE) brand-new request, no prior session, nothing set: " + seen[0]);
}
}
}

View File

@@ -0,0 +1,292 @@
package com.ankurm.vt;
// Explained in docs/08-testing-contract.md -- run via `mvn test` (uses --enable-preview
// via the surefire argLine in pom.xml). Pins the CONTRACT each demo asserts with a println,
// as real JUnit assertions: which scenario keeps the SecurityContext, which loses it, and
// which status code / boolean the guide's claims translate to. This is the repo's answer to
// the post's "Testing Security Context Propagation" section -- including a real
// TestSecurityContextHolder-based test, scenario 12 below, matching that section's
// testAsyncWithManualContext example almost line for line.
import jakarta.servlet.FilterChain;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.scheduling.DelegatingSecurityContextTaskScheduler;
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
import org.springframework.security.test.context.TestSecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
import org.springframework.security.web.context.SecurityContextRepository;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
class SecurityContextPropagationContractTest {
static Authentication auth(String name) {
return UsernamePasswordAuthenticationToken.authenticated(
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
static void setAuth(String name) {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth(name));
SecurityContextHolder.setContext(ctx);
}
// --- Demo 4: Executor / ExecutorService / AsyncTaskExecutor wrapping -------------------
@Test
void rawThreadPoolExecutorLosesContext() throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
setAuth("alice");
Authentication[] seen = new Authentication[1];
CountDownLatch latch = new CountDownLatch(1);
pool.execute(() -> { seen[0] = SecurityContextHolder.getContext().getAuthentication(); latch.countDown(); });
latch.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
pool.shutdown();
assertThat(seen[0]).isNull();
}
@Test
void delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask() throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
var wrapped = new DelegatingSecurityContextExecutorService(pool);
setAuth("task-one");
Authentication[] seenOne = new Authentication[1];
CountDownLatch l1 = new CountDownLatch(1);
wrapped.execute(() -> { seenOne[0] = SecurityContextHolder.getContext().getAuthentication(); l1.countDown(); });
l1.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
// Same pool worker is very likely reused here (pool size 1) -- if this wrapper
// behaved like plain InheritableThreadLocal, task two would see task one's stale
// value. It must not.
setAuth("task-two");
Authentication[] seenTwo = new Authentication[1];
CountDownLatch l2 = new CountDownLatch(1);
wrapped.execute(() -> { seenTwo[0] = SecurityContextHolder.getContext().getAuthentication(); l2.countDown(); });
l2.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
pool.shutdown();
assertThat(seenOne[0].getName()).isEqualTo("task-one");
assertThat(seenTwo[0].getName()).isEqualTo("task-two");
}
@Test
void completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates() throws Exception {
setAuth("erin");
CompletableFuture<Authentication> lost = CompletableFuture.supplyAsync(
() -> SecurityContextHolder.getContext().getAuthentication());
assertThat(lost.get(5, TimeUnit.SECONDS)).isNull();
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
var delegating = new DelegatingSecurityContextExecutor(pool);
CompletableFuture<Authentication> kept = CompletableFuture.supplyAsync(
() -> SecurityContextHolder.getContext().getAuthentication(), delegating);
assertThat(kept.get(5, TimeUnit.SECONDS).getName()).isEqualTo("erin");
SecurityContextHolder.clearContext();
pool.shutdown();
}
@Test
void delegatingSecurityContextAsyncTaskExecutorPropagates() throws Exception {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.initialize();
var wrapped = new DelegatingSecurityContextAsyncTaskExecutor(executor);
setAuth("grace");
Authentication[] seen = new Authentication[1];
CountDownLatch latch = new CountDownLatch(1);
wrapped.execute(() -> { seen[0] = SecurityContextHolder.getContext().getAuthentication(); latch.countDown(); });
latch.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
executor.shutdown();
assertThat(seen[0].getName()).isEqualTo("grace");
}
// --- Demo 5: Reactive context -----------------------------------------------------------
static Mono<String> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(sc -> "Hello, " + sc.getAuthentication().getName())
.defaultIfEmpty("Anonymous");
}
@Test
void reactiveContextSurvivesSchedulerHop_threadLocalDoesNot() {
setAuth("dave-threadlocal");
StepVerifier.create(
Mono.just("x")
.publishOn(Schedulers.boundedElastic())
// map() cannot emit null, so report presence/absence as a String
// rather than the (possibly null) Authentication itself.
.map(ignored -> SecurityContextHolder.getContext().getAuthentication() == null
? "NO AUTHENTICATION (lost)" : "unexpectedly present"))
.expectNext("NO AUTHENTICATION (lost)")
.verifyComplete();
SecurityContextHolder.clearContext();
StepVerifier.create(
Mono.just("x")
.publishOn(Schedulers.boundedElastic())
.then(Mono.defer(SecurityContextPropagationContractTest::getProfile))
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("dave-reactive"))))
.expectNext("Hello, dave-reactive")
.verifyComplete();
}
@Test
void reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext() {
StepVerifier.create(getProfile()).expectNext("Anonymous").verifyComplete();
}
// --- Demo 6: Scheduled tasks --------------------------------------------------------------
@Test
void delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction() throws Exception {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.initialize();
var wrapped = new DelegatingSecurityContextTaskScheduler(scheduler);
setAuth("registration-X");
Authentication[] seenX = new Authentication[1];
CountDownLatch lx = new CountDownLatch(1);
wrapped.schedule(() -> { seenX[0] = SecurityContextHolder.getContext().getAuthentication(); lx.countDown(); },
Instant.now().plusMillis(20));
lx.await(5, TimeUnit.SECONDS);
setAuth("registration-Y");
Authentication[] seenY = new Authentication[1];
CountDownLatch ly = new CountDownLatch(1);
wrapped.schedule(() -> { seenY[0] = SecurityContextHolder.getContext().getAuthentication(); ly.countDown(); },
Instant.now().plusMillis(20));
ly.await(5, TimeUnit.SECONDS);
SecurityContextHolder.clearContext();
scheduler.shutdown();
assertThat(seenX[0].getName()).isEqualTo("registration-X");
assertThat(seenY[0].getName()).isEqualTo("registration-Y");
}
// --- Demo 7: Servlet filter load vs. save ------------------------------------------------
@Test
void securityContextPersistenceFilterAutoSaves_holderFilterDoesNot() throws Exception {
SecurityContextRepository repoA = new HttpSessionSecurityContextRepository();
var persistenceFilter = new SecurityContextPersistenceFilter(repoA);
MockHttpServletRequest reqA = new MockHttpServletRequest();
MockHttpServletResponse respA = new MockHttpServletResponse();
FilterChain chainA = (req, resp) -> {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("alice"));
SecurityContextHolder.setContext(ctx);
};
persistenceFilter.doFilter(reqA, respA, chainA);
SecurityContextHolder.clearContext();
assertThat(sessionContextName(reqA.getSession(false))).isEqualTo("alice");
SecurityContextRepository repoB = new HttpSessionSecurityContextRepository();
var holderFilter = new SecurityContextHolderFilter(repoB);
MockHttpServletRequest reqB = new MockHttpServletRequest();
MockHttpServletResponse respB = new MockHttpServletResponse();
FilterChain chainB = (req, resp) -> {
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth("bob"));
SecurityContextHolder.setContext(ctx);
};
holderFilter.doFilter(reqB, respB, chainB);
SecurityContextHolder.clearContext();
assertThat(sessionContextName(reqB.getSession(false))).isNull();
}
@Test
void securityContextHolderFilterLoadsAnExistingSession() throws Exception {
SecurityContextRepository repo = new HttpSessionSecurityContextRepository();
MockHttpServletRequest seedReq = new MockHttpServletRequest();
MockHttpServletResponse seedResp = new MockHttpServletResponse();
SecurityContext seeded = SecurityContextHolder.createEmptyContext();
seeded.setAuthentication(auth("existing-user"));
repo.saveContext(seeded, seedReq, seedResp);
var holderFilter = new SecurityContextHolderFilter(repo);
MockHttpServletRequest req = new MockHttpServletRequest();
req.setSession((MockHttpSession) seedReq.getSession());
MockHttpServletResponse resp = new MockHttpServletResponse();
Authentication[] seen = new Authentication[1];
holderFilter.doFilter(req, resp, (r, s) -> seen[0] = SecurityContextHolder.getContext().getAuthentication());
SecurityContextHolder.clearContext();
assertThat(seen[0].getName()).isEqualTo("existing-user");
}
private static String sessionContextName(jakarta.servlet.http.HttpSession session) {
if (session == null) return null;
Object stored = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
if (!(stored instanceof SecurityContext sc) || sc.getAuthentication() == null) return null;
return sc.getAuthentication().getName();
}
// --- The post's own "Testing Security Context Propagation" section, reproduced ----------
/**
* Mirrors {@code AsyncServiceTest.testAsyncWithManualContext} from the post almost line
* for line: set up a context manually via {@code TestSecurityContextHolder} (the class
* the post names as the alternative to {@code @WithMockUser}), run an async operation
* through a {@code Delegating*} wrapper, and assert the result carries the test
* principal's name. Confirms {@code TestSecurityContextHolder} and
* {@code SecurityContextHolder} really are reading and writing the same underlying
* holder -- there's exactly one strategy per JVM (per thread, for the default
* {@code MODE_THREADLOCAL} strategy), test or production code.
*/
@Test
void testSecurityContextHolderIsTheSameHolderTestSecurityContextHolderWrites() throws Exception {
TestSecurityContextHolder.setAuthentication(auth("testuser"));
try {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
var wrapped = new DelegatingSecurityContextExecutorService(pool);
CompletableFuture<String> future = new CompletableFuture<>();
wrapped.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
future.complete("Processed by: " + (a == null ? "nobody" : a.getName()));
});
String result = future.get(5, TimeUnit.SECONDS);
pool.shutdown();
Assertions.assertThat(result).contains("testuser");
} finally {
TestSecurityContextHolder.clearContext();
}
}
}