1
0

Add every example from the post, plus edge cases, to the companion repo

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.
This commit is contained in:
2026-08-24 21:48:47 +05:30
committed by Claude
commit 9f950bffa9
28 changed files with 2009 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
cp.txt

131
README.md Normal file
View File

@@ -0,0 +1,131 @@
# spring-security-demo
Companion repo for [Spring Security Context Propagation: The Complete
Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) on ankurm.com.
Eight small, dependency-light programs and one JUnit test suite that answer one question each:
does a Spring Security `SecurityContext` survive a specific thread, scheduler, or request
hand-off? Every scenario sets an `Authentication` somewhere and checks whether the other side
of the hand-off can see it -- against real executors, a real Reactor pipeline, a real
`TaskScheduler`, and real servlet filter classes (via Spring Test's mock request/response, no
running server needed).
Every example the post shows -- `@Async`, `ExecutorService`, `CompletableFuture`, virtual
threads, `StructuredTaskScope`, `ReactiveSecurityContextHolder`/WebFlux,
`DelegatingSecurityContextTaskScheduler` and scheduled tasks, and the
`SecurityContextHolderFilter`/`SecurityContextPersistenceFilter` servlet distinction -- has a
runnable demo here, plus edge cases the post doesn't have room for. See the [edge-case
index](docs/08-testing-contract.md#edge-case-index) for the full list with links.
## Verified versions
| Component | Version |
|---|---|
| JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 |
| Spring Boot (reference target) | 4.1.1 |
| Spring Framework | 7.0.9 |
| Spring Security | 7.1.1 |
| Spring Security Test | 7.1.1 |
| `io.micrometer:context-propagation` | 1.2.1 (as managed by Boot 4.1.1's BOM) |
| Reactor Core / Reactor Test | 3.8.7 (as managed by Boot 4.1.1's `reactor-bom` 2025.0.7) |
| `jakarta.servlet-api` | 6.1.0 |
| JUnit Jupiter | 6.0.3 |
| AssertJ | 3.27.7 |
`StructuredTaskScope` is a **preview API** on JDK 25 (JEP 505, fifth preview) and remains
preview through JDK 26 (JEP 525, sixth preview) -- every build/run/test command below needs
`--enable-preview`.
## Quickstart
```bash
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
javac --release 25 --enable-preview -cp "$(cat cp.txt)" -d target/classes $(find src/main -name '*.java')
java --enable-preview -cp "target/classes:$(cat cp.txt)" com.ankurm.vt.Demo1PlainThreadLocal
```
Or just run everything -- all seven demos plus the test suite -- and regenerate the captured
output: `scripts/run-all.sh`. To run only the JUnit contract tests: `mvn test` (the
`--enable-preview` flag is already wired into `pom.xml`'s surefire `argLine`, no extra flags
needed).
## What each demo shows
| Demo | Question | Chapter |
|---|---|---|
| `Demo1PlainThreadLocal` | Does `InheritableThreadLocal` behave differently for a pooled platform thread vs. a fresh virtual thread? (No Spring.) | [docs/01](docs/01-inheritable-threadlocal.md) |
| `Demo2AsyncVirtualThreads` | Does the Boot-4.1-style virtual-thread `@Async` executor propagate `SecurityContext`, and what four fixes change? | [docs/02](docs/02-async-virtual-threads.md) |
| `Demo3StructuredConcurrency` | Does a `StructuredTaskScope.fork()` subtask see the parent's `SecurityContext`? | [docs/03](docs/03-structured-concurrency.md) |
| `Demo4ExecutorWrapping` | Do `DelegatingSecurityContextExecutorService`/`Executor`/`AsyncTaskExecutor` propagate context on a classic *pooled platform-thread* executor, and does a reused worker leak between tasks the way `InheritableThreadLocal` did in Demo 1? | [docs/04](docs/04-executor-wrapping.md) |
| `Demo5ReactiveContext` | Does `ReactiveSecurityContextHolder` survive a scheduler hop that kills plain `ThreadLocal`-based `SecurityContextHolder`? | [docs/05](docs/05-reactive-context.md) |
| `Demo6ScheduledSystemIdentity` | What does `DelegatingSecurityContextTaskScheduler` actually capture, and when -- and how does the post's `createSystemContext()` pattern fix the fact that there's no real caller to propagate from? | [docs/06](docs/06-scheduled-tasks.md) |
| `Demo7ServletFilterPersistence` | Does `SecurityContextHolderFilter` really never save, while `SecurityContextPersistenceFilter` does -- proven against real filter instances and a real `HttpSession`? | [docs/07](docs/07-servlet-filter-persistence.md) |
| `SecurityContextPropagationContractTest` (JUnit, `src/test`) | Same ten claims above, pinned as assertions instead of printed lines; includes a `TestSecurityContextHolder`-based test reproducing the post's own "Testing Security Context Propagation" section | [docs/08](docs/08-testing-contract.md) |
## Endpoints / entry points
There's no web server in this repo (see the top of this file), so "entry points" means: every
class above has a runnable `main()`, and the whole suite runs end to end via
`scripts/run-all.sh`. `Demo5ReactiveContext` reproduces the post's `/profile`
(`ReactiveSecurityContextHolder`) behavior as a plain `Mono` chain rather than a bound HTTP
route, and `Demo7ServletFilterPersistence` reproduces the filter chain's request-scoped
behavior against `MockHttpServletRequest`/`MockHttpServletResponse` rather than a bound
servlet container -- both keep the "no web server, no HTTP" property the original three demos
established, so the whole repo still runs in well under a second with zero open ports.
## Captured output
Every number and log line in the blog post traces back to one of these, produced by
`scripts/run-all.sh`, not retyped:
- [docs/output/demo1.txt](docs/output/demo1.txt)
- [docs/output/demo2.txt](docs/output/demo2.txt)
- [docs/output/demo3.txt](docs/output/demo3.txt)
- [docs/output/demo4.txt](docs/output/demo4.txt)
- [docs/output/demo5.txt](docs/output/demo5.txt)
- [docs/output/demo6.txt](docs/output/demo6.txt)
- [docs/output/demo7.txt](docs/output/demo7.txt)
- [docs/output/tests.txt](docs/output/tests.txt) -- `mvn test` surefire summary for the ten
contract tests
## Doc chapters
Numbered, cross-linked, each with prev/next navigation at the top:
1. [InheritableThreadLocal across thread models](docs/01-inheritable-threadlocal.md)
2. [@Async, DelegatingSecurityContextExecutor, and virtual threads](docs/02-async-virtual-threads.md)
3. [StructuredTaskScope and SecurityContext](docs/03-structured-concurrency.md)
4. [Executor, ExecutorService, and AsyncTaskExecutor wrapping](docs/04-executor-wrapping.md)
5. [ReactiveSecurityContextHolder and Reactor Context](docs/05-reactive-context.md)
6. [DelegatingSecurityContextTaskScheduler and the synthetic system identity](docs/06-scheduled-tasks.md)
7. [SecurityContextHolderFilter vs. SecurityContextPersistenceFilter](docs/07-servlet-filter-persistence.md)
8. [Testing contract + edge-case index](docs/08-testing-contract.md)
## Edge cases
Thirteen reproducible edge cases were found building this repository -- pooled-worker leaks
the `Delegating*` classes don't have, the common `ForkJoinPool` trap, why
`MODE_INHERITABLETHREADLOCAL` is a JVM-wide instrument, why there's no
`DelegatingSecurityContextStructuredTaskScope` and never will be, a real `NullPointerException`
hit writing the reactive test, `ReactiveSecurityContextHolder` completing empty rather than
erroring, per-call (not per-construction) context capture in
`DelegatingSecurityContextTaskScheduler`, why a synthetic `SYSTEM` principal isn't
"anonymous", and the precise load-vs-save split between the two servlet filters. Full list,
each with the chapter that reproduces it: [docs/08 § Edge-case
index](docs/08-testing-contract.md#edge-case-index).
## The one-line summary of all eight chapters
`SecurityContextHolder` is a `ThreadLocal`. Nothing about virtual threads, structured
concurrency, reactive streams, schedulers, or servlet filters changes that fact -- what changes
between them is *how* (or whether) anything carries that `ThreadLocal`'s value across the
boundary each one introduces. Virtual threads are never pooled, so
`MODE_INHERITABLETHREADLOCAL`'s old danger (stale context on a reused pool worker) doesn't
apply to them, but it's still a JVM-wide setting. The `Delegating*` wrapper family solves the
same pooled-worker problem by a completely different mechanism -- explicit push/pop per task,
never thread inheritance -- which is why it has worked, unchanged, since long before virtual
threads existed. Reactive code doesn't have a `ThreadLocal`-compatible thread to begin with, so
`ReactiveSecurityContextHolder` uses Reactor's own `Context` instead. Scheduled tasks have no
caller at all, so the fix isn't propagation, it's minting an identity. And the servlet filter
that used to auto-save the context for you was replaced by one that only loads -- a change
worth knowing about before it's the reason a custom filter's write silently doesn't survive to
the next request.

View File

@@ -0,0 +1,39 @@
# 1. Why InheritableThreadLocal behaves differently with virtual threads
[Next: Async + virtual threads →](02-async-virtual-threads.md)
`Demo1PlainThreadLocal.java` has no Spring in it at all. It exists to settle one question
before Spring Security enters the picture: does `InheritableThreadLocal` actually behave
differently once the thread on the other end is virtual?
## The three cases
Every `Thread` copies the creating thread's `InheritableThreadLocal` values **once, at
construction time**. That single sentence explains everything Spring Security's concurrency
support has ever had to work around:
- A **fresh platform `Thread`** picks up whatever was set on the thread that created it. Fine.
- A **pooled platform thread** was constructed once, long ago, by the pool's internal thread
factory. Every task submitted to it later runs on that same physical thread, so it keeps
whatever `InheritableThreadLocal` value existed *when the pool created the worker*, not
what the submitting thread had at submission time. `docs/output/demo1.txt` shows this
directly: task 2 on a reused pool worker still reports `request-B`, not `request-C`, even
though the caller updated the value in between.
- A **virtual thread** is, in this respect, identical to the fresh-platform-thread case.
`Executors.newVirtualThreadPerTaskExecutor()` and `Thread.ofVirtual().start(...)` both
construct a brand new `Thread` object per task -- virtual threads are never pooled or
reused the way platform worker threads are. So the "stale value from a reused thread"
failure mode that made `SecurityContextHolder.MODE_INHERITABLETHREADLOCAL` dangerous with
`ThreadPoolTaskExecutor` simply does not exist for virtual threads.
## Why this matters for the rest of the repo
Spring Security's docs (and the original version of the blog post this repo supports) warn
against `MODE_INHERITABLETHREADLOCAL` because of the pooled-thread case above. That warning
is correct for `ThreadPoolTaskExecutor`. It stops being the relevant risk once
`spring.threads.virtual.enabled=true` swaps the executor for a `SimpleAsyncTaskExecutor`
backed by virtual threads -- there is no pool left to go stale. [Chapter 2](02-async-virtual-threads.md)
verifies that directly against `SecurityContextHolder`.
Run it yourself: `scripts/run-all.sh`, or just `demo1` from the output already captured in
[`docs/output/demo1.txt`](output/demo1.txt).

View File

@@ -0,0 +1,67 @@
# 2. @Async, DelegatingSecurityContextExecutor, and virtual threads on Boot 4.1
[← Prev: InheritableThreadLocal](01-inheritable-threadlocal.md) | [Next: Structured concurrency →](03-structured-concurrency.md)
`Demo2AsyncVirtualThreads.java` reproduces the exact executor bean Spring Boot 4.1 wires up
when you set `spring.threads.virtual.enabled=true`: a `SimpleAsyncTaskExecutor` with
`setVirtualThreads(true)`. That bean backs `@Async`, MVC async request handling, and WebFlux's
blocking-execution support. It is not a `ThreadPoolTaskExecutor` and never has a fixed pool of
workers to reuse -- see [Chapter 1](01-inheritable-threadlocal.md) for why that matters.
Four scenarios, same question each time: does the async task see the `Authentication` that was
active on the calling thread? Full output in [`docs/output/demo2.txt`](output/demo2.txt).
## A) Default mode, unwrapped executor -- loses it
`SecurityContextHolder`'s default strategy, `MODE_THREADLOCAL`, does not travel to any new
thread, virtual or not. This is the exact symptom reported against Spring Security as
[gh-15040](https://github.com/spring-projects/spring-security/issues/15040): swap in a raw
virtual-thread executor and `@Async` methods start throwing `AccessDeniedException` because
`SecurityContextHolder.getContext().getAuthentication()` is `null`.
## B) MODE_INHERITABLETHREADLOCAL, unwrapped executor -- works
This is the finding from Chapter 1 applied to Spring Security directly. Because the virtual
thread the executor spins up is fresh every time, `MODE_INHERITABLETHREADLOCAL` propagates the
context correctly with **zero extra wrapping code**. The reference docs' warning against this
mode predates virtual threads and is about pooled platform threads specifically -- it does not
apply to this executor shape. This is still a global JVM-wide setting, so weigh that against the
next two options, which are scoped to one executor bean.
## C) DelegatingSecurityContextExecutor -- still works, unconditionally
`DelegatingSecurityContextExecutor` doesn't rely on thread-local inheritance at all -- it wraps
the submitted `Runnable`, and the wrapper explicitly calls
`SecurityContextHolder.setContext(...)` / `clearContext()` around the delegate's `run()`,
wherever that `run()` happens to execute. That is why it has worked, unchanged, since long
before virtual threads existed, and why it is still the correct choice for library code that
cannot assume the application has set `MODE_INHERITABLETHREADLOCAL` globally.
## D) ContextPropagatingTaskDecorator -- the mechanism that's actually new
This is the one that did not exist when the [original version of this
post](https://ankurm.com/spring-security-context-propagation-complete-guide/) went up.
Spring Security 6.5 (GA 2025-05-19) added `SecurityContextHolderThreadLocalAccessor`, which
self-registers with Micrometer's `ContextRegistry` via `ServiceLoader` the moment
`io.micrometer:context-propagation` is on the classpath -- no bean, no configuration. Spring
Framework's `ContextPropagatingTaskDecorator` (since 6.1) uses that registry to snapshot and
restore every registered `ThreadLocalAccessor` around a task. Set it as the executor's task
decorator and `@Async` methods get the `SecurityContext` back **without any
`DelegatingSecurityContext*` wrapper at all** -- and the same decorator simultaneously restores
MDC and tracing context, which the `Delegating*` classes never touched.
```java
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
executor.setVirtualThreads(true);
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
```
Spring Security's own [Concurrency Support
page](https://docs.spring.io/spring-security/reference/features/integrations/concurrency.html)
still only documents the `Delegating*` family as of 7.1.1 -- this pattern is real and shipped,
just not yet reflected in that page.
`io.micrometer:context-propagation` is already on the classpath of any Boot 4.1 app that pulls
in `micrometer-observation` (actuator, tracing, or `spring-boot-starter-micrometer-*`). If your
app doesn't have a Micrometer dependency anywhere, add
`io.micrometer:context-propagation:1.2.1` (the version Boot 4.1.1's BOM manages) explicitly.

View File

@@ -0,0 +1,61 @@
# 3. StructuredTaskScope and SecurityContext
[← Prev: Async + virtual threads](02-async-virtual-threads.md) | [Next: Executor/ExecutorService wrapping →](04-executor-wrapping.md)
`Demo3StructuredConcurrency.java` asks the Chapter 2 question again, but for
`StructuredTaskScope` (JEP 505, fifth preview in JDK 25 -- still preview through the JDK 26
sixth preview per JEP 525, so every example here needs `--enable-preview`). A `fork()` call
starts a brand new virtual thread for the subtask, same as the executors in Chapter 2, so the
Chapter 1 finding applies here too. Full output in
[`docs/output/demo3.txt`](output/demo3.txt).
## What the JEP actually promises
JEP 525's text is explicit about one kind of context and silent about another:
> Subtasks forked in a scope inherit `ScopedValue` bindings.
That is a real, specified guarantee -- and it says nothing about `ThreadLocal`. Spring
Security's `SecurityContextHolder` is a `ThreadLocal`/`InheritableThreadLocal`, not a
`ScopedValue`. Nothing in the structured concurrency API changes that, and scenario A below
proves it: a plain `scope.fork(...)` with the default `MODE_THREADLOCAL` strategy loses the
`Authentication` exactly like the unwrapped executor in Chapter 2 did.
## Four scenarios
- **A) Plain fork, MODE_THREADLOCAL** -- lost. The default `SecurityContextHolder` strategy
isn't inherited by anything, structured concurrency included.
- **B) Plain fork, MODE_INHERITABLETHREADLOCAL** -- propagates. Same reasoning as Chapter 2,
scenario B: `fork()`'s subtask thread is a fresh virtual thread, so inheritance at
construction time works and there is no pooled-thread staleness risk.
- **C) Manual capture-and-restore around the forked `Callable`** -- propagates, and does not
depend on the global strategy mode at all:
```java
SecurityContext captured = SecurityContextHolder.getContext();
Callable<String> task = () -> {
SecurityContextHolder.setContext(captured);
try { return doWork(); }
finally { SecurityContextHolder.clearContext(); }
};
scope.fork(task);
```
This is the safest pattern for a `StructuredTaskScope` used inside library code, the same
way `DelegatingSecurityContextExecutor` is the safest pattern for an `Executor`: it works
regardless of what the surrounding application has set `SecurityContextHolder`'s strategy to.
- **D) `ContextSnapshot.wrap(...)` around the forked `Callable`** -- the Chapter 2 mechanism
applied to `fork()` instead of `execute()`. Because `SecurityContextHolderThreadLocalAccessor`
is already registered with Micrometer's `ContextRegistry`, `ContextSnapshotFactory.builder()
.build().captureAll()` picks up the current `SecurityContext` (and MDC, and tracing context)
in one call, and `.wrap(callable)` restores all of them inside the subtask. This is the
version worth reaching for once you have more than the `SecurityContext` to carry across the
scope boundary.
## The practical takeaway
`StructuredTaskScope` does not give `SecurityContextHolder` anything for free. If your
`fork()`ed subtasks need to call secured services, wrap them explicitly -- option C if you
want zero new dependencies, option D if `context-propagation` is already on the classpath and
you have other thread-locals to carry along too.

View File

@@ -0,0 +1,65 @@
# 4. Executor, ExecutorService, and AsyncTaskExecutor wrapping
[&larr; Prev: Structured concurrency](03-structured-concurrency.md) | [Next: Reactive context &rarr;](05-reactive-context.md)
Chapters 1&ndash;3 are all about virtual threads and structured concurrency, which came later.
`Demo4ExecutorWrapping.java` goes back to the baseline the post's "Using @Async", "Using
ExecutorService", and "Using CompletableFuture" sections describe: a **fixed platform-thread
pool** (`ThreadPoolExecutor`, `ThreadPoolTaskExecutor`), the shape almost every Spring app used
before `spring.threads.virtual.enabled` existed, and the exact pooled-thread danger
[Chapter 1](01-inheritable-threadlocal.md) demonstrated for `InheritableThreadLocal`. Full
output in [`docs/output/demo4.txt`](output/demo4.txt).
## The three wrapper classes the post names
- **`DelegatingSecurityContextExecutorService`** wraps an entire `ExecutorService` --
`execute()`, `submit()`, `invokeAll()`, `invokeAny()` all go through the wrapper. This is
the fix for the post's `TaskExecutionService` ("Using ExecutorService") example.
- **`DelegatingSecurityContextExecutor`** wraps a plain `Executor` and is what you hand to
`CompletableFuture.supplyAsync(supplier, executor)` -- the fix for
`CompletableFutureService` ("Using CompletableFuture"). The common `ForkJoinPool` that
`CompletableFuture.supplyAsync(supplier)` uses when you don't supply an executor never
propagates context; scenario C2 in the output shows that directly.
- **`DelegatingSecurityContextAsyncTaskExecutor`** wraps Spring's own
`AsyncTaskExecutor`/`TaskExecutor` abstraction -- the type `AsyncConfigurer#getAsyncExecutor()`
actually returns, and the object Spring's `@Async` infrastructure calls `execute()`/`submit()`
on. This is the fix for the post's `AsyncConfig` example.
All three extend the same mechanism [Chapter 2](02-async-virtual-threads.md) already
described: wrap the submitted `Runnable`/`Callable`, capture `SecurityContextHolder.getContext()`
once (at wrap time, not at thread-construction time), and push/pop it around the delegate's
execution on whatever thread that turns out to be.
## The edge case Chapter 1 sets up and this chapter resolves
Chapter 1's whole point was that a **reused pool worker** keeps whatever
`InheritableThreadLocal` value existed when the pool created it, not what the submitting
thread had at submission time -- that's why `MODE_INHERITABLETHREADLOCAL` is dangerous with a
fixed thread pool. The `EDGE` scenario in this demo asks the same question of the
`Delegating*` wrappers, and the answer is the opposite:
```
EDGE) task 1 on possibly-reused worker: authenticated as carol-task1
EDGE) task 2, same pool, different caller context: authenticated as dave-task2 <-- correct, NOT stale
```
Two tasks submitted through `DelegatingSecurityContextExecutorService` to the *same* two-worker
pool, with the caller's `SecurityContextHolder` context changed in between, each see their
**own** context -- never the other task's. That's because the wrapper captures context per
submission (inside `wrap()`, called synchronously from `execute()`/`submit()`), not once per
worker thread the way thread-local inheritance does. This is the actual reason the
`Delegating*` family predates virtual threads by years and is still correct on a fixed pool:
it never depended on thread identity in the first place.
[Chapter 8](08-testing-contract.md) pins this exact contrast as a JUnit assertion
(`delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask`), not just
a printed line.
## Practical note: which one do you actually need?
If you already have an `AsyncConfigurer` returning a `ThreadPoolTaskExecutor`, wrap it with
`DelegatingSecurityContextAsyncTaskExecutor` and nothing else changes -- `@Async` keeps working
as written. If you're calling `CompletableFuture.supplyAsync(...)` without an explicit executor
anywhere in the codebase, that's the common-`ForkJoinPool` trap in scenario C2; the fix is
always to supply a `DelegatingSecurityContextExecutor`-wrapped executor, never to reach for
`MODE_INHERITABLETHREADLOCAL` as a global patch for one call site.

View File

@@ -0,0 +1,90 @@
# 5. ReactiveSecurityContextHolder and Reactor Context
[&larr; Prev: Executor/ExecutorService wrapping](04-executor-wrapping.md) | [Next: Scheduled tasks &rarr;](06-scheduled-tasks.md)
Every other demo in this repository asks "does the *thread* on the other side of a hand-off
see the `SecurityContext`?" `Demo5ReactiveContext.java` asks a different question, because
WebFlux doesn't have a thread on the other side of anything in the sense the rest of this
repo cares about -- a reactive chain hops between scheduler threads as operators execute, and
none of those threads is dedicated to one request. Full output in
[`docs/output/demo5.txt`](output/demo5.txt).
## Why ThreadLocal genuinely cannot work here
Scenarios A and B make the failure concrete rather than asserted. A sets
`SecurityContextHolder` (a `ThreadLocal`) on the calling thread and reads it back
immediately -- works, because nothing has moved threads yet. B does the identical setup, but
inserts a single `.publishOn(Schedulers.boundedElastic())` between the write and the read --
exactly what a real WebFlux event loop does routinely between operators -- and the context is
gone:
```
A) SecurityContextHolder (ThreadLocal), no scheduler hop: authenticated as alice
B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: NO AUTHENTICATION (lost)
```
This is the actual mechanism behind the post's line "`ThreadLocal` doesn't work because
operations jump between threads" -- not a general reactive-programming caveat, a specific,
reproducible failure with a specific operator.
## ReactiveSecurityContextHolder: the fix, and why it survives the same hop
`ReactiveSecurityContextHolder.getContext()` doesn't read a `ThreadLocal` at all -- it reads
Project Reactor's own `Context`, which is attached to the *subscription*, not a thread, and
which Reactor propagates through every operator in the chain regardless of which
`Scheduler` runs which step. `ReactiveSecurityContextHolder.withAuthentication(auth)` produces
a `Context` you attach with `.contextWrite(...)`. Scenarios C and D repeat A and B with this
mechanism instead, and D survives the identical `publishOn` hop that killed B:
```
C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: Hello, carol
D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: Hello, dave
```
This is the post's `getProfile()` example, reproduced verbatim as `Demo5ReactiveContext.getProfile()`:
```java
static Mono<String> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> "Hello, " + securityContext.getAuthentication().getName())
.defaultIfEmpty("Anonymous");
}
```
## Edge case: `map()` cannot emit `null`
Writing this demo's JUnit counterpart in [Chapter 8](08-testing-contract.md) hit a real
`NullPointerException` on the first attempt: `Mono.map(...)` throws if the mapper function
returns `null` (Reactor treats a `null` signal as a programming error, not an empty result --
that's what `Mono.empty()`/`defaultIfEmpty()` are for). A lambda that reads
`SecurityContextHolder.getContext().getAuthentication()` and returns it directly breaks the
moment the authentication is absent. The fix used throughout this demo and its test is to map
to a descriptive `String` ("NO AUTHENTICATION (lost)") instead of passing a possibly-null
domain object through a reactive operator. This is a real trap for exactly the kind of
diagnostic code you'd add while debugging a context-propagation bug in a reactive pipeline.
## Edge case: no context ever written
Scenario E calls `getProfile()` with no `.contextWrite(...)` anywhere upstream at all --
`ReactiveSecurityContextHolder.getContext()` completes **empty**, not with an error, so
`.defaultIfEmpty("Anonymous")` fires cleanly:
```
E) getProfile() with no contextWrite() upstream at all: Anonymous
```
This matters for the post's `/profile` endpoint: an anonymous request to a permitted path
never throws inside `getProfile()`, it degrades to "Anonymous" -- the `defaultIfEmpty` isn't
defensive boilerplate, it's covering a real, reachable case.
## What this means for `@AuthenticationPrincipal Mono<UserDetails>`
The post's second example, `getUser(@AuthenticationPrincipal Mono<UserDetails> user)`, is the
same mechanism at one more remove: Spring Security resolves that `Mono` parameter by reading
`ReactiveSecurityContextHolder` internally before your method runs, so it inherits everything
in this chapter for free. If a `Mono<UserDetails>` argument comes back empty in a WebFlux
controller for a request you expected to be authenticated, the two things worth checking first
are exactly A and B above: is a `ThreadLocal`-based mechanism (yours or a library's) trying to
read the context after a scheduler hop, and is `.contextWrite(...)` actually upstream of the
read in the chain that populates it (`SecurityWebFilterChain` normally handles this for you,
but a hand-rolled `WebFilter` that reorders operators can break it).

View File

@@ -0,0 +1,87 @@
# 6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
[&larr; Prev: Reactive context](05-reactive-context.md) | [Next: Servlet filter persistence &rarr;](07-servlet-filter-persistence.md)
Every previous chapter is about carrying *somebody's* context across a thread or scheduler
boundary. `Demo6ScheduledSystemIdentity.java` is about the case where that framing breaks
down: a `@Scheduled` cron trigger has no caller, so there is no `Authentication` anywhere to
propagate in the first place. Full output in [`docs/output/demo6.txt`](output/demo6.txt).
## What `DelegatingSecurityContextTaskScheduler` actually captures
Before writing this demo, the class's bytecode was read directly (not assumed from the
Javadoc) to answer one question precisely: does the single-argument constructor capture
`SecurityContextHolder.getContext()` once, when the wrapper is built, or fresh, every time
`schedule(...)` is called? The constructor itself stores a `null` `SecurityContext` field;
`wrap(Runnable)` -- called synchronously from inside every `schedule*` method -- passes that
`null` to `DelegatingSecurityContextRunnable.create(...)`, which resolves `null` to
`SecurityContextHolder.getContext()` at that exact call site. So the capture happens **per
call to `schedule()`**, on whatever thread makes that call, not once at wrapper-construction
time. Scenario B in the demo proves it against the real class rather than the disassembly:
```
B1) first schedule() call, caller context = registration-thread-X: authenticated as registration-thread-X
B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: authenticated as registration-thread-Y
```
Two `schedule()` calls on the identical `DelegatingSecurityContextTaskScheduler` instance,
with the calling thread's `SecurityContextHolder` changed in between, capture two different
contexts independently. In a real Spring app, `ScheduledTaskRegistrar.afterPropertiesSet()`
calls `schedule()` once per `@Scheduled` method, all during application context refresh on the
startup thread -- which is almost always running with **no** `SecurityContext` at all.
Scenario A is that realistic case:
```
A) schedule() called with NO context present on the caller thread: NO AUTHENTICATION (lost)
```
## Why the post's `createSystemContext()` pattern exists
There is no "the user" to recover here, so the fix in the post's `ScheduledTasks` example
doesn't try to propagate anything -- it constructs a brand-new `SecurityContext` from scratch,
inside the `@Scheduled` method body, with a narrowly scoped synthetic principal:
```java
private SecurityContext createSystemContext() {
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM")
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(systemAuth);
return context;
}
```
Scenario C runs exactly this pattern and confirms the result end to end:
```
C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: SYSTEM with authorities [ROLE_SYSTEM]
```
`ROLE_SYSTEM` here, not an admin role borrowed from somewhere else in the app -- the point of
minting a dedicated identity is that a bug in the scheduled task is bounded by what
`ROLE_SYSTEM` can do, not by whatever the broadest role in the system happens to be.
## Edge case: the synthetic principal isn't "anonymous" to Spring Security
It's tempting to assume a made-up `SYSTEM` principal with no credentials is somehow a special
or unauthenticated case. It isn't -- `AuthenticationTrustResolver` has no concept of "system"
at all, and treats it as a completely ordinary authenticated principal:
```
EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): false
```
That matters anywhere the app has authorization rules keyed on `isAnonymous()` or
`isRememberMe()` (an `.anonymous()` matcher in a `SecurityFilterChain`, for instance) --
`createSystemContext()`'s output will not match those rules, which is usually what you want,
but is worth confirming rather than assuming for a security-relevant identity.
## The Boot 4.1 virtual-thread footnote
`spring.threads.virtual.enabled=true` also swaps the scheduler backing `@Scheduled` for a
`SimpleAsyncTaskScheduler` running virtual threads, the same substitution
[Chapter 2](02-async-virtual-threads.md) covers for `@Async`. The "fresh thread every time, no
pooled-worker staleness" reasoning from Chapters 1&ndash;2 applies here too, but it changes
nothing about this chapter's actual point: there is still no per-invocation user identity for
any thread model to inherit, because there was never a user in the first place.

View File

@@ -0,0 +1,86 @@
# 7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
[&larr; Prev: Scheduled tasks](06-scheduled-tasks.md) | [Next: Testing contract + edge-case index &rarr;](08-testing-contract.md)
The post's "Security Context Propagation in Servlet Environment" section makes a specific,
checkable claim: since Spring Security 6.0, `SecurityContextHolderFilter` replaced
`SecurityContextPersistenceFilter` as the default, and the two behave differently in a way
that matters -- the old filter auto-saved the context at the end of the request, the new one
only loads. `Demo7ServletFilterPersistence.java` runs both real filter classes against a real
`HttpSession` (via Spring Test's `MockHttpServletRequest`/`MockHttpServletResponse`, no
servlet container) to confirm it directly rather than restate the reference docs. Full output
in [`docs/output/demo7.txt`](output/demo7.txt).
## Load vs. save, proven separately
Scenario A runs `SecurityContextPersistenceFilter`: the simulated controller sets an
`Authentication` on `SecurityContextHolder` mid-chain, and once the filter's `doFilter`
returns, the session already contains it:
```
A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: true
```
Scenario B is the identical setup against `SecurityContextHolderFilter`, the Security 6+
default:
```
B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: false
```
Nothing was written to the session. This is `requireExplicitSave`'s default behavior made
concrete: setting `SecurityContextHolder.setContext(...)` inside request processing does not,
by itself, persist anything past the current request under the Security-6-default filter.
## The fix, proven too
Scenario C repeats B but adds one line inside the simulated chain --
`repository.saveContext(ctx, request, response)` -- the exact workaround the post recommends
for custom pre-authentication filters that set the context directly:
```
C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: true
```
## Edge case: "only loads, never saves" describes the save side, not the load side
It's easy to over-read "only loads, never saves" as "does almost nothing." Scenario D seeds a
session with a context (simulating what an earlier request's explicit `saveContext(...)`
would have left behind) and confirms `SecurityContextHolderFilter` still loads it correctly on
a subsequent request through the same session:
```
D) SecurityContextHolderFilter, context already saved in an existing session, next request: authenticated as dave
```
The filter's whole job on the read side is unchanged; the only thing Security 6 removed is the
automatic write at the end.
## Edge case: no session, no prior save -- not an error
Scenario E runs a completely fresh request through `SecurityContextHolderFilter` with no
existing session and nothing set anywhere:
```
EDGE) brand-new request, no prior session, nothing set: NO AUTHENTICATION (empty context, not an error)
```
`SecurityContextHolder.getContext()` never returns `null` -- Spring Security's
`SecurityContextHolderStrategy` always hands back an empty `SecurityContext` object whose
`getAuthentication()` is `null`, rather than a `null` context itself. Code that checks
`context == null` to detect "nobody's authenticated" is checking the wrong thing; check
`context.getAuthentication() == null` instead.
## Why this matters more than it looks
`requireExplicitSave(true)` (shown in the post's `WebSecurityConfig`) is not something you
turn on -- it has been the default since Security 6.0, and OpenRewrite ships a migration
recipe specifically to *remove* the explicit call as dead weight when upgrading to 6.0. The
behavior it names, though, is exactly what scenarios A/B/C above measure: the old auto-save
wrote to the session on every request regardless of whether the context had actually changed,
which was wasteful and made intent ambiguous; the new default only writes when something
explicitly asks it to. The framework's own authentication filters (form login, basic auth,
OAuth2 login) already call `saveContext(...)` after a successful login, so this rarely bites
application code -- it becomes a real bug only in code that calls
`SecurityContextHolder.setContext(...)` directly, outside that flow, exactly the custom
pre-authentication-filter case the post calls out.

113
docs/08-testing-contract.md Normal file
View File

@@ -0,0 +1,113 @@
# 8. Testing contract + edge-case index
[&larr; Prev: Servlet filter persistence](07-servlet-filter-persistence.md)
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`](output/tests.txt).
## Running the tests
```bash
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](04-executor-wrapping.md) |
| `delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask` | Two tasks on a reused pool worker each keep their own context | [4](04-executor-wrapping.md) |
| `completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates` | Common `ForkJoinPool` loses context; a wrapped executor keeps it | [4](04-executor-wrapping.md) |
| `delegatingSecurityContextAsyncTaskExecutorPropagates` | `DelegatingSecurityContextAsyncTaskExecutor` propagates through `ThreadPoolTaskExecutor` | [4](04-executor-wrapping.md) |
| `reactiveContextSurvivesSchedulerHop_threadLocalDoesNot` | `ThreadLocal` fails across a `publishOn` hop; Reactor `Context` survives it | [5](05-reactive-context.md) |
| `reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext` | `getProfile()` degrades to `"Anonymous"`, does not error, with no context written | [5](05-reactive-context.md) |
| `delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction` | Two `schedule()` calls on one wrapper capture two independent contexts | [6](06-scheduled-tasks.md) |
| `securityContextPersistenceFilterAutoSaves_holderFilterDoesNot` | The Security-6 filter swap changed save behavior, not load behavior | [7](07-servlet-filter-persistence.md) |
| `securityContextHolderFilterLoadsAnExistingSession` | `SecurityContextHolderFilter` still loads correctly from a prior save | [7](07-servlet-filter-persistence.md) |
| `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 plain `InheritableThreadLocal`, which leaks the previous task's value onto
a reused worker. See [Chapter 1](01-inheritable-threadlocal.md) for the leak, [Chapter
4](04-executor-wrapping.md#the-edge-case-chapter-1-sets-up-and-this-chapter-resolves) for the
fix proven independent-per-task.
- **`CompletableFuture.supplyAsync(supplier)` with no executor argument silently uses the
common `ForkJoinPool`**, which never propagates `SecurityContext` -- easy to miss because the
one-argument overload compiles fine and works in every way except this one. See [Chapter
4](04-executor-wrapping.md).
- **`MODE_INHERITABLETHREADLOCAL` is 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-rolled `ThreadPoolExecutor`, the common `ForkJoinPool`
behind parallel streams) and leaks the active `SecurityContext` into background threads that
were never meant to run as the current user. See [Chapter 2](02-async-virtual-threads.md).
- **`StructuredTaskScope` inherits `ScopedValue` bindings by specification and says nothing
about `ThreadLocal`** -- `SecurityContextHolder` gets nothing for free from a `fork()` call,
which is easy to assume otherwise since the forked subtask is a fresh virtual thread, exactly
the shape that makes `MODE_INHERITABLETHREADLOCAL` work elsewhere. See [Chapter
3](03-structured-concurrency.md).
- **There is no `DelegatingSecurityContextStructuredTaskScope` and there will not be one**,
structurally: `StructuredTaskScope` does not implement `Executor`, so there is no
`execute(Runnable)` seam for a `Delegating*` class to wrap. See [Chapter
3](03-structured-concurrency.md).
- **Reactor's `Mono.map()` throws `NullPointerException` if the mapper returns `null`** --
discovered writing this repo's own JUnit test for reactive context propagation. Diagnostic
code that maps straight to `SecurityContextHolder.getContext().getAuthentication()` (which can
legitimately be `null`) breaks on the first request with no authentication, unless the mapper
returns a sentinel value or wraps in `Optional` instead. See [Chapter
5](05-reactive-context.md#edge-case-map-cannot-emit-null).
- **`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](05-reactive-context.md).
- **A `ThreadLocal` write survives inside one operator but not across a `publishOn` hop to a
different `Scheduler`** -- proven as a same-chain before/after comparison, not two unrelated
claims. See [Chapter 5](05-reactive-context.md).
- **`DelegatingSecurityContextTaskScheduler`'s single-argument constructor captures
`SecurityContextHolder.getContext()` fresh on every `schedule()` 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. Two `schedule()` calls on the same wrapper, with the
caller's context changed in between, do not see each other's value. See [Chapter
6](06-scheduled-tasks.md).
- **A synthetic `SYSTEM` principal is not "anonymous" to `AuthenticationTrustResolver`** --
authorization rules keyed on `isAnonymous()` will not match it, which matters for anything
gated by `.anonymous()` in a `SecurityFilterChain`. See [Chapter 6](06-scheduled-tasks.md).
- **`SecurityContextHolderFilter` (Security 6+ default) only ever *loads* -- it has no code
path that calls `SecurityContextRepository.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](07-servlet-filter-persistence.md).
- **`SecurityContextHolder.getContext()` never returns `null`** -- an unauthenticated request
gets an empty `SecurityContext` object whose `getAuthentication()` is `null`, not a `null`
context. Code that checks `context == null` to detect "nobody's authenticated" is checking
the wrong condition. See [Chapter 7](07-servlet-filter-persistence.md).
- **`TestSecurityContextHolder` and production `SecurityContextHolder` read and write the
same underlying strategy** -- there's no separate mock state to keep in sync. See this
chapter, above.

5
docs/output/demo1.txt Normal file
View File

@@ -0,0 +1,5 @@
=== Demo 1: InheritableThreadLocal across thread models ===
fresh platform thread sees: request-A
pool thread, task 1, sees: request-B
pool thread, task 2 (reused), sees: request-B <-- stale, not request-C
fresh virtual thread sees: request-D

6
docs/output/demo2.txt Normal file
View File

@@ -0,0 +1,6 @@
=== Demo 2: @Async-style virtual thread executor + SecurityContext ===
A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): NO AUTHENTICATION (lost) [VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-1]
B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): authenticated as bob [VirtualThread[#26,vt-1]/runnable@ForkJoinPool-1-worker-2]
C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual): authenticated as carol [VirtualThread[#27,vt-1]/runnable@ForkJoinPool-1-worker-1]
SecurityContextHolderThreadLocalAccessor present: true
D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper: authenticated as dave [VirtualThread[#28,vt-1]/runnable@ForkJoinPool-1-worker-1]

5
docs/output/demo3.txt Normal file
View File

@@ -0,0 +1,5 @@
=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===
A) plain fork, MODE_THREADLOCAL: NO AUTHENTICATION (lost)
B) plain fork, MODE_INHERITABLETHREADLOCAL: authenticated as frank
C) manual capture/restore: authenticated as grace
D) ContextSnapshot.wrap: authenticated as heidi

9
docs/output/demo4.txt Normal file
View File

@@ -0,0 +1,9 @@
=== Demo 4: Executor/ExecutorService/AsyncTaskExecutor wrapping on a pooled platform thread ===
A) raw ThreadPoolExecutor, no wrapper: NO AUTHENTICATION (lost)
B) DelegatingSecurityContextExecutorService.execute(...): authenticated as bob
B2) DelegatingSecurityContextExecutorService.submit(Callable): authenticated as bob
EDGE) task 1 on possibly-reused worker: authenticated as carol-task1
EDGE) task 2, same pool, different caller context: authenticated as dave-task2 <-- correct, NOT stale, unlike plain InheritableThreadLocal on a reused worker
C) DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync: authenticated as erin
C2) default CompletableFuture executor (common ForkJoinPool), no wrapper: NO AUTHENTICATION (lost)
D) DelegatingSecurityContextAsyncTaskExecutor wrapping ThreadPoolTaskExecutor: authenticated as grace

6
docs/output/demo5.txt Normal file
View File

@@ -0,0 +1,6 @@
=== Demo 5: ReactiveSecurityContextHolder vs. ThreadLocal across a scheduler hop ===
A) SecurityContextHolder (ThreadLocal), no scheduler hop: authenticated as alice
B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: NO AUTHENTICATION (lost) [proves ThreadLocal doesn't survive a scheduler hop]
C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: Hello, carol
D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: Hello, dave [Context travels with the stream, not the thread]
E) getProfile() with no contextWrite() upstream at all: Anonymous [defaultIfEmpty fires; getContext() completes empty, it does not error]

6
docs/output/demo6.txt Normal file
View File

@@ -0,0 +1,6 @@
=== Demo 6: DelegatingSecurityContextTaskScheduler and the synthetic system identity ===
A) schedule() called with NO context present on the caller thread: NO AUTHENTICATION (lost) [this is the realistic startup case the post warns about]
B1) first schedule() call, caller context = registration-thread-X: authenticated as registration-thread-X
B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: authenticated as registration-thread-Y [independent per-call capture, not frozen at wrapper construction]
C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: SYSTEM with authorities [ROLE_SYSTEM]
EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): false [false -- SYSTEM is a normal authenticated principal, not Spring Security's anonymous concept]

6
docs/output/demo7.txt Normal file
View File

@@ -0,0 +1,6 @@
=== Demo 7: SecurityContextHolderFilter vs SecurityContextPersistenceFilter -- load vs. load+save ===
A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: true [true -- this filter saves for you]
B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: false [false -- requireExplicitSave's default; nothing persists unless you save it yourself]
C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: true [true -- the workaround the post recommends actually works]
D) SecurityContextHolderFilter, context already saved in an existing session, next request: authenticated as dave [it does load -- "only loads, never saves" describes the SAVE side, not the LOAD side]
EDGE) brand-new request, no prior session, nothing set: NO AUTHENTICATION (empty context, not an error)

6
docs/output/tests.txt Normal file
View File

@@ -0,0 +1,6 @@
mvn test -- SecurityContextPropagationContractTest (10 tests pinning the claims each demo prints above)
-------------------------------------------------------------------------------
Test set: com.ankurm.vt.SecurityContextPropagationContractTest
-------------------------------------------------------------------------------
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.413 s -- in com.ankurm.vt.SecurityContextPropagationContractTest

112
pom.xml Normal file
View File

@@ -0,0 +1,112 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm</groupId>
<artifactId>vt-verify</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>7.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>7.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>7.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>7.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>7.0.9</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>context-propagation</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.8.7</version>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<!-- compile scope, not test: Demo7ServletFilterPersistence (src/main) uses
MockHttpServletRequest/Response directly so it stays runnable standalone via
`java -cp ...`, matching the other demos in this repo. -->
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>7.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>7.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.8.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>

33
scripts/run-all.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Compiles and runs all seven demos plus the JUnit contract test suite, regenerating
# docs/output/*.txt. Requires JDK 25 (StructuredTaskScope is a preview API through JDK 25 /
# JEP 505).
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
CP=$(cat cp.txt)
rm -rf target/classes
mkdir -p target/classes docs/output
javac --release 25 --enable-preview -cp "$CP" -d target/classes $(find src/main -name '*.java')
for demo in Demo1PlainThreadLocal Demo2AsyncVirtualThreads Demo3StructuredConcurrency \
Demo4ExecutorWrapping Demo5ReactiveContext Demo6ScheduledSystemIdentity \
Demo7ServletFilterPersistence; do
num=$(echo "$demo" | grep -o '^Demo[0-9]*' | grep -o '[0-9]*')
out="docs/output/demo${num}.txt"
echo "Running $demo -> $out"
java --enable-preview -cp "target/classes:$CP" "com.ankurm.vt.$demo" \
| grep -v '^Picked up JAVA_TOOL_OPTIONS' | tee "$out"
done
echo "Running JUnit contract test suite -> docs/output/tests.txt"
mvn -q test > /tmp/mvn-test-raw.txt 2>&1 || true
{
echo "mvn test -- SecurityContextPropagationContractTest (10 tests pinning the claims each demo prints above)"
echo
cat target/surefire-reports/com.ankurm.vt.SecurityContextPropagationContractTest.txt
} > docs/output/tests.txt
cat docs/output/tests.txt
rm -f /tmp/mvn-test-raw.txt

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();
}
}
}