commit 9f950bffa99d2777e3367fa9e8e942dc40cac679 Author: Ankur Date: Mon Aug 24 21:48:47 2026 +0530 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb0b47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +cp.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..04cf481 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/01-inheritable-threadlocal.md b/docs/01-inheritable-threadlocal.md new file mode 100644 index 0000000..ae8f2ba --- /dev/null +++ b/docs/01-inheritable-threadlocal.md @@ -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). diff --git a/docs/02-async-virtual-threads.md b/docs/02-async-virtual-threads.md new file mode 100644 index 0000000..d543afd --- /dev/null +++ b/docs/02-async-virtual-threads.md @@ -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. diff --git a/docs/03-structured-concurrency.md b/docs/03-structured-concurrency.md new file mode 100644 index 0000000..cf326e9 --- /dev/null +++ b/docs/03-structured-concurrency.md @@ -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 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. diff --git a/docs/04-executor-wrapping.md b/docs/04-executor-wrapping.md new file mode 100644 index 0000000..690db6e --- /dev/null +++ b/docs/04-executor-wrapping.md @@ -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. diff --git a/docs/05-reactive-context.md b/docs/05-reactive-context.md new file mode 100644 index 0000000..0393887 --- /dev/null +++ b/docs/05-reactive-context.md @@ -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 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` + +The post's second example, `getUser(@AuthenticationPrincipal Mono 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` 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). diff --git a/docs/06-scheduled-tasks.md b/docs/06-scheduled-tasks.md new file mode 100644 index 0000000..11cdeda --- /dev/null +++ b/docs/06-scheduled-tasks.md @@ -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. diff --git a/docs/07-servlet-filter-persistence.md b/docs/07-servlet-filter-persistence.md new file mode 100644 index 0000000..3542130 --- /dev/null +++ b/docs/07-servlet-filter-persistence.md @@ -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. diff --git a/docs/08-testing-contract.md b/docs/08-testing-contract.md new file mode 100644 index 0000000..c80a8a0 --- /dev/null +++ b/docs/08-testing-contract.md @@ -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. diff --git a/docs/output/demo1.txt b/docs/output/demo1.txt new file mode 100644 index 0000000..3240e79 --- /dev/null +++ b/docs/output/demo1.txt @@ -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 diff --git a/docs/output/demo2.txt b/docs/output/demo2.txt new file mode 100644 index 0000000..ced4679 --- /dev/null +++ b/docs/output/demo2.txt @@ -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] diff --git a/docs/output/demo3.txt b/docs/output/demo3.txt new file mode 100644 index 0000000..6c1bcff --- /dev/null +++ b/docs/output/demo3.txt @@ -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 diff --git a/docs/output/demo4.txt b/docs/output/demo4.txt new file mode 100644 index 0000000..e4c2d71 --- /dev/null +++ b/docs/output/demo4.txt @@ -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 diff --git a/docs/output/demo5.txt b/docs/output/demo5.txt new file mode 100644 index 0000000..88bcf5d --- /dev/null +++ b/docs/output/demo5.txt @@ -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] diff --git a/docs/output/demo6.txt b/docs/output/demo6.txt new file mode 100644 index 0000000..632f08e --- /dev/null +++ b/docs/output/demo6.txt @@ -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] diff --git a/docs/output/demo7.txt b/docs/output/demo7.txt new file mode 100644 index 0000000..0875f3d --- /dev/null +++ b/docs/output/demo7.txt @@ -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) diff --git a/docs/output/tests.txt b/docs/output/tests.txt new file mode 100644 index 0000000..f96258c --- /dev/null +++ b/docs/output/tests.txt @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..fbcbaf4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,112 @@ + + 4.0.0 + com.ankurm + vt-verify + 1.0 + jar + + + 25 + UTF-8 + + + + + org.springframework.security + spring-security-core + 7.1.1 + + + org.springframework.security + spring-security-web + 7.1.1 + + + org.springframework + spring-core + 7.0.9 + + + org.springframework + spring-context + 7.0.9 + + + org.springframework + spring-web + 7.0.9 + + + io.micrometer + context-propagation + 1.2.1 + + + io.projectreactor + reactor-core + 3.8.7 + + + jakarta.servlet + jakarta.servlet-api + 6.1.0 + provided + + + + org.springframework + spring-test + 7.0.9 + + + org.springframework.security + spring-security-test + 7.1.1 + test + + + io.projectreactor + reactor-test + 3.8.7 + test + + + org.junit.jupiter + junit-jupiter + 6.0.3 + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + --enable-preview + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + --enable-preview + + + + + diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..700b0ff --- /dev/null +++ b/scripts/run-all.sh @@ -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 diff --git a/src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java b/src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java new file mode 100644 index 0000000..f1b7cca --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java @@ -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 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(); + } +} diff --git a/src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java b/src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java new file mode 100644 index 0000000..241fc45 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java @@ -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(); + } +} diff --git a/src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java b/src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java new file mode 100644 index 0000000..a2cc560 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java @@ -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 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 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 task = () -> { + SecurityContextHolder.setContext(captured); + try { + return "C) manual capture/restore: " + readAuthInSubtask(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + Subtask 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 task = snapshot.wrap( + (Callable) () -> "D) ContextSnapshot.wrap: " + readAuthInSubtask()); + Subtask s = scope.fork(task); + scope.join(); + System.out.println(s.get()); + } + SecurityContextHolder.clearContext(); + } +} diff --git a/src/main/java/com/ankurm/vt/Demo4ExecutorWrapping.java b/src/main/java/com/ankurm/vt/Demo4ExecutorWrapping.java new file mode 100644 index 0000000..fd8f8f6 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo4ExecutorWrapping.java @@ -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}. + * + *

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. + * + *

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 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 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); + } +} diff --git a/src/main/java/com/ankurm/vt/Demo5ReactiveContext.java b/src/main/java/com/ankurm/vt/Demo5ReactiveContext.java new file mode 100644 index 0000000..1e96787 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo5ReactiveContext.java @@ -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. + * + *

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 different 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 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(); + } +} diff --git a/src/main/java/com/ankurm/vt/Demo6ScheduledSystemIdentity.java b/src/main/java/com/ankurm/vt/Demo6ScheduledSystemIdentity.java new file mode 100644 index 0000000..d59c0b5 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo6ScheduledSystemIdentity.java @@ -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". + * + *

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 per call to + * {@code schedule()}, 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]; + } +} diff --git a/src/main/java/com/ankurm/vt/Demo7ServletFilterPersistence.java b/src/main/java/com/ankurm/vt/Demo7ServletFilterPersistence.java new file mode 100644 index 0000000..11f7958 --- /dev/null +++ b/src/main/java/com/ankurm/vt/Demo7ServletFilterPersistence.java @@ -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]); + } + } +} diff --git a/src/test/java/com/ankurm/vt/SecurityContextPropagationContractTest.java b/src/test/java/com/ankurm/vt/SecurityContextPropagationContractTest.java new file mode 100644 index 0000000..75340f2 --- /dev/null +++ b/src/test/java/com/ankurm/vt/SecurityContextPropagationContractTest.java @@ -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 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 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 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 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(); + } + } +}