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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
cp.txt
|
||||||
131
README.md
Normal file
131
README.md
Normal 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.
|
||||||
39
docs/01-inheritable-threadlocal.md
Normal file
39
docs/01-inheritable-threadlocal.md
Normal 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).
|
||||||
67
docs/02-async-virtual-threads.md
Normal file
67
docs/02-async-virtual-threads.md
Normal 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.
|
||||||
61
docs/03-structured-concurrency.md
Normal file
61
docs/03-structured-concurrency.md
Normal 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.
|
||||||
65
docs/04-executor-wrapping.md
Normal file
65
docs/04-executor-wrapping.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 4. Executor, ExecutorService, and AsyncTaskExecutor wrapping
|
||||||
|
|
||||||
|
[← Prev: Structured concurrency](03-structured-concurrency.md) | [Next: Reactive context →](05-reactive-context.md)
|
||||||
|
|
||||||
|
Chapters 1–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.
|
||||||
90
docs/05-reactive-context.md
Normal file
90
docs/05-reactive-context.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 5. ReactiveSecurityContextHolder and Reactor Context
|
||||||
|
|
||||||
|
[← Prev: Executor/ExecutorService wrapping](04-executor-wrapping.md) | [Next: Scheduled tasks →](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).
|
||||||
87
docs/06-scheduled-tasks.md
Normal file
87
docs/06-scheduled-tasks.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
|
||||||
|
|
||||||
|
[← Prev: Reactive context](05-reactive-context.md) | [Next: Servlet filter persistence →](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–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.
|
||||||
86
docs/07-servlet-filter-persistence.md
Normal file
86
docs/07-servlet-filter-persistence.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
|
||||||
|
|
||||||
|
[← Prev: Scheduled tasks](06-scheduled-tasks.md) | [Next: Testing contract + edge-case index →](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
113
docs/08-testing-contract.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# 8. Testing contract + edge-case index
|
||||||
|
|
||||||
|
[← 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
5
docs/output/demo1.txt
Normal 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
6
docs/output/demo2.txt
Normal 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
5
docs/output/demo3.txt
Normal 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
9
docs/output/demo4.txt
Normal 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
6
docs/output/demo5.txt
Normal 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
6
docs/output/demo6.txt
Normal 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
6
docs/output/demo7.txt
Normal 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
6
docs/output/tests.txt
Normal 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
112
pom.xml
Normal 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
33
scripts/run-all.sh
Executable 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
|
||||||
44
src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java
Normal file
44
src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java
Normal file
93
src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java
Normal file
92
src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/main/java/com/ankurm/vt/Demo4ExecutorWrapping.java
Normal file
167
src/main/java/com/ankurm/vt/Demo4ExecutorWrapping.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/main/java/com/ankurm/vt/Demo5ReactiveContext.java
Normal file
102
src/main/java/com/ankurm/vt/Demo5ReactiveContext.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/main/java/com/ankurm/vt/Demo6ScheduledSystemIdentity.java
Normal file
123
src/main/java/com/ankurm/vt/Demo6ScheduledSystemIdentity.java
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/main/java/com/ankurm/vt/Demo7ServletFilterPersistence.java
Normal file
161
src/main/java/com/ankurm/vt/Demo7ServletFilterPersistence.java
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user