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.
132 lines
8.4 KiB
Markdown
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.
|