1
0
Files
spring-security-demo/README.md
Ankur 9f950bffa9 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.
2026-08-24 16:49:30 +00:00

132 lines
8.4 KiB
Markdown

# 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.