Full companion repo for the ankurm.com post "Spring Security Context Propagation: The Complete Guide" -- every code example the post discusses now has a corresponding runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency versions), not just the virtual-thread/structured-concurrency sections: - Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring) - Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator) - Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation - Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/ AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections - Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a Reactor scheduler hop -- the post's WebFlux/getProfile() section - Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual per-call capture semantics (confirmed via bytecode before writing the demo) and the createSystemContext() pattern -- the post's scheduled-tasks section - Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs. SecurityContextPersistenceFilter (load+auto-save), against real filter instances and a real HttpSession -- the post's servlet-environment section - SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as assertions instead of printed lines, including a TestSecurityContextHolder-based test reproducing the post's own "Testing Security Context Propagation" section Thirteen edge cases discovered along the way are indexed in docs/08 with links into the chapter that reproduces each one -- a reused pool worker NOT leaking under the Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be, a real NullPointerException from Reactor's map() hit while writing the reactive test, per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler, and the precise load-vs-save split between the two servlet filters, among others. docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh regenerates every docs/output/*.txt and the test suite output from one command.
8.5 KiB
8. Testing contract + edge-case index
← Prev: Servlet filter persistence
Every chapter so far demonstrates a claim by printing it. This chapter pins the same claims as
real JUnit 5 assertions (mvn test, SecurityContextPropagationContractTest, 10 tests) and
collects, in one place, every edge case discovered while building this repository -- the
things that would not survive a copy-paste of the post's original code samples, or that the
post's prose states as fact and this repo now proves against a real run instead. Output in
docs/output/tests.txt.
Running the tests
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
mvn test
The surefire plugin is configured with --enable-preview in pom.xml's argLine, matching
the compiler configuration -- no extra flags needed on the command line.
What the ten tests pin
| Test | Claim it pins | Chapter |
|---|---|---|
rawThreadPoolExecutorLosesContext |
An unwrapped ThreadPoolExecutor loses SecurityContext |
4 |
delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask |
Two tasks on a reused pool worker each keep their own context | 4 |
completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates |
Common ForkJoinPool loses context; a wrapped executor keeps it |
4 |
delegatingSecurityContextAsyncTaskExecutorPropagates |
DelegatingSecurityContextAsyncTaskExecutor propagates through ThreadPoolTaskExecutor |
4 |
reactiveContextSurvivesSchedulerHop_threadLocalDoesNot |
ThreadLocal fails across a publishOn hop; Reactor Context survives it |
5 |
reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext |
getProfile() degrades to "Anonymous", does not error, with no context written |
5 |
delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction |
Two schedule() calls on one wrapper capture two independent contexts |
6 |
securityContextPersistenceFilterAutoSaves_holderFilterDoesNot |
The Security-6 filter swap changed save behavior, not load behavior | 7 |
securityContextHolderFilterLoadsAnExistingSession |
SecurityContextHolderFilter still loads correctly from a prior save |
7 |
testSecurityContextHolderIsTheSameHolderTestSecurityContextHolderWrites |
TestSecurityContextHolder and SecurityContextHolder share one underlying holder |
this chapter |
The last test reproduces the post's own "Testing Security Context Propagation" section --
specifically AsyncServiceTest.testAsyncWithManualContext -- almost line for line:
TestSecurityContextHolder.setAuthentication(...) sets the context the same way manual
SecurityContextHolder.getContext().setAuthentication(...) does in the post's example, then a
DelegatingSecurityContextExecutorService-wrapped task reads it back on a different thread and
the assertion checks the result contains the test principal's name. The point worth knowing:
TestSecurityContextHolder is not a separate mock holder that needs wiring -- Spring
Security's default SecurityContextHolderStrategy is one strategy per JVM (per thread, under
MODE_THREADLOCAL), and TestSecurityContextHolder writes through the same one production
code reads from. @WithMockUser is a thin annotation-driven wrapper around the same
mechanism, wired in by WithSecurityContextTestExecutionListener when tests run under a full
Spring TestContext; this repo's tests use TestSecurityContextHolder directly instead, since
none of the other demos need a Spring ApplicationContext and adding one just for this test
would be the only place in the repository that did.
Edge-case index
Every edge case this repository actually reproduces, in one list. Each links to the chapter that runs it.
- A reused pool worker keeps two different tasks' contexts separate under the
Delegating*wrappers, unlike plainInheritableThreadLocal, which leaks the previous task's value onto a reused worker. See Chapter 1 for the leak, Chapter 4 for the fix proven independent-per-task. CompletableFuture.supplyAsync(supplier)with no executor argument silently uses the commonForkJoinPool, which never propagatesSecurityContext-- easy to miss because the one-argument overload compiles fine and works in every way except this one. See Chapter 4.MODE_INHERITABLETHREADLOCALis JVM-wide: turning it on to fix one virtual-thread executor also changes behavior for every other platform thread pool in the same process (JDBC housekeeping threads, a hand-rolledThreadPoolExecutor, the commonForkJoinPoolbehind parallel streams) and leaks the activeSecurityContextinto background threads that were never meant to run as the current user. See Chapter 2.StructuredTaskScopeinheritsScopedValuebindings by specification and says nothing aboutThreadLocal--SecurityContextHoldergets nothing for free from afork()call, which is easy to assume otherwise since the forked subtask is a fresh virtual thread, exactly the shape that makesMODE_INHERITABLETHREADLOCALwork elsewhere. See Chapter 3.- There is no
DelegatingSecurityContextStructuredTaskScopeand there will not be one, structurally:StructuredTaskScopedoes not implementExecutor, so there is noexecute(Runnable)seam for aDelegating*class to wrap. See Chapter 3. - Reactor's
Mono.map()throwsNullPointerExceptionif the mapper returnsnull-- discovered writing this repo's own JUnit test for reactive context propagation. Diagnostic code that maps straight toSecurityContextHolder.getContext().getAuthentication()(which can legitimately benull) breaks on the first request with no authentication, unless the mapper returns a sentinel value or wraps inOptionalinstead. See Chapter 5. ReactiveSecurityContextHolder.getContext()completes empty, it does not error, when no context was ever written upstream --.defaultIfEmpty(...)is covering a real, reachable case (an anonymous request to a permitted endpoint), not defensive boilerplate. See Chapter 5.- A
ThreadLocalwrite survives inside one operator but not across apublishOnhop to a differentScheduler-- proven as a same-chain before/after comparison, not two unrelated claims. See Chapter 5. DelegatingSecurityContextTaskScheduler's single-argument constructor capturesSecurityContextHolder.getContext()fresh on everyschedule()call, not once when the wrapper object is built -- confirmed by reading the class's bytecode before writing the demo, then proving it against the real class. Twoschedule()calls on the same wrapper, with the caller's context changed in between, do not see each other's value. See Chapter 6.- A synthetic
SYSTEMprincipal is not "anonymous" toAuthenticationTrustResolver-- authorization rules keyed onisAnonymous()will not match it, which matters for anything gated by.anonymous()in aSecurityFilterChain. See Chapter 6. SecurityContextHolderFilter(Security 6+ default) only ever loads -- it has no code path that callsSecurityContextRepository.saveContext(...)at all, confirmed by running it against a real session and observing nothing gets written, then confirming the load side still works correctly against a session seeded by an earlier explicit save. See Chapter 7.SecurityContextHolder.getContext()never returnsnull-- an unauthenticated request gets an emptySecurityContextobject whosegetAuthentication()isnull, not anullcontext. Code that checkscontext == nullto detect "nobody's authenticated" is checking the wrong condition. See Chapter 7.TestSecurityContextHolderand productionSecurityContextHolderread and write the same underlying strategy -- there's no separate mock state to keep in sync. See this chapter, above.