1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
3170adef18 Add the service-to-service module 2026-08-28 10:02:47 +05:30
47bd1e56ec Add the cors-csrf module 2026-08-28 09:33:22 +05:30
85 changed files with 5891 additions and 13 deletions

View File

@@ -10,37 +10,54 @@ by that module's `scripts/run-all.sh`, never typed by hand.
| [`context-propagation/`](context-propagation/README.md) | [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) | Whether a `SecurityContext` survives `@Async`, executors, virtual threads, `StructuredTaskScope`, Reactor, schedulers and the servlet filter chain |
| [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run |
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
| [`cors-csrf/`](cors-csrf/README.md) | [CORS, CSRF and SameSite in Spring Boot 4](https://ankurm.com/spring-boot-4-cors-csrf-samesite/) | Why MVC-layer CORS does not fix a security-layer preflight rejection, what `csrf.spa()` assigns, and the cookie a browser silently refuses to store |
| [`service-to-service/`](service-to-service/README.md) | [Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS](https://ankurm.com/spring-boot-microservices-token-relay-mtls/) | Whose identity arrives at the last service under relay, client credentials and token exchange — and what a resource server does not check by default |
The three are related more closely than they look. `filter-chain` is about how an
`Authentication` gets into `SecurityContextHolder` in the first place and in what order;
`context-propagation` is about whether it survives leaving the request thread; `method-security`
reads it back on whatever thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails
with `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module,
not the third — and a custom authentication filter that never populated the context in the first
They are related more closely than they look. `filter-chain` is about how an `Authentication`
gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
about whether it survives leaving the request thread; `method-security` reads it back on whatever
thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with
`AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, not
the third — and a custom authentication filter that never populated the context in the first
place fails the same way, for reasons that belong to the first.
`service-to-service` is the same question one process further out: `cors-csrf` and
`context-propagation` ask whether an identity survives a thread or a browser boundary, and this
one asks whether it survives an HTTP boundary — and what the service on the far side bothers to
verify about it. Its `/edge/relay-async` endpoint fails for exactly the reason
`context-propagation` documents.
`cors-csrf` is where those order numbers stop being trivia. `CorsFilter` at 1000 and `CsrfFilter`
at 1100 both sit far above `AuthorizationFilter` at 4200, and almost every confusing symptom in
that module is a consequence of one of those three positions — including a 403 that arrives as a
401 because the container re-dispatched the request to `/error` and the chain ran a second time.
## Common ground
All three modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
All modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
**Spring Framework 7.0.9**, **Spring Security 7.1.1** — the versions Spring Boot **4.1.1**
manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from
release announcements.
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
still a preview API on JDK 25. `method-security` does not. `filter-chain` is the only module
that is a real servlet application: it inherits `spring-boot-starter-parent` and runs on Tomcat,
because the thing it demonstrates only exists inside a servlet container.
still a preview API on JDK 25. `method-security` does not. `filter-chain`, `cors-csrf` and
`service-to-service` are real servlet applications: they inherit `spring-boot-starter-parent`
and run on Tomcat, because the things they demonstrate only exist inside a servlet container.
`service-to-service` runs five of them at once, and is the only module that also pulls in
Spring Cloud — a separate release train, built against Boot 4.0.8 rather than 4.1.1.
## Running a module
```bash
cd method-security # or context-propagation, or filter-chain
cd cors-csrf # or context-propagation, method-security, filter-chain, service-to-service
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
mvn test # just the assertions
```
`filter-chain` also has `./scripts/run.sh <profile>` and `./scripts/stop.sh`, because its
scenarios are a running web application rather than a `main()` method.
`filter-chain` and `cors-csrf` also have `./scripts/run.sh <profile>` and `./scripts/stop.sh`,
because their scenarios are a running web application rather than a `main()` method.
`cors-csrf` adds `./scripts/preflight.sh`, which sends one CORS preflight and prints the headers
that decide the outcome.
## License

117
cors-csrf/README.md Normal file
View File

@@ -0,0 +1,117 @@
# `cors-csrf` — CORS, CSRF and SameSite, reproduced state by state
Companion project for
[**CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong**](https://ankurm.com/spring-boot-4-cors-csrf-samesite/)
on ankurm.com.
Every broken state the article describes is a Spring profile on this one application, and every
transcript under [`docs/output/`](docs/output/) was produced by running it. No browser is
required: a CORS preflight is an `OPTIONS` carrying two headers, and `curl` sends those.
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent, so everything below is Boot-managed |
| Spring Framework | 7.0.9 | `CorsFilter`, `DefaultCorsProcessor`, `CorsConfiguration` |
| Spring Security | 7.1.1 | `CorsConfigurer`, `CsrfConfigurer.spa()` |
| Tomcat | 11.0.24 | |
| JUnit Jupiter / AssertJ | Boot-managed | 23 assertions |
Versions were read from `repo1.maven.org/.../maven-metadata.xml`, not from release
announcements. Note that `maven-metadata.xml`'s own `<release>` element pointed at
`4.2.0-M1` while this was written; a milestone is not a release.
## Quickstart
```bash
./scripts/run.sh securitysource # the configuration that works
./scripts/preflight.sh # one preflight, headers printed
./scripts/run.sh mvconly # the same app with CORS on the MVC layer only
./scripts/preflight.sh # → 401
./scripts/run-all.sh # every scenario, regenerating docs/output/
mvn test # just the 23 assertions
./scripts/stop.sh
```
The user is `alice` / `password`.
## Profiles
| Profile | Configuration | Shows |
|---|---|---|
| `securitysource` *(default)* | [`SecuritySourceConfig`](src/main/java/com/ankurm/cors/config/SecuritySourceConfig.java) | A bean named `corsConfigurationSource`. This one works |
| `mvconly` | [`MvcOnlySecurityConfig`](src/main/java/com/ankurm/cors/config/MvcOnlySecurityConfig.java) | `addCorsMappings` and nothing else — preflight answered `401` |
| `mvcbridge` | [`MvcBridgeSecurityConfig`](src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java) | The same MVC config plus `.cors(withDefaults())` — and MVC's `max-age` default |
| `misnamed` | [`MisnamedSourceConfig`](src/main/java/com/ankurm/cors/config/MisnamedSourceConfig.java) | Right type, wrong bean name — preflight answered `200` with no CORS headers |
| `twosources` | [`TwoSourcesConfig`](src/main/java/com/ankurm/cors/config/TwoSourcesConfig.java) | Two sources. The docs say CORS is not configured; it is, and the name decides |
| `wildcard` | [`WildcardCredentialsConfig`](src/main/java/com/ankurm/cors/config/WildcardCredentialsConfig.java) | `allowedOrigins("*")` with credentials — fails on the request, surfaces as `401` |
| `csrfnaive` | [`CsrfNaiveConfig`](src/main/java/com/ankurm/cors/config/CsrfNaiveConfig.java) | The pre-6.0 SPA recipe: no cookie on the GET, 403 on the POST |
| `csrfspa` | [`CsrfSpaConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaConfig.java) | `csrf.spa()`, and why the cookie now arrives on the bootstrap GET |
| `spaorder` | [`CsrfSpaOrderConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaOrderConfig.java) | `csrfTokenRepository(..)` before `spa()` — silently discarded |
| `crosssite` | [`CsrfSpaCrossSiteConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaCrossSiteConfig.java) | `SameSite=None; Secure` on the CSRF cookie |
| `errorpermit` | [`ErrorDispatchConfig`](src/main/java/com/ankurm/cors/config/ErrorDispatchConfig.java) | Add-on. Combine with any other profile to see the status code the `/error` dispatch was hiding |
Add-on profiles combine: `./scripts/run.sh csrfnaive,errorpermit`.
Three environment settings change behaviour rather than configuration:
| Setting | Effect |
|---|---|
| `SESSION_SAME_SITE` / `SESSION_SECURE` | The session cookie's attributes, written straight through by Boot |
| `JVM_ARGS=-DOMIT_SECURE=true` | Under `crosssite`, emit `SameSite=None` **without** `Secure` |
| `CORS_LOG_LEVEL` / `CSRF_LOG_LEVEL` | `DEBUG` turns on the two log categories that answer almost every question here |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET`/`POST /api/data` | The API the imaginary SPA calls |
| `GET /api/whoami` | Who the request authenticated as |
| `GET /api/boom` | Throws, so you can watch the error dispatch |
| `GET /diag/chain` | The filters `FilterChainProxy` actually holds |
| `GET /diag/cors-sources` | Every `CorsConfigurationSource` bean, by name |
| `GET /diag/cookie-spec?h=…&secure=…` | Real `Set-Cookie` headers run through the RFC 6265bis rules |
The `/diag/**` endpoints are permitted without authentication so the scripts can read them.
Delete them before shipping.
## Documentation
| Chapter | |
|---|---|
| [01](docs/01-two-layers.md) | Two layers, one word — why MVC CORS does not fix a security-layer rejection |
| [02](docs/02-who-resolves-the-source.md) | Who resolves the `CorsConfigurationSource` — by type, then by **name** |
| [03](docs/03-three-identical-403s.md) | The three identical 403s, and reading a status code as a diagnosis |
| [04](docs/04-preflight-handlers.md) | `PreFlightRequestHandler`, and the wildcard that is not allowed |
| [05](docs/05-the-error-dispatch.md) | The `/error` dispatch, or why your 403 arrives as a 401 |
| [06](docs/06-csrf-for-spas.md) | CSRF for SPAs, and what `spa()` actually assigns |
| [07](docs/07-samesite.md) | SameSite, `Secure`, and the cookie that is never stored |
| [08](docs/08-debugging-recipes.md) | Debugging recipes |
## Captured output
| File | |
|---|---|
| [01-mvc-only.txt](docs/output/01-mvc-only.txt) | An 11-filter chain with no `CorsFilter`, and a `401` preflight |
| [02-mvc-bridge.txt](docs/output/02-mvc-bridge.txt) | The same app plus one line — `200`, and `Access-Control-Max-Age: 1800` |
| [03-security-source.txt](docs/output/03-security-source.txt) | A `corsConfigurationSource` bean — `200`, and **no** max-age |
| [04-three-identical-403s.txt](docs/output/04-three-identical-403s.txt) | Three rejections, one response, three DEBUG lines |
| [05-misnamed-bean.txt](docs/output/05-misnamed-bean.txt) | `200` with no CORS headers, and `Skip: no CORS configuration has been provided` |
| [06-two-sources.txt](docs/output/06-two-sources.txt) | Two sources, CORS configured anyway, the named bean winning |
| [07-wildcard-credentials.txt](docs/output/07-wildcard-credentials.txt) | The `IllegalArgumentException`, arriving as a `401` |
| [08-csrf-naive.txt](docs/output/08-csrf-naive.txt) | No cookie on the GET, then two rejected POSTs |
| [09-error-dispatch.txt](docs/output/09-error-dispatch.txt) | The same failure with `/error` permitted — the `403` reappears |
| [10-csrf-spa.txt](docs/output/10-csrf-spa.txt) | `csrf.spa()`: cookie on the GET, raw value accepted in the header |
| [11-spa-ordering.txt](docs/output/11-spa-ordering.txt) | A custom repository silently discarded by `spa()` |
| [12-samesite.txt](docs/output/12-samesite.txt) | Four sets of real `Set-Cookie` headers, run through the RFC rules |
| [13-tests.txt](docs/output/13-tests.txt) | `mvn test` |
## Related modules
- [`filter-chain/`](../filter-chain/README.md) — the order numbers this module keeps citing, and the `/error` dispatch in full
- [`context-propagation/`](../context-propagation/README.md) — whether the `SecurityContext` survives leaving the request thread
- [`method-security/`](../method-security/README.md) — reading that context back

View File

@@ -0,0 +1,82 @@
# 1. Two layers, one word
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
A Spring Boot application can be told about CORS in two entirely separate places, and the two
places do not talk to each other unless you make them.
**The MVC layer.** `WebMvcConfigurer.addCorsMappings(..)` and `@CrossOrigin` register a
`CorsConfiguration` with Spring MVC's handler mappings. It is consulted inside
`DispatcherServlet`, when the request is being matched to a handler method.
**The security layer.** `HttpSecurity.cors(..)` puts a `org.springframework.web.filter.CorsFilter`
into the security filter chain. It runs at order **1000** &mdash; between `HeaderWriterFilter`
(900) and `CsrfFilter` (1100), and a long way above `AuthorizationFilter` (4200).
The security filter chain runs to completion before `DispatcherServlet` is ever entered. So if
the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code
that reads it never executes.
## Why that specifically breaks preflights
A CORS preflight is not a special protocol. It is an ordinary `OPTIONS` request carrying two
headers:
```
OPTIONS /api/data HTTP/1.1
Origin: https://spa.example.com
Access-Control-Request-Method: POST
```
The browser sends it **without credentials** &mdash; no cookies, no `Authorization` header, by
design. Against `anyRequest().authenticated()` that request is anonymous, and anonymous requests
are denied. The rejection happens at order 4200, three thousand two hundred slots before
`DispatcherServlet` and about four thousand before your `addCorsMappings` call matters.
[`docs/output/01-mvc-only.txt`](output/01-mvc-only.txt) is that state: an eleven-filter chain
with no `CorsFilter` in it, and a preflight answered `401`.
## The fix is one line, and it is not on the MVC layer
```java
http.cors(Customizer.withDefaults())
```
That is [`MvcBridgeSecurityConfig`](../src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java),
and [`docs/output/02-mvc-bridge.txt`](output/02-mvc-bridge.txt) is the identical application
answering `200`. The MVC configuration was fine all along. Nothing was reading it.
`CorsFilter` short-circuits every preflight it sees:
```java
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return; // the chain stops here
}
filterChain.doFilter(request, response);
```
Read that `if` carefully, because chapter 2 turns on it: the filter returns on **every**
preflight, whether or not it found a configuration to apply.
## One thing that changes when you move the configuration
Moving CORS from `addCorsMappings` to a `CorsConfigurationSource` bean is not a pure
relocation. Compare the two transcripts:
```
02-mvc-bridge.txt Access-Control-Max-Age: 1800
03-security-source.txt (nothing)
```
`CorsRegistration` &mdash; the builder behind `addCorsMappings` &mdash; defaults `maxAge` to
1800 seconds. A bare `CorsConfiguration` leaves it `null`, and a preflight response with no
`Access-Control-Max-Age` is not cached, so the browser preflights **every single cross-origin
call**. Two round trips instead of one, forever, with nothing in any log to suggest it.
```java
configuration.setMaxAge(1800L);
```
---
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*

View File

@@ -0,0 +1,99 @@
# 2. Who resolves the `CorsConfigurationSource`
*Prev: [1. Two layers, one word](01-two-layers.md) &middot; Next: [3. The three identical 403s](03-three-identical-403s.md)*
There are two lookups involved in getting a CORS configuration into the filter chain, and they
disagree about what they are looking for. One searches by **type**. The other searches by
**name**.
## Lookup 1: should the CORS configurer run at all?
`HttpSecurityConfiguration.applyCorsIfAvailable(HttpSecurity)`, disassembled from
`spring-security-config` 7.1.1:
```
4: ldc // class org/springframework/web/cors/UrlBasedCorsConfigurationSource
6: invokeinterface // ApplicationContext.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String;
11: arraylength
12: ifle 23
19: invokevirtual // HttpSecurity.cors:(Customizer)HttpSecurity
23: return
```
By type, and the test is `ifle` &mdash; "branch if less than or equal to zero". One bean is
enough. So is five.
> The reference documentation says: *"If you have more than one `CorsConfigurationSource` bean,
> Spring Security won't automatically configure CORS support for you, because it cannot decide
> which one to use."* That is not what 7.1.1 does.
> [`docs/output/06-two-sources.txt`](output/06-two-sources.txt) has two such beans, CORS
> configured, and one of them serving traffic.
## Lookup 2: which source does the configurer use?
`CorsConfigurer.getCorsConfigurationSource(ApplicationContext)`:
```
7: ldc // String corsConfigurationSource
9: invokeinterface // ApplicationContext.containsBeanDefinition:(Ljava/lang/String;)Z
20: ldc // String corsConfigurationSource
24: invokeinterface // ApplicationContext.getBean:(String,Class)Object
34: invokestatic // MvcCorsFilter.getMvcCorsConfigurationSource:(ApplicationContext)CorsConfigurationSource
```
By name. The literal string `corsConfigurationSource`. If no bean definition carries that name,
it falls through to Spring MVC's registrations. (There is a similar name check first, for a
`CorsFilter` bean named `corsFilter`.)
## The gap between them
Name a `UrlBasedCorsConfigurationSource` bean anything other than `corsConfigurationSource` and
you land between the two lookups: CORS is switched **on** by the type lookup, and the
configuration you wrote is **ignored** by the name lookup.
That is the `misnamed` profile, and it produces the worst diagnostic in this whole subject:
```
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
...
```
Two hundred. No `Access-Control-Allow-Origin`. The browser blocks the request and reports a CORS
error; your access log shows a successful `OPTIONS`; nothing anywhere is red.
Why 200 rather than the 401 from chapter 1? Because `CorsFilter` is now in the chain, and its
`if (!isValid || isPreFlightRequest(request)) return;` fires on the second clause. The request
never reaches `AuthorizationFilter`. The only trace is one DEBUG line:
```
o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
```
Full transcript: [`docs/output/05-misnamed-bean.txt`](output/05-misnamed-bean.txt).
## What is in the context that you did not put there
`/diag/cors-sources` on a stock Boot web application:
```json
{ "corsConfigurationSourceBeans": { "mvcHandlerMappingIntrospector": "HandlerMappingIntrospector" } }
```
`HandlerMappingIntrospector` implements `CorsConfigurationSource`. It is always there, it is not
a `UrlBasedCorsConfigurationSource`, and it is the object the MVC fallback returns. That is why
the fallback path never throws in a normal application &mdash; and why the failure is silent
rather than loud.
## Rules that follow
- Name the bean `corsConfigurationSource`. Exactly that.
- If you want several, pass them per chain with `.cors(c -> c.configurationSource(..))`, which
bypasses both lookups.
- `NoSuchBeanDefinitionException: Failed to find a bean that implements
\`CorsConfigurationSource\`` names three fixes and does not mention the fourth one, which is
usually the right one: rename your bean.
---
*Prev: [1. Two layers, one word](01-two-layers.md) &middot; Next: [3. The three identical 403s](03-three-identical-403s.md)*

View File

@@ -0,0 +1,79 @@
# 3. The three identical 403s
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) &middot; Next: [4. Preflight handlers](04-preflight-handlers.md)*
`DefaultCorsProcessor` runs three checks on a preflight, in order: origin, method, request
headers. All three failures produce the same thing.
```
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Invalid CORS request
```
Same status, same body, byte for byte, no `Access-Control-*` header to distinguish them. The
assertion in `CorsContractTests.threeRejectionsLookIdentical` compares the three bodies for
equality, so if a future version starts distinguishing them, that test fails.
The only place the difference exists is a DEBUG log line, and the three are worth memorising
because they are the fastest CORS diagnosis available:
```
o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
```
Turn them on with:
```yaml
logging.level.org.springframework.web.cors: DEBUG
```
The complete set of messages, read out of the class's constant pool, is five:
| Message | Meaning |
|---|---|
| `Skip: no CORS configuration has been provided` | The source returned `null` for this path &mdash; chapter 2 |
| `Skip: response already contains "Access-Control-Allow-Origin"` | Something upstream already handled it |
| `Reject: origin is malformed` | The `Origin` header did not parse |
| `Reject: '…' origin is not allowed` | |
| `Reject: HTTP '…' is not allowed` | |
| `Reject: headers '[…]' are not allowed` | |
## Status codes, and what each one means
Collecting the states this module reproduces:
| What you see | What it means |
|---|---|
| `401`/`403`, no `Access-Control-*` at all | No `CorsFilter` in the chain. The preflight was judged by authorization &mdash; chapter 1 |
| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path &mdash; chapter 2 |
| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers &mdash; this chapter |
| `200` with `Access-Control-Allow-Origin` | It worked |
| `404`, no `Access-Control-*` | The path is outside the pattern you registered. Common with `/api/**` versus a mis-typed URL |
The browser reports the same "blocked by CORS policy" for the first four rows. Two of them are
not CORS problems.
## What a **simple** request does
Only preflighted requests get intercepted. A simple `GET` runs the whole chain, so an
unauthenticated one returns 401 &mdash; **carrying** the CORS header, because `CorsFilter` at
1000 already wrote it before `AuthorizationFilter` at 4200 rejected the request:
```
HTTP/1.1 401
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Credentials: true
```
That is the good case: the SPA's `fetch` resolves and the code can read `response.status`. It is
also the reason "my POST fails but my GET returns a readable 401" is a coherent bug report and
not a contradiction.
---
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) &middot; Next: [4. Preflight handlers](04-preflight-handlers.md)*

View File

@@ -0,0 +1,73 @@
# 4. `PreFlightRequestHandler`, and the wildcard that is not allowed
*Prev: [3. The three identical 403s](03-three-identical-403s.md) &middot; Next: [5. The /error dispatch](05-the-error-dispatch.md)*
## `preFlightRequestHandler`
`CorsConfigurer` in 7.1.1 has a second setter beside `configurationSource`:
```java
public CorsConfigurer<H> preFlightRequestHandler(PreFlightRequestHandler handler);
```
When one is selected, Spring Security registers Spring Framework's `PreFlightRequestFilter`
**before** `CorsFilter` in the chain &mdash; `addFilterBefore(.., CorsFilter.class)`, which lands
it at 999. It is for applications that answer preflights from their own routing rather than from
a `CorsConfiguration`, and it is the hook `WebFlux`-style functional routing and gateway-shaped
applications want.
The handler is picked up either from the `preFlightRequestHandler(..)` call or from a
`PreFlightRequestHandler` bean, and only when no `CorsConfigurationSource` or `CorsFilter` was
chosen for that chain. Configuring both raises, at startup:
```
java.lang.IllegalStateException: Cannot configure both a CorsConfigurationSource and a
PreFlightRequestHandler on CorsConfigurer
```
That string is in `CorsConfigurer.configure`'s constant pool; it is a hard failure, not a
warning.
## `allowedOrigins("*")` with `allowCredentials(true)`
The Fetch standard forbids answering a credentialed request with
`Access-Control-Allow-Origin: *`. Spring enforces it &mdash; but not where you would expect.
The configuration builds. The context starts. The check happens on the first request, inside
`CorsConfiguration.validateAllowCredentials`, reached from `checkOrigin`:
```
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain
the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response
header. To allow credentials to a set of origins, list them explicitly or consider using
"allowedOriginPatterns" instead.
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552)
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678)
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193)
```
And here is the part worth knowing: **the client does not get a 500.** It gets a `401`.
[`docs/output/07-wildcard-credentials.txt`](output/07-wildcard-credentials.txt) shows a request
with entirely correct Basic credentials answered `401 WWW-Authenticate: Basic`. Chapter 5 is why.
The fix is `setAllowedOriginPatterns(..)`, which echoes the request's own origin back instead of
a literal asterisk, and is therefore legal with credentials:
```java
configuration.setAllowedOriginPatterns(List.of("https://*.example.com"));
configuration.setAllowCredentials(true);
```
`allowedHeaders("*")` and `allowedMethods("*")` are unaffected &mdash; the prohibition is
specific to the origin, because that is the one that gets reflected into a header the browser
uses to decide whether the caller may read a credentialed response.
## Private Network Access
`DefaultCorsProcessor` in Spring Framework 7.0.9 also handles
`Access-Control-Request-Private-Network` / `Access-Control-Allow-Private-Network` &mdash; both
strings are in the class. If a public-origin SPA calls something on a private address, that is
the header pair to look for, and `CorsConfiguration.setAllowPrivateNetwork(true)` is the switch.
---
*Prev: [3. The three identical 403s](03-three-identical-403s.md) &middot; Next: [5. The /error dispatch](05-the-error-dispatch.md)*

View File

@@ -0,0 +1,79 @@
# 5. The `/error` dispatch, or why your 403 arrives as a 401
*Prev: [4. Preflight handlers](04-preflight-handlers.md) &middot; Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*
Two of the failures in this module &mdash; the wildcard/credentials clash in chapter 4 and the
CSRF rejection in chapter 6 &mdash; produce a status code that has nothing to do with what went
wrong. Both have the same cause, and it is worth understanding once because it explains a large
fraction of confusing Spring Security bug reports.
## The mechanism
1. Something inside the chain calls `response.sendError(403, ..)` (that is what
`AccessDeniedHandlerImpl` does) or lets an exception escape `FilterChainProxy`.
2. The servlet container does not write that response. It **re-dispatches** the request
internally to `/error`, with `DispatcherType.ERROR`.
3. Spring Boot registers `springSecurityFilterChain` for **every** dispatcher type:
`SecurityFilterProperties.dispatcherTypes` defaults to `EnumSet.allOf(DispatcherType.class)`.
So the whole security chain runs again on that dispatch.
4. On the second pass, the filters that extend `OncePerRequestFilter` skip themselves &mdash;
`shouldNotFilterErrorDispatch()` defaults to `true`. `BasicAuthenticationFilter` is one of
them. The credential is never re-read.
5. The filters that extend `GenericFilterBean` do run. `AuthorizationFilter` is one of them.
6. So the second pass is **authorized but not authenticated**: `AuthorizationFilter` evaluates
`/error` against `anyRequest().authenticated()`, finds an anonymous principal, and denies it.
7. The 401 from step 6 is what reaches the client. The 403 from step 1 is gone.
The mechanism is set out in full in
[The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/);
this chapter is what it looks like when it lands on a CORS or CSRF problem.
## Proving it in one diff
The `errorpermit` profile adds one filter chain, `@Order(0)`, matching `/error` and permitting
everything. Nothing else changes.
```
./scripts/run.sh csrfnaive POST → 401, empty body, WWW-Authenticate: Basic
./scripts/run.sh csrfnaive,errorpermit POST → 403, {"status":403,"error":"Forbidden", ...}
```
[`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) against
[`docs/output/09-error-dispatch.txt`](output/09-error-dispatch.txt).
## What to do about it
Permit `/error`. It is not a hole: the error page is generated from an attribute the container
set, and an unauthenticated request cannot reach it except through a dispatch the container
initiated.
```java
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf(CsrfConfigurer::disable)
.build();
}
```
The alternative is to narrow the dispatcher types so the chain does not run on the error
dispatch at all:
```yaml
spring.security.filter.dispatcher-types: request
```
That one is broader in effect than it looks; prefer the `/error` chain unless you have a
specific reason.
## Why this matters more for a SPA than for a server-rendered app
A browser will not let a SPA read a cross-origin response unless the CORS headers are present.
When the 403 is replaced by a 401 written on a dispatch where `CorsFilter` may or may not have
re-run, what the developer sees in the console is neither "403" nor "CSRF"; it is
`TypeError: Failed to fetch`. Every layer of the stack has thrown away the actual cause by then.
---
*Prev: [4. Preflight handlers](04-preflight-handlers.md) &middot; Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*

View File

@@ -0,0 +1,114 @@
# 6. CSRF for SPAs, and what `spa()` actually assigns
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) &middot; Next: [7. SameSite](07-samesite.md)*
## The recipe that stopped working in 6.0
```java
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
```
Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go
wrong with it now, and [`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) walks through
all three in one transcript.
**1. The bootstrap GET sets no cookie.** Since 6.0 the token is *deferred*: `CsrfFilter` puts a
`Supplier<CsrfToken>` in a request attribute and only materialises it if something dereferences
it. A `GET` on a JSON endpoint dereferences nothing. So the SPA starts up, sees no `XSRF-TOKEN`
cookie, and its first mutating request has nothing to send.
**2. Sending the raw cookie value fails.** The default handler is
`XorCsrfTokenRequestAttributeHandler`, added as a BREACH mitigation. It expects the value in the
header to be XOR-masked. `CookieCsrfTokenRepository` writes the **raw** token into the cookie.
So the SPA reads a raw value, sends a raw value, and the handler tries to unmask it. 403.
**3. The cookie is not sent cross-site anyway.** Chapter 7.
## What `spa()` is
Spring Security 7.0 added `CsrfConfigurer.spa()`. Its entire bytecode:
```
0: aload_0
1: invokestatic // CookieCsrfTokenRepository.withHttpOnlyFalse()
4: putfield // Field csrfTokenRepository
7: aload_0
8: new // class CsrfConfigurer$SpaCsrfTokenRequestHandler
12: invokespecial // <init>
15: putfield // Field requestHandler
18: aload_0
19: areturn
```
Two unconditional assignments. `SpaCsrfTokenRequestHandler` holds two delegates:
```java
private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
// constructor: xor.setCsrfRequestAttributeName(null);
```
`handle(..)` always delegates to `xor`. `resolveCsrfTokenValue(..)` picks `plain` when the
request carries the header and `xor` otherwise:
```java
String headerValue = request.getHeader(csrfToken.getHeaderName());
return (StringUtils.hasText(headerValue) ? this.plain : this.xor)
.resolveCsrfTokenValue(request, csrfToken);
```
So a SPA reading the cookie and echoing it in `X-XSRF-TOKEN` compares raw against raw and
succeeds, while a `<form>` post keeps the BREACH masking on the hidden field. Both work, from
one configuration.
## The part nobody documents: why the cookie now appears on the GET
That `xor.setCsrfRequestAttributeName(null)` looks like a detail. It is the fix for problem 1.
`CsrfTokenRequestAttributeHandler.handle` wraps the supplier in a `SupplierCsrfToken` and sets
two request attributes. The key for the second one is the configured attribute name &mdash; or,
when that is `null`, `csrfToken.getParameterName()`. Calling `getParameterName()` on a
`SupplierCsrfToken` **dereferences the supplier**. The token is generated, the repository saves
it, and the `Set-Cookie` header goes out.
The eager rendering is a side effect of needing a string for a map key. It is real, it is
load-bearing, and [`docs/output/10-csrf-spa.txt`](output/10-csrf-spa.txt) shows the cookie
arriving on the bootstrap `GET` where `08` showed nothing.
## `spa()` discards what you configured before it
Because the two assignments are unconditional:
```java
.csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is gone
.csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins
```
The `spaorder` profile asks for a cookie named `MY-CSRF` and a header named `X-CSRF-TOKEN`;
[`docs/output/11-spa-ordering.txt`](output/11-spa-ordering.txt) shows `XSRF-TOKEN` coming back
instead. Tracked as [spring-security#18718](https://github.com/spring-projects/spring-security/issues/18718).
## A CSRF rejection does not look like a CSRF rejection
`CsrfFilter` logs, at DEBUG:
```
o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
```
and hands off to its `AccessDeniedHandler`, which calls `sendError(403)`. What the client
receives, in a Basic-authenticated API with no `/error` chain, is **401 with
`WWW-Authenticate: Basic`** &mdash; chapter 5. Every hour spent checking credentials after a
403-that-is-a-401 is spent on the wrong thing.
Turn on `logging.level.org.springframework.security.web.csrf: DEBUG` before anything else.
## Do you need CSRF at all?
If the API authenticates with a `Bearer` token held in memory and never with a cookie, then no:
there is no ambient credential for a third-party page to ride on, and `csrf.disable()` is
correct rather than lazy. If any part of the session lives in a cookie &mdash; including a
`HttpOnly` refresh cookie &mdash; then yes, and `spa()` is the shortest correct configuration.
---
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) &middot; Next: [7. SameSite](07-samesite.md)*

View File

@@ -0,0 +1,107 @@
# 7. SameSite, `Secure`, and the cookie that is never stored
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) &middot; Next: [8. Debugging recipes](08-debugging-recipes.md)*
CORS decides whether the browser lets your JavaScript *read* a response. SameSite decides whether
the browser *sends the cookie* in the first place. Getting CORS perfect and SameSite wrong
produces a request that arrives cleanly and is anonymous.
## What Spring emits by default
From [`docs/output/12-samesite.txt`](output/12-samesite.txt), under `csrf.spa()`:
```
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
```
The session cookie gets `SameSite=Lax` from Boot's
`server.servlet.session.cookie.same-site` default. The CSRF cookie gets **no SameSite attribute
at all**: `CookieCsrfTokenRepository`'s default cookie customizer is, in bytecode, a single
`return`. Nothing is set.
An absent `SameSite` is not "no restriction". Every current browser treats it as `Lax`.
## The two rules that matter
**Storage (RFC 6265bis &sect;5.5).** *"If the cookie's `same-site-flag` is `None` and the
cookie's `secure-only-flag` is false, then abort these steps and ignore the newly created cookie
entirely."*
`SameSite=None` without `Secure` is not a weaker cookie. It is not a cookie. No console warning
is required, no error is raised, and the server has no idea.
**Sending (RFC 6265bis &sect;5.8.3).** `Strict` and `Lax` cookies are not attached to cross-site
requests, except that `Lax` allows top-level safe-method navigations. A `fetch()` from a SPA is a
subresource request, not a top-level navigation, so `Lax` does not help it.
## Running the rules instead of quoting them
`SpecCookieJar` implements those two paragraphs in about sixty lines, and
`/diag/cookie-spec` feeds the application's own `Set-Cookie` headers through it. Over a
trustworthy origin:
| `Set-Cookie` | Stored? | Sent on a cross-site `fetch`? |
|---|---|---|
| `JSESSIONID=s1; HttpOnly; SameSite=Lax` | yes | no |
| `JSESSIONID=s2; HttpOnly; SameSite=None` | **no** | &mdash; |
| `JSESSIONID=s3; Secure; HttpOnly; SameSite=None` | yes | **yes** |
| `XSRF-TOKEN=t1` (no SameSite) | yes | no |
| `XSRF-TOKEN=t2; SameSite=None` | **no** | &mdash; |
| `XSRF-TOKEN=t3; Secure; SameSite=None` | yes | **yes** |
Two of six reach a cross-site fetch, and they are the two carrying both attributes.
## The trap that costs a day: plain `http` during development
`Secure` is only honoured from a *trustworthy* origin. Over plain `http` the attribute is
discarded, which makes `SameSite=None; Secure` collapse into `SameSite=None` with no `Secure`
&mdash; which is then rejected outright. The first block of `12-samesite.txt` is that: **nothing
survives**.
`http://localhost` is treated as trustworthy by current browsers, so it works. `http://127.0.0.1`
and `http://192.168.x.x` are not, and do not. A developer testing a cross-site SPA against a LAN
address will find that the cookie simply never appears, with no message anywhere.
## The configuration
Boot writes exactly what you tell it, and does **not** add `Secure` for you when you ask for
`none`:
```yaml
server:
servlet:
session:
cookie:
same-site: none
secure: true # required. Omit it and the cookie is discarded by the browser.
http-only: true
```
Spring Security's CSRF cookie is separate and needs its own customizer:
```java
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieCustomizer((cookie) -> cookie.sameSite("None").secure(true));
http.csrf(csrf -> csrf.spa().csrfTokenRepository(repository));
```
Order matters &mdash; chapter 6.
## Partitioned cookies (CHIPS)
`Partitioned` requires `Secure` and, in practice, `SameSite=None`. It changes the cookie's
storage key so that a cookie set in a third-party context is scoped to the top-level site that
embedded it. `SpecCookieJar` models the `Secure` requirement; it does not model partitioning,
which is noted here rather than pretended.
If your SPA and API are separate registrable domains, the honest conclusion is:
> **A same-site deployment removes this entire chapter.** Serving the SPA and the API from one
> origin, or from two subdomains of one registrable domain, means `SameSite=Lax` works, `Secure`
> is a hygiene setting rather than a prerequisite, and the preflight disappears. A reverse proxy
> in front of both is usually less work than everything above.
---
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) &middot; Next: [8. Debugging recipes](08-debugging-recipes.md)*

View File

@@ -0,0 +1,77 @@
# 8. Debugging recipes
*Prev: [7. SameSite](07-samesite.md)*
## Turn on the two log categories first
```yaml
logging:
level:
org.springframework.web.cors: DEBUG # DefaultCorsProcessor's Skip:/Reject: lines
org.springframework.security.web.csrf: DEBUG # "Invalid CSRF token found for ..."
```
Almost every question in this subject is answered by one line from one of those two.
## Reproduce the preflight without a browser
```bash
curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
```
That is the whole preflight. `scripts/preflight.sh` wraps it. Note the absence of `-u` and
`-b`: the browser sends no credentials on a preflight, and reproducing it *with* credentials
hides the bug.
## Is `CorsFilter` even in the chain?
```bash
curl -s localhost:8080/diag/chain | python3 -m json.tool
```
If `CorsFilter` is absent, no amount of MVC configuration will help &mdash; chapter 1. The
production equivalent, without a diagnostic endpoint, is the startup log:
```
Will secure any request with filters: DisableEncodeUrlFilter, ..., CorsFilter, ...
```
Grep for `with filters:`.
## Which `CorsConfigurationSource` beans exist, and what are they called?
```bash
curl -s localhost:8080/diag/cors-sources
```
`hasBeanNamedCorsConfigurationSource: false` with a `UrlBasedCorsConfigurationSource` in the list
is the chapter 2 failure exactly.
## Read the status code as a diagnosis
| Symptom | Look at |
|---|---|
| Preflight `401`/`403`, no CORS headers | Chapter 1 &mdash; no `CorsFilter` |
| Preflight `200`, no CORS headers | Chapter 2 &mdash; bean name |
| Preflight `403`, `Invalid CORS request` | Chapter 3 &mdash; read the DEBUG line |
| `401` on a request with valid credentials | Chapter 5 &mdash; the `/error` dispatch |
| `403` on a POST, `GET` is fine | Chapter 6 &mdash; CSRF |
| Cookie visible in DevTools' response, absent from the jar | Chapter 7 &mdash; `SameSite=None` with no `Secure` |
| Every request preflights, latency doubled | Chapter 1 &mdash; no `Access-Control-Max-Age` |
## Check the cookie jar, not the response
DevTools shows the `Set-Cookie` header in the Network tab whether or not the browser stored the
cookie. Application &rarr; Cookies is the jar. A header present in one and absent from the other
is chapter 7, every time.
## Delete the diagnostic endpoints
`DiagController` and `CookieSpecReport` publish your filter chain and bean names. They exist to
make this repository legible. Do not ship them.
---
*Prev: [7. SameSite](07-samesite.md)*

View File

@@ -0,0 +1,58 @@
==============================================================================
docs/output/01-mvc-only.txt
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
Profile: mvconly
==============================================================================
# The security chain. Note what is NOT in it.
$ curl -s localhost:8080/diag/chain
{
"profiles": [
"mvconly"
],
"chains": [
{
"size": 11,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"LogoutFilter",
"BasicAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
]
}
]
}
# CorsConfigurationSource beans in the context.
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": false
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 401
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# The MVC CORS mapping is real - it just never runs, because the request is
# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is
# downstream of the entire filter chain.

View File

@@ -0,0 +1,56 @@
==============================================================================
docs/output/02-mvc-bridge.txt
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
Profile: mvcbridge
==============================================================================
$ curl -s localhost:8080/diag/chain
{
"profiles": [
"mvcbridge"
],
"chains": [
{
"size": 12,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"CorsFilter",
"LogoutFilter",
"BasicAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
]
}
]
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type, x-xsrf-token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1800
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)
# and CsrfFilter (1100), and it short-circuits the preflight before authorization
# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's
# CorsRegistration, not from CorsConfiguration.

View File

@@ -0,0 +1,36 @@
==============================================================================
docs/output/03-security-source.txt
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
Profile: securitysource
==============================================================================
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": true
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type, x-xsrf-token
Access-Control-Allow-Credentials: true
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration
# leaves maxAge null, so every single cross-origin call re-runs the preflight.

View File

@@ -0,0 +1,47 @@
==============================================================================
docs/output/04-three-identical-403s.txt
Origin not allowed, method not allowed, header not allowed. One status, one shape.
Profile: securitysource, CORS_LOG_LEVEL=DEBUG
==============================================================================
# 1. disallowed origin
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 2. disallowed method
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 3. disallowed request header
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# body of a rejected preflight:
Invalid CORS request
# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed

View File

@@ -0,0 +1,39 @@
==============================================================================
docs/output/05-misnamed-bean.txt
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
Profile: misnamed
==============================================================================
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"apiCorsSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": false
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks
# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the
# array is non-empty, so the bean above DID switch the configurer on.
# CorsConfigurer.getCorsConfigurationSource then asks
# containsBeanDefinition("corsConfigurationSource"), which is false, and falls back
# to Spring MVC's registrations - of which there are none.
# CorsFilter returns from every preflight whether or not it found a configuration:
# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }
# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200.

View File

@@ -0,0 +1,44 @@
==============================================================================
docs/output/06-two-sources.txt
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
'won't automatically configure CORS support for you, because it cannot decide which one to
use'. In 7.1.1 it configures it, and the bean NAME decides.
Profile: twosources
==============================================================================
{
"corsConfigurationSourceBeans": {
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
"adminCorsSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": true
}
# the origin allowed by the bean named corsConfigurationSource:
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# the origin allowed by adminCorsSource, which is never consulted:
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://admin.example.com' origin is not allowed

View File

@@ -0,0 +1,41 @@
==============================================================================
docs/output/07-wildcard-credentials.txt
allowedOrigins("*") together with allowCredentials(true). Legal to configure, illegal to
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
Profile: wildcard
==============================================================================
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# and a plain authenticated GET, with correct credentials:
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.DefaultCorsProcessor.handleInternal(DefaultCorsProcessor.java:131) ~[spring-web-7.0.9.jar:7.0.9]
# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,
# the security chain runs again on that dispatch without re-reading the credential,
# and the anonymous second pass is what answers.

View File

@@ -0,0 +1,54 @@
==============================================================================
docs/output/08-csrf-naive.txt
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
tutorial. Three separate things go wrong.
Profile: csrfnaive
==============================================================================
# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# cookie jar after the GET:
(empty - no cookie was set)
# 2. POST with no token.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# cookie jar now:
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 4888debb-2e51-4742-b0e7-262c489825b9
# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every
# SPA snippet on the internet does.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data

View File

@@ -0,0 +1,25 @@
==============================================================================
docs/output/09-error-dispatch.txt
The identical CSRF failure, with one extra filter chain that permits /error.
Profile: csrfnaive,errorpermit
==============================================================================
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
{"timestamp":"<timestamp>","status":403,"error":"Forbidden","path":"/api/data"}
# 403, and a body. Without the /error chain the same request answers 401 with an
# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls
# response.sendError(403), the container re-dispatches to /error, and the security
# chain runs a second time on that dispatch. BasicAuthenticationFilter extends
# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous
# and AuthorizationFilter answers 401 over the top of the 403.

View File

@@ -0,0 +1,47 @@
==============================================================================
docs/output/10-csrf-spa.txt
The same flow under csrf.spa(), added in Spring Security 7.0.
Profile: csrfspa
==============================================================================
# 1. The bootstrap GET now DOES set the cookie.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 2. POST with no token still fails, as it must.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# 3. POST echoing the raw cookie value in X-XSRF-TOKEN.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
{"created":true,"received":"{}","cookies":"XSRF-TOKEN"}
# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,
# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a
# genuinely cross-site SPA still never receives it. See 12.

View File

@@ -0,0 +1,24 @@
==============================================================================
docs/output/11-spa-ordering.txt
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
Profile: spaorder
==============================================================================
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# cookie jar:
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 42573eea-76d6-4bc6-a14b-bff76640461d
# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a
# 'defaults if unset' method. Swap the two calls and MY-CSRF appears.

View File

@@ -0,0 +1,59 @@
==============================================================================
docs/output/12-samesite.txt
The Set-Cookie headers this application emits under four configurations, and what
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them.
==============================================================================
## csrf.spa() defaults, session cookie left at same-site=lax
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
## session cookie set to same-site=none, secure=false
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
## crosssite profile: SameSite=None and Secure on both cookies
Set-Cookie: XSRF-TOKEN=<token>; Path=/; Secure; SameSite=None
Set-Cookie: JSESSIONID=<session>; Path=/; Secure; HttpOnly; SameSite=None
## crosssite profile with -DOMIT_SECURE=true
Set-Cookie: XSRF-TOKEN=<token>; Path=/; SameSite=None
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
## The same headers, run through SpecCookieJar
{
"origin": "not trustworthy (plain http)",
"setCookieOutcomes": {
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5",
"XSRF-TOKEN=t1; Path=/": "stored",
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5"
},
"sentOnSameSiteRequest": "JSESSIONID=s1; XSRF-TOKEN=t1",
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s1; XSRF-TOKEN=t1",
"sentOnCrossSiteFetch": "(no cookies sent)"
}
{
"origin": "trustworthy (https, or http://localhost)",
"setCookieOutcomes": {
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "stored",
"XSRF-TOKEN=t1; Path=/": "stored",
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "stored"
},
"sentOnSameSiteRequest": "JSESSIONID=s3; XSRF-TOKEN=t3",
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s3; XSRF-TOKEN=t3",
"sentOnCrossSiteFetch": "JSESSIONID=s3; XSRF-TOKEN=t3"
}
# Read the second block first: over a trustworthy origin, the only two of the six
# that reach a cross-site fetch are the two carrying Secure AND SameSite=None.
# Then read the first: over plain http, none do -
# which is why a cross-site SPA cannot be developed against http://127.0.0.1.
# (http://localhost itself is treated as trustworthy by current browsers; a bare IP
# is not.)

View File

@@ -0,0 +1,34 @@
==============================================================================
docs/output/13-tests.txt
mvn -B test
==============================================================================
09:33:03.378 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
09:33:03.464 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.904 s -- in CookieCsrfTokenRepository.withHttpOnlyFalse() on its own
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.444 s -- in csrf.spa()
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in SpecCookieJar - the storage and sending rules a browser applies
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.362 s -- in csrfTokenRepository(..) before spa()
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.783 s -- in com.ankurm.cors.CsrfAndCookieTests
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.305 s -- in CORS on the MVC layer only
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.264 s -- in the right type under the wrong bean name
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in a bean named corsConfigurationSource
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.235 s -- in two UrlBasedCorsConfigurationSource beans
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.217 s -- in the same MVC configuration plus .cors(withDefaults())
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.256 s -- in com.ankurm.cors.CorsContractTests
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

55
cors-csrf/pom.xml Normal file
View File

@@ -0,0 +1,55 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Inheriting spring-boot-starter-parent (rather than importing the BOM) so this module
gets Boot's own compiler settings. Every version below is managed by the parent.
This module is a real servlet application because the thing it demonstrates - the
order in which the CORS and CSRF filters run relative to authorization - only exists
inside a servlet container. See docs/01-two-layers.md. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>cors-csrf-samesite</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

25
cors-csrf/scripts/preflight.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Send one CORS preflight and print the status line and the headers that decide the outcome.
#
# ./scripts/preflight.sh https://spa.example.com POST /api/data
#
# A preflight is not a special kind of request. It is an OPTIONS carrying Origin and
# Access-Control-Request-Method, and it is sent WITHOUT cookies or an Authorization header -
# which is precisely why a chain that requires authentication rejects it.
set -eu
ORIGIN="${1:-https://spa.example.com}"
METHOD="${2:-POST}"
PATH_="${3:-/api/data}"
echo "\$ curl -s -i -X OPTIONS http://localhost:8080$PATH_ \\"
echo " -H 'Origin: $ORIGIN' \\"
echo " -H 'Access-Control-Request-Method: $METHOD' \\"
echo " -H 'Access-Control-Request-Headers: content-type,x-xsrf-token'"
echo
curl -s -i -X OPTIONS "http://localhost:8080$PATH_" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: $METHOD" \
-H "Access-Control-Request-Headers: content-type,x-xsrf-token" \
| sed -n '1,/^\r$/p' \
| grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):' \
| sed 's/\r$//'

355
cors-csrf/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
# hand-written; if a number in the article disagrees with a file here, the file is right.
#
# ./scripts/run-all.sh
#
# Takes a few minutes: the application restarts once per scenario, because the scenarios are
# Spring profiles and profiles are fixed at context startup.
set -eu
cd "$(dirname "$0")/.."
OUT=docs/output
mkdir -p "$OUT"
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
# Strip run-to-run noise so committed files diff cleanly.
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1<token>/g' \
-e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1<token>/g' \
-e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \
-e 's/PID [0-9]+/PID <pid>/g' \
-e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in <n> seconds/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e 's/\[nio-8080-exec-[0-9]+\]/[nio-8080-exec-N]/g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
headers() { # headers <curl args...>
curl -s -i "$@" | sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding|content-type|content-language):'
}
logs_since() { # logs_since <marker-line-count> <grep-pattern>
sed -n "$(( $1 + 1 )),\$p" /tmp/cors-csrf-app.log | grep -E "$2" || true
}
########################################################################################
# 1. CORS on the MVC layer only - the preflight never reaches the servlet
########################################################################################
./scripts/run.sh mvconly > /dev/null
{
hdr "docs/output/01-mvc-only.txt
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
Profile: mvconly"
echo "# The security chain. Note what is NOT in it."
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
echo "# CorsConfigurationSource beans in the context."
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# The MVC CORS mapping is real - it just never runs, because the request is"
echo "# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is"
echo "# downstream of the entire filter chain."
} | scrub > "$OUT/01-mvc-only.txt"
########################################################################################
# 2. The same MVC configuration, with .cors(withDefaults()) added
########################################################################################
./scripts/run.sh mvcbridge > /dev/null
{
hdr "docs/output/02-mvc-bridge.txt
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
Profile: mvcbridge"
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)"
echo "# and CsrfFilter (1100), and it short-circuits the preflight before authorization"
echo "# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's"
echo "# CorsRegistration, not from CorsConfiguration."
} | scrub > "$OUT/02-mvc-bridge.txt"
########################################################################################
# 3. A CorsConfigurationSource bean, correctly named
########################################################################################
./scripts/run.sh securitysource > /dev/null
{
hdr "docs/output/03-security-source.txt
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
Profile: securitysource"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration"
echo "# leaves maxAge null, so every single cross-origin call re-runs the preflight."
} | scrub > "$OUT/03-security-source.txt"
########################################################################################
# 4. Three rejections that look identical from the client
########################################################################################
{
hdr "docs/output/04-three-identical-403s.txt
Origin not allowed, method not allowed, header not allowed. One status, one shape.
Profile: securitysource, CORS_LOG_LEVEL=DEBUG"
} > "$OUT/04-three-identical-403s.txt"
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh securitysource > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
echo "# 1. disallowed origin"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo "# 2. disallowed method"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: DELETE'
echo "# 3. disallowed request header"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization'
echo "# body of a rejected preflight:"
curl -s -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo
echo
echo "# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:"
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub >> "$OUT/04-three-identical-403s.txt"
########################################################################################
# 5. The bean-name trap: right type, wrong name
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh misnamed > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/05-misnamed-bean.txt
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
Profile: misnamed"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
echo
echo "# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks"
echo "# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the"
echo "# array is non-empty, so the bean above DID switch the configurer on."
echo "# CorsConfigurer.getCorsConfigurationSource then asks"
echo "# containsBeanDefinition(\"corsConfigurationSource\"), which is false, and falls back"
echo "# to Spring MVC's registrations - of which there are none."
echo "# CorsFilter returns from every preflight whether or not it found a configuration:"
echo "# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }"
echo "# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200."
} | scrub > "$OUT/05-misnamed-bean.txt"
########################################################################################
# 6. Two sources - the documentation says CORS is not configured. It is.
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh twosources > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/06-two-sources.txt
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
'won't automatically configure CORS support for you, because it cannot decide which one to
use'. In 7.1.1 it configures it, and the bean NAME decides.
Profile: twosources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
echo "# the origin allowed by the bean named corsConfigurationSource:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
echo "# the origin allowed by adminCorsSource, which is never consulted:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://admin.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub > "$OUT/06-two-sources.txt"
########################################################################################
# 7. allowedOrigins("*") with allowCredentials(true)
########################################################################################
./scripts/run.sh wildcard > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/07-wildcard-credentials.txt
allowedOrigins(\"*\") together with allowCredentials(true). Legal to configure, illegal to
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
Profile: wildcard"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST'
echo "# and a plain authenticated GET, with correct credentials:"
headers -u alice:password localhost:8080/api/data -H 'Origin: https://spa.example.com'
echo
sleep 1
logs_since "$MARK" 'IllegalArgumentException: When allowCredentials|at org.springframework.web.cors' | head -5
echo
echo "# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,"
echo "# the security chain runs again on that dispatch without re-reading the credential,"
echo "# and the anonymous second pass is what answers."
} | scrub > "$OUT/07-wildcard-credentials.txt"
########################################################################################
# 8. CSRF for a SPA: the pre-6.0 recipe
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/08-csrf-naive.txt
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
tutorial. Three separate things go wrong.
Profile: csrfnaive"
echo "# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here."
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar after the GET:"
{ grep -v '^#' "$J" | sed 's/\t/ | /g' | grep . || echo "(empty - no cookie was set)"; }
echo
echo "# 2. POST with no token."
headers -b "$J" -c "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo "# cookie jar now:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every"
echo "# SPA snippet on the internet does."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
sleep 1
logs_since "$MARK" 'CsrfFilter'
} | scrub > "$OUT/08-csrf-naive.txt"
########################################################################################
# 9. The same failure with /error permitted - the status the SPA never sees
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive,errorpermit > /dev/null
{
hdr "docs/output/09-error-dispatch.txt
The identical CSRF failure, with one extra filter chain that permits /error.
Profile: csrfnaive,errorpermit"
headers -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
curl -s -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# 403, and a body. Without the /error chain the same request answers 401 with an"
echo "# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls"
echo "# response.sendError(403), the container re-dispatches to /error, and the security"
echo "# chain runs a second time on that dispatch. BasicAuthenticationFilter extends"
echo "# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous"
echo "# and AuthorizationFilter answers 401 over the top of the 403."
} | scrub > "$OUT/09-error-dispatch.txt"
########################################################################################
# 10. csrf.spa()
########################################################################################
./scripts/run.sh csrfspa > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/10-csrf-spa.txt
The same flow under csrf.spa(), added in Spring Security 7.0.
Profile: csrfspa"
echo "# 1. The bootstrap GET now DOES set the cookie."
headers -c "$J" -u alice:password localhost:8080/api/data
echo
echo "# 2. POST with no token still fails, as it must."
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo "# 3. POST echoing the raw cookie value in X-XSRF-TOKEN."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
curl -s -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,"
echo "# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a"
echo "# genuinely cross-site SPA still never receives it. See 12."
} | scrub > "$OUT/10-csrf-spa.txt"
########################################################################################
# 11. spa() discards a repository configured before it
########################################################################################
./scripts/run.sh spaorder > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/11-spa-ordering.txt
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
Profile: spaorder"
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a"
echo "# 'defaults if unset' method. Swap the two calls and MY-CSRF appears."
} | scrub > "$OUT/11-spa-ordering.txt"
########################################################################################
# 12. SameSite - what is actually written, and what a browser does with it
########################################################################################
{
hdr "docs/output/12-samesite.txt
The Set-Cookie headers this application emits under four configurations, and what
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them."
} > "$OUT/12-samesite.txt"
emit() { # emit <label> <env...>
local label="$1"; shift
env "$@" ./scripts/run.sh "$PROFILE" > /dev/null
echo "## $label"
curl -s -D- -o /dev/null -u alice:password localhost:8080/api/data | grep -i '^set-cookie' | sed 's/\r$//'
curl -s -D- -o /dev/null localhost:8080/api/data | grep -i '^set-cookie' | grep -i jsessionid | sed 's/\r$//' || true
echo
}
{
PROFILE=csrfspa
emit "csrf.spa() defaults, session cookie left at same-site=lax" SESSION_SAME_SITE=lax SESSION_SECURE=false
emit "session cookie set to same-site=none, secure=false" SESSION_SAME_SITE=none SESSION_SECURE=false
PROFILE=crosssite
emit "crosssite profile: SameSite=None and Secure on both cookies" SESSION_SAME_SITE=none SESSION_SECURE=true
emit "crosssite profile with -DOMIT_SECURE=true" JVM_ARGS=-DOMIT_SECURE=true SESSION_SAME_SITE=none SESSION_SECURE=false
} | scrub >> "$OUT/12-samesite.txt"
./scripts/run.sh csrfspa > /dev/null
{
echo "## The same headers, run through SpecCookieJar"
python3 - <<'PY'
import urllib.parse, urllib.request, json
headers = [
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None",
"XSRF-TOKEN=t1; Path=/",
"XSRF-TOKEN=t2; Path=/; SameSite=None",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None",
]
query = "&".join("h=" + urllib.parse.quote(h) for h in headers)
for secure in ("false", "true"):
url = f"http://localhost:8080/diag/cookie-spec?{query}&secure={secure}"
print(json.dumps(json.load(urllib.request.urlopen(url)), indent=2))
print()
PY
echo "# Read the second block first: over a trustworthy origin, the only two of the six"
echo "# that reach a cross-site fetch are the two carrying Secure AND SameSite=None."
echo "# Then read the first: over plain http, none do -"
echo "# which is why a cross-site SPA cannot be developed against http://127.0.0.1."
echo "# (http://localhost itself is treated as trustworthy by current browsers; a bare IP"
echo "# is not.)"
} | scrub >> "$OUT/12-samesite.txt"
########################################################################################
# 13. The assertions
########################################################################################
{
hdr "docs/output/13-tests.txt
mvn -B test"
(cd . && mvn -B test 2>&1) | grep -E 'Tests run|ERROR|BUILD|CorsCsrf' | head -30
} | scrub > "$OUT/13-tests.txt"
./scripts/stop.sh
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

37
cors-csrf/scripts/run.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Start the demo application under a given profile and wait until it answers.
#
# ./scripts/run.sh securitysource
# ./scripts/run.sh mvconly
# SESSION_SAME_SITE=none SESSION_SECURE=false ./scripts/run.sh crosssite
# JVM_ARGS=-DOMIT_SECURE=true ./scripts/run.sh crosssite
# CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive
#
# Two profiles are expected to FAIL to start - `misnamed` and `preflightclash`. That is what
# they demonstrate, so this script returns 1 for them and the transcript keeps the exception.
set -eu
cd "$(dirname "$0")/.."
PROFILE="${1:-securitysource}"
LOG="${LOG:-/tmp/cors-csrf-app.log}"
./scripts/stop.sh
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILE" \
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-}" \
> "$LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
if curl -sf -o /dev/null http://localhost:8080/diag/chain 2>/dev/null; then
echo "started with profile: $PROFILE (log: $LOG)"
exit 0
fi
if grep -q 'APPLICATION FAILED TO START' "$LOG" 2>/dev/null; then
echo "application failed to start under profile: $PROFILE (log: $LOG)" >&2
exit 1
fi
sleep 2
done
echo "application did not become ready; see $LOG" >&2
exit 1

11
cors-csrf/scripts/stop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
# process. Match the MAIN CLASS, never 'spring-boot' - that pattern also matches the shell
# command line that started the application, so pkill -f 'spring-boot' kills your own shell.
set -eu
for pid in $(ps -eo pid,cmd | grep '[C]orsCsrfApplication' | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,24 @@
package com.ankurm.cors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for
* <a href="https://ankurm.com/spring-boot-4-cors-csrf-samesite/">CORS, CSRF and SameSite in
* Spring Boot 4</a>.
*
* <p>Every scenario in the article is a Spring profile on this one application. Start it with
* {@code ./scripts/run.sh <profile>} and drive it with {@code curl}; nothing here needs a
* browser, because a preflight request is just an {@code OPTIONS} with two headers.
*
* <p>See <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a> for why the same
* CORS configuration behaves differently depending on which layer you put it on.
*/
@SpringBootApplication
public class CorsCsrfApplication {
public static void main(String[] args) {
SpringApplication.run(CorsCsrfApplication.class, args);
}
}

View File

@@ -0,0 +1,58 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfnaive}: {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} on its own.
*
* <p>This is the recipe in every SPA tutorial written before Spring Security 6, and since 6.0
* it produces a 403 on the first POST. The default {@code CsrfTokenRequestHandler} is
* {@code XorCsrfTokenRequestAttributeHandler}: the value written into the {@code XSRF-TOKEN}
* cookie is XOR-masked against a per-response random, so the raw cookie value the SPA reads and
* echoes back in {@code X-XSRF-TOKEN} is not the value the server compares against.
*
* <p>Two more things go wrong here and both are visible in the transcripts:
* the token is <em>deferred</em>, so a plain {@code GET} does not set the cookie at all unless
* something dereferences the token; and the cookie carries no {@code SameSite} attribute, which
* browsers treat as {@code Lax}, so a cross-site SPA never receives it. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a>.
*/
@Configuration
@Profile("csrfnaive")
public class CsrfNaiveConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.build();
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfspa}: {@code csrf.spa()}, added in Spring Security 7.0.
*
* <p>Disassembling {@code CsrfConfigurer.spa()} shows exactly two assignments: the repository
* becomes {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} and the request handler becomes
* the package-private {@code SpaCsrfTokenRequestHandler}. That handler holds two delegates
* &mdash; a plain {@code CsrfTokenRequestAttributeHandler} with
* {@code setCsrfRequestAttributeName(null)}, and an {@code XorCsrfTokenRequestAttributeHandler}
* &mdash; writes with the XOR one and, on resolve, uses the plain one whenever the request
* carries the header. Header-carrying SPA requests compare raw values; form posts keep the
* BREACH masking.
*
* <p>Because {@code spa()} assigns both fields unconditionally, calling
* {@code csrfTokenRepository(..)} before it is silently discarded. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a> and
* {@link CsrfSpaOrderConfig}.
*/
@Configuration
@Profile("csrfspa")
public class CsrfSpaConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa())
.build();
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code crosssite}: what {@code spa()} still does not do for a genuinely cross-site
* SPA, and the two cookie attributes you have to add yourself.
*
* <p>{@code spa()} leaves the {@code XSRF-TOKEN} cookie with no {@code SameSite} attribute
* &mdash; {@code CookieCsrfTokenRepository}'s default cookie customizer is an empty lambda,
* confirmed in the bytecode. A cookie with no {@code SameSite} is treated as {@code Lax}, so
* it is not sent on a cross-site {@code fetch}. Setting {@code SameSite=None} without
* {@code Secure} does not help either: the browser rejects the whole {@code Set-Cookie}
* (RFC 6265bis &sect;5.5). Both attributes are required, together.
*
* <p>The same applies to the session cookie, which is Boot's concern rather than Spring
* Security's &mdash; see {@code application.yml} and
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*
* <p>Run with {@code -DOMIT_SECURE=true} to emit {@code SameSite=None} <em>without</em>
* {@code Secure} and watch {@code SpecCookieJar} reject it.
*/
@Configuration
@Profile("crosssite")
public class CsrfSpaCrossSiteConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
boolean omitSecure = Boolean.getBoolean("OMIT_SECURE");
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieCustomizer((cookie) -> {
cookie.sameSite("None");
// The point of the flag: SameSite=None and Secure are a pair. Emitting one
// without the other produces a Set-Cookie that every browser discards.
cookie.secure(!omitSecure);
});
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa().csrfTokenRepository(repository))
.build();
}
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code spaorder}: {@code csrfTokenRepository(..)} placed <em>before</em>
* {@code spa()}, which throws it away.
*
* <p>{@code spa()} is not a "defaults if unset" method. Its two statements are unconditional
* field assignments, so the custom cookie name below never reaches the running application and
* the SPA gets a 403 while looking at a configuration that appears to say otherwise. Swap the
* two calls and it works. {@link CsrfSpaCrossSiteConfig} relies on that ordering.
*
* <p>This is <a href="https://github.com/spring-projects/spring-security/issues/18718">
* spring-security#18718</a>, and the surprising part is that the fix is a reordering rather
* than a different API.
*/
@Configuration
@Profile("spaorder")
public class CsrfSpaOrderConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN", "X-CSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieName("MY-CSRF");
repository.setHeaderName("X-CSRF-TOKEN");
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// Deliberately the wrong way round.
.csrf((csrf) -> csrf.csrfTokenRepository(repository).spa())
.build();
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Add-on profile {@code errorpermit}: a first filter chain that matches {@code /error} and
* permits everything, so the container's error dispatch stops rewriting the status code.
*
* <p>Without it, a rejection raised inside the chain calls {@code response.sendError(403, ..)},
* Tomcat re-dispatches the request to {@code /error}, and the security chain runs a second time
* on that dispatch. {@code BasicAuthenticationFilter} extends {@code OncePerRequestFilter} and
* skips error dispatches, so the credential is never re-read and the second pass is anonymous.
* {@code AuthorizationFilter} then denies it and the client receives <b>401</b> &mdash; the
* original 403 is gone.
*
* <p>Combine it with any other profile: {@code ./scripts/run.sh csrfnaive,errorpermit}.
* See <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>, and
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
* Filter Chain Explained</a> for the mechanism in full.
*/
@Configuration
@Profile("errorpermit")
public class ErrorDispatchConfig {
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code misnamed}: the same bean as {@link SecuritySourceConfig}, under a different
* name. This is the gap between the two lookups.
*
* <p>{@code HttpSecurityConfiguration.applyCorsIfAvailable} asks
* {@code getBeanNamesForType(UrlBasedCorsConfigurationSource.class)} and enables the CORS
* configurer if the array is non-empty &mdash; so the bean below <em>does</em> switch CORS on.
* {@code CorsConfigurer.getCorsConfigurationSource} then asks
* {@code containsBeanDefinition("corsConfigurationSource")}, which is false, and falls through
* to Spring MVC's registrations. There are none, so startup fails with
* {@code NoSuchBeanDefinitionException}.
*
* <p>The message it prints names three fixes and does not mention the one that applies:
* rename your bean.
*/
@Configuration
@Profile("misnamed")
public class MisnamedSourceConfig {
@Bean
UrlBasedCorsConfigurationSource apiCorsSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvcbridge}: the same MVC CORS mapping as {@code mvconly}, plus one line.
*
* <p>{@code .cors(Customizer.withDefaults())} makes {@code CorsConfigurer} run. With no bean
* named {@code corsConfigurationSource} in the context it falls back to
* {@code CorsConfigurer.MvcCorsFilter.getMvcCorsConfigurationSource(..)}, which reads the
* registrations made by {@link MvcCorsConfig}. The resulting {@code CorsFilter} goes into the
* chain at order 1000 &mdash; before {@code CsrfFilter} (1100) and a long way before
* {@code AuthorizationFilter} (4200) &mdash; and short-circuits the preflight.
*
* <p>So MVC CORS configuration <em>can</em> drive the security layer. It just does not do so by
* itself.
*/
@Configuration
@Profile("mvcbridge")
public class MvcBridgeSecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,34 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* CORS configured on the MVC layer &mdash; the first thing everybody tries, and the thing that
* does not fix a preflight rejection on its own.
*
* <p>This registers a {@code CorsConfiguration} with Spring MVC's
* {@code AbstractHandlerMapping}. It is consulted inside {@code DispatcherServlet}, which is
* downstream of the entire security filter chain. If the preflight never reaches the servlet,
* this configuration never runs. See
* <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a>.
*
* <p>Active under the {@code mvconly} and {@code mvcbridge} profiles. The two profiles share
* this file and differ only in whether the security chain enables CORS &mdash; which is the
* whole point.
*/
@Configuration
@Profile({ "mvconly", "mvcbridge" })
public class MvcCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://spa.example.com")
.allowedMethods("GET", "POST")
.allowedHeaders("Content-Type", "X-XSRF-TOKEN")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvconly}: MVC has a CORS mapping, the security chain does not.
*
* <p>There is no {@code UrlBasedCorsConfigurationSource} bean here, so Spring Security's
* {@code HttpSecurityConfiguration.applyCorsIfAvailable} does not switch CORS on, so no
* {@code CorsFilter} enters the chain. The preflight {@code OPTIONS} therefore travels the
* whole chain and is judged by {@code AuthorizationFilter} at order 4200, which sees an
* anonymous request and rejects it. The browser reports a CORS error; the server log shows an
* authentication failure. Those are the same event.
*
* <p>Reproduce: {@code ./scripts/scenario-cors.sh mvconly}.
*/
@Configuration
@Profile("mvconly")
public class MvcOnlySecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code securitysource}: the configuration that actually works, and the name that
* makes it work.
*
* <p>The bean method is called {@code corsConfigurationSource} on purpose. That literal string
* appears in {@code CorsConfigurer.getCorsConfigurationSource(..)} as a
* {@code containsBeanDefinition} check. Rename this method and the behaviour changes &mdash;
* see {@link MisnamedSourceConfig}.
*
* <p>Note also that {@code .cors(..)} is never called below. It does not need to be: with a
* {@code UrlBasedCorsConfigurationSource} bean present, {@code HttpSecurityConfiguration}
* applies the CORS configurer for you.
*/
@Configuration
@Profile("securitysource")
public class SecuritySourceConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,65 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code twosources}: two {@code UrlBasedCorsConfigurationSource} beans, one of which
* carries the magic name.
*
* <p>The reference documentation says that with more than one such bean "Spring Security won't
* automatically configure CORS support for you, because it cannot decide which one to use".
* In 7.1.1 that is not what the bytecode does: {@code applyCorsIfAvailable} tests
* {@code getBeanNamesForType(..).length} with {@code ifle}, i.e. "greater than zero", not
* "exactly one". CORS is applied, and the bean named {@code corsConfigurationSource} wins.
* {@code adminCorsSource} is never consulted on this chain.
*
* <p>Verified by {@code /diag/cors-sources} plus the preflight transcripts in
* <a href="../../../../docs/output/">docs/output/</a>.
*/
@Configuration
@Profile("twosources")
public class TwoSourcesConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
return sourceFor("https://spa.example.com");
}
@Bean
UrlBasedCorsConfigurationSource adminCorsSource() {
return sourceFor("https://admin.example.com");
}
private static UrlBasedCorsConfigurationSource sourceFor(String origin) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(origin));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,18 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/** One user, {@code alice}/{@code password}, shared by every profile. */
@Configuration
public class Users {
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build());
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code wildcard}: {@code allowedOrigins("*")} together with
* {@code allowCredentials(true)}.
*
* <p>This is the combination every "just make CORS work" answer suggests, and it is illegal
* under the Fetch standard: a response may not carry both
* {@code Access-Control-Allow-Origin: *} and {@code Access-Control-Allow-Credentials: true}.
* Spring does not reject it at startup. It rejects it on the first preflight, from inside
* {@code CorsConfiguration.checkOrigin}, which means the failure surfaces as a 500 on an
* {@code OPTIONS} request rather than as a configuration error.
*
* <p>The fix is {@code setAllowedOriginPatterns(..)}, which echoes the request origin back
* instead of a literal asterisk.
*/
@Configuration
@Profile("wildcard")
public class WildcardCredentialsConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.cors.spec;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Runs the {@code Set-Cookie} headers this application actually emits through
* {@link SpecCookieJar} and reports what a browser would do with them.
*
* <p>Pass real headers with repeated {@code ?h=} parameters &mdash;
* {@code scripts/scenario-samesite.sh} collects them from a live response and feeds them back
* in, so the input is never typed by hand.
*/
@RestController
public class CookieSpecReport {
@GetMapping("/diag/cookie-spec")
public Map<String, Object> report(@RequestParam("h") List<String> headers,
@RequestParam(name = "secure", defaultValue = "false") boolean secureContext) {
SpecCookieJar jar = new SpecCookieJar();
Map<String, String> outcomes = new LinkedHashMap<>();
for (String header : headers) {
String rejection = jar.setCookie(header, secureContext);
outcomes.put(header, (rejection == null) ? "stored" : rejection);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("origin", secureContext ? "trustworthy (https, or http://localhost)" : "not trustworthy (plain http)");
out.put("setCookieOutcomes", outcomes);
out.put("sentOnSameSiteRequest",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)));
out.put("sentOnCrossSiteTopLevelNavigation",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true)));
out.put("sentOnCrossSiteFetch",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)));
return out;
}
private static String orNone(String header) {
return header.isEmpty() ? "(no cookies sent)" : header;
}
}

View File

@@ -0,0 +1,139 @@
package com.ankurm.cors.spec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* A deliberately small cookie jar that applies the storage and sending rules a browser applies,
* so that "the browser drops this cookie" becomes something you can run instead of something
* you have to believe.
*
* <p>It is a model of two paragraphs of
* <a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis">RFC 6265bis</a>,
* not a browser:
*
* <ul>
* <li><b>&sect;5.5 storage.</b> "If the cookie's {@code same-site-flag} is {@code None} and the
* cookie's {@code secure-only-flag} is false, then abort these steps and ignore the newly
* created cookie entirely." A {@code Set-Cookie} with {@code SameSite=None} and no
* {@code Secure} is not stored, and there is no error anywhere &mdash; the cookie simply never
* exists.</li>
* <li><b>&sect;5.8.3 sending.</b> A cookie whose {@code same-site-flag} is {@code Strict} or
* {@code Lax} is not attached to a cross-site request; {@code Lax} makes an exception for
* top-level safe-method navigations, which a {@code fetch()} from a SPA is not. A cookie with
* no {@code SameSite} attribute is treated as {@code Lax}.</li>
* </ul>
*
* <p>Feeding the real {@code Set-Cookie} headers the application emits through this jar is what
* turns the SameSite section of the article into evidence. See
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*/
public final class SpecCookieJar {
/** How the request was initiated, which is what decides the same-site check. */
public enum Context {
/** Same registrable domain as the cookie's origin. */
SAME_SITE,
/** A top-level navigation (clicking a link, a form GET) from another site. */
CROSS_SITE_TOP_LEVEL_NAVIGATION,
/** An XHR/fetch/subresource load from another site. This is the SPA case. */
CROSS_SITE_SUBRESOURCE
}
/** A stored cookie, after the attributes have been parsed. */
public record StoredCookie(String name, String value, String sameSite, boolean secure,
boolean httpOnly, boolean partitioned) {
}
private final Map<String, StoredCookie> jar = new LinkedHashMap<>();
private final List<String> rejections = new ArrayList<>();
/**
* Apply one {@code Set-Cookie} header. Returns the reason it was rejected, or {@code null}
* when it was stored.
*/
public String setCookie(String header, boolean secureContext) {
String[] parts = header.split(";");
String[] nv = parts[0].split("=", 2);
String name = nv[0].trim();
String value = nv.length > 1 ? nv[1].trim() : "";
String sameSite = null;
boolean secure = false;
boolean httpOnly = false;
boolean partitioned = false;
for (int i = 1; i < parts.length; i++) {
String attribute = parts[i].trim();
String lower = attribute.toLowerCase(Locale.ROOT);
if (lower.startsWith("samesite=")) {
sameSite = attribute.substring("samesite=".length()).trim();
}
else if (lower.equals("secure")) {
secure = true;
}
else if (lower.equals("httponly")) {
httpOnly = true;
}
else if (lower.equals("partitioned")) {
partitioned = true;
}
}
// RFC 6265bis 5.5: the Secure attribute is only honoured from a trustworthy origin.
// HTTPS qualifies; so does http://localhost in every current browser, which is why
// this parameter is called secureContext rather than https.
boolean secureHonoured = secure && secureContext;
// RFC 6265bis 5.5: SameSite=None without an effective Secure is ignored entirely.
if ("None".equalsIgnoreCase(sameSite) && !secureHonoured) {
String reason = "REJECTED " + name + ": SameSite=None " + (secure
? "with Secure, but the origin is not trustworthy so Secure is not honoured"
: "and no Secure attribute") + " - RFC 6265bis 5.5";
this.rejections.add(reason);
return reason;
}
secure = secureHonoured;
// Partitioned (CHIPS) requires Secure as well.
if (partitioned && !secure) {
String reason = "REJECTED " + name + ": Partitioned without Secure";
this.rejections.add(reason);
return reason;
}
this.jar.put(name, new StoredCookie(name, value, sameSite, secure, httpOnly, partitioned));
return null;
}
/** The {@code Cookie} header a browser would send for a request made in this context. */
public String cookieHeaderFor(Context context, boolean safeMethod) {
StringBuilder sb = new StringBuilder();
for (StoredCookie cookie : this.jar.values()) {
if (!willSend(cookie, context, safeMethod)) {
continue;
}
sb.append(sb.isEmpty() ? "" : "; ").append(cookie.name()).append('=').append(cookie.value());
}
return sb.toString();
}
private static boolean willSend(StoredCookie cookie, Context context, boolean safeMethod) {
// No SameSite attribute means Lax, which is where the modern default bites.
String effective = (cookie.sameSite() == null) ? "Lax" : cookie.sameSite();
return switch (context) {
case SAME_SITE -> true;
case CROSS_SITE_TOP_LEVEL_NAVIGATION ->
effective.equalsIgnoreCase("None") || (effective.equalsIgnoreCase("Lax") && safeMethod);
case CROSS_SITE_SUBRESOURCE -> effective.equalsIgnoreCase("None");
};
}
public List<String> rejections() {
return List.copyOf(this.rejections);
}
public Map<String, StoredCookie> stored() {
return Map.copyOf(this.jar);
}
}

View File

@@ -0,0 +1,67 @@
package com.ankurm.cors.web;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* The API the imaginary single-page application talks to.
*
* <p>{@code /api/boom} exists to demonstrate one specific failure: a request that passed the
* CORS check and then threw. Tomcat re-dispatches to {@code /error}, and what the browser
* reports is not the 500 &mdash; see
* <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>.
*/
@RestController
public class ApiController {
@GetMapping("/api/data")
public Map<String, Object> data(HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("method", request.getMethod());
body.put("origin", String.valueOf(request.getHeader("Origin")));
body.put("cookies", cookieNames(request));
return body;
}
@PostMapping("/api/data")
public Map<String, Object> create(HttpServletRequest request,
@RequestBody(required = false) String body) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("created", true);
out.put("received", body == null ? "" : body);
out.put("cookies", cookieNames(request));
return out;
}
@GetMapping("/api/whoami")
public Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("name", authentication == null ? "(none)" : authentication.getName());
out.put("sessionId", request.getSession(false) == null ? "(no session)" : "present");
return out;
}
@GetMapping("/api/boom")
public Map<String, Object> boom() {
throw new IllegalStateException("deliberate failure, so you can watch the CORS headers vanish");
}
private static String cookieNames(HttpServletRequest request) {
if (request.getCookies() == null) {
return "(none)";
}
StringBuilder sb = new StringBuilder();
for (var c : request.getCookies()) {
sb.append(sb.isEmpty() ? "" : ",").append(c.getName());
}
return sb.toString();
}
}

View File

@@ -0,0 +1,73 @@
package com.ankurm.cors.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Prints runtime state that is otherwise invisible: which filters are actually in the chain,
* and which {@code CorsConfigurationSource} beans the context holds and what they are named.
*
* <p>The bean-name question matters more than it looks. {@code CorsConfigurer} resolves the
* source by the bean <em>name</em> {@code corsConfigurationSource}, while the code that decides
* whether to switch CORS on at all looks it up by <em>type</em>. See
* <a href="../../../../docs/02-who-resolves-the-source.md">docs/02-who-resolves-the-source.md</a>.
*
* <p>Delete this controller before shipping anything.
*/
@RestController
public class DiagController {
private final FilterChainProxy proxy;
private final Map<String, CorsConfigurationSource> sources;
private final Environment environment;
public DiagController(@Qualifier("springSecurityFilterChain") Filter springSecurityFilterChain,
Map<String, CorsConfigurationSource> sources, Environment environment) {
this.proxy = (FilterChainProxy) springSecurityFilterChain;
this.sources = sources;
this.environment = environment;
}
@GetMapping("/diag/chain")
public Map<String, Object> chain() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("profiles", List.of(this.environment.getActiveProfiles()));
List<Map<String, Object>> chains = new ArrayList<>();
for (SecurityFilterChain chain : this.proxy.getFilterChains()) {
Map<String, Object> one = new LinkedHashMap<>();
one.put("size", chain.getFilters().size());
List<String> names = new ArrayList<>();
for (Filter filter : chain.getFilters()) {
names.add(filter.getClass().getSimpleName());
}
one.put("filters", names);
chains.add(one);
}
out.put("chains", chains);
return out;
}
@GetMapping("/diag/cors-sources")
public Map<String, Object> corsSources() {
Map<String, Object> out = new LinkedHashMap<>();
Map<String, String> byName = new LinkedHashMap<>();
this.sources.forEach((name, source) -> byName.put(name, source.getClass().getSimpleName()));
out.put("corsConfigurationSourceBeans", byName);
out.put("hasBeanNamedCorsConfigurationSource", this.sources.containsKey("corsConfigurationSource"));
return out;
}
}

View File

@@ -0,0 +1,25 @@
# Every scenario in the article is a profile. The default is `securitysource`, the
# configuration that works, so that a bare `mvn spring-boot:run` starts something sane.
spring:
application:
name: cors-csrf-samesite
profiles:
default: securitysource
server:
port: 8080
servlet:
session:
cookie:
# Boot writes exactly what you put here. It does NOT add `Secure` for you when
# same-site is `none`, which is the whole subject of docs/07-samesite.md. Flip
# SESSION_SAME_SITE / SESSION_SECURE from scripts/scenario-samesite.sh and read the
# emitted Set-Cookie header back.
same-site: ${SESSION_SAME_SITE:lax}
secure: ${SESSION_SECURE:false}
http-only: true
logging:
level:
org.springframework.security.web.csrf: ${CSRF_LOG_LEVEL:INFO}
org.springframework.web.cors: ${CORS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,221 @@
package com.ankurm.cors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Assertions that pin the <em>contract</em>: which status code a browser sees, and whether the
* response carries the header that decides whether the browser will show it. They are written
* against the same profiles the transcripts in {@code docs/output/} use, so a change in Spring
* Security that alters any of this breaks a test rather than a paragraph.
*
* <p>These run through {@code MockMvc} with {@code springSecurityFilterChain} applied. That
* exercises the filter chain, which is the layer under test; it does <em>not</em> exercise the
* container's error dispatch, which is why the {@code /error} finding is verified by the
* transcripts in {@code docs/output/09-error-dispatch.txt} rather than here. Noted rather than
* hidden: it is a real limit of this test setup.
*/
class CorsContractTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("mvconly")
@DisplayName("CORS on the MVC layer only")
class MvcOnly {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is rejected by authorization, and carries no CORS header")
void preflightIsRejected() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(401)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
@Test
@DisplayName("no CorsFilter is in the chain")
void noCorsFilter() {
assertThat(chainClassNames(this.context)).doesNotContain("CorsFilter");
}
}
@Nested
@SpringBootTest
@ActiveProfiles("mvcbridge")
@DisplayName("the same MVC configuration plus .cors(withDefaults())")
class MvcBridge {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is answered by CorsFilter with the MVC configuration")
void preflightSucceeds() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("MVC's CorsRegistration supplies a max-age default that CorsConfiguration does not")
void mvcSuppliesMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800");
}
@Test
@DisplayName("CorsFilter sits between HeaderWriterFilter and LogoutFilter")
void corsFilterPosition() {
var names = chainClassNames(this.context);
assertThat(names).contains("CorsFilter");
assertThat(names.indexOf("CorsFilter")).isGreaterThan(names.indexOf("HeaderWriterFilter"));
assertThat(names.indexOf("CorsFilter")).isLessThan(names.indexOf("AuthorizationFilter"));
}
}
@Nested
@SpringBootTest
@ActiveProfiles("securitysource")
@DisplayName("a bean named corsConfigurationSource")
class SecuritySource {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("no max-age is emitted, so every request re-runs the preflight")
void noMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE);
}
@Test
@DisplayName("origin, method and header rejections are indistinguishable to the client")
void threeRejectionsLookIdentical() throws Exception {
var badOrigin = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://evil.example.com")
.header("Access-Control-Request-Method", "POST").exchange();
var badMethod = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "DELETE").exchange();
var badHeader = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "authorization").exchange();
assertThat(badOrigin.getResponse().getStatus()).isEqualTo(403);
assertThat(badMethod.getResponse().getStatus()).isEqualTo(403);
assertThat(badHeader.getResponse().getStatus()).isEqualTo(403);
assertThat(badOrigin.getResponse().getContentAsString()).isEqualTo("Invalid CORS request");
assertThat(badMethod.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
assertThat(badHeader.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
}
@Test
@DisplayName("an unauthenticated request still carries the CORS header, so the SPA can read the 401")
void unauthenticatedStillCarriesCorsHeader() {
assertThat(tester(this.context).get().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.hasStatus(401)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("a path outside the registered pattern gets no CORS header at all")
void outsideThePatternGetsNothing() {
assertThat(tester(this.context).get().uri("/nope")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("misnamed")
@DisplayName("the right type under the wrong bean name")
class Misnamed {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the context starts and CorsFilter is in the chain")
void itStarts() {
assertThat(chainClassNames(this.context)).contains("CorsFilter");
}
@Test
@DisplayName("the preflight returns 200 with no CORS headers - the most confusing state there is")
void twoHundredWithNothing() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("twosources")
@DisplayName("two UrlBasedCorsConfigurationSource beans")
class TwoSources {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("CORS is configured anyway, and the bean NAME decides which one wins")
void nameWins() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200);
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://admin.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(403);
}
}
private static java.util.List<String> chainClassNames(WebApplicationContext context) {
var proxy = (org.springframework.security.web.FilterChainProxy) context
.getBean("springSecurityFilterChain");
return proxy.getFilterChains().get(proxy.getFilterChains().size() - 1).getFilters().stream()
.map((filter) -> filter.getClass().getSimpleName())
.toList();
}
}

View File

@@ -0,0 +1,193 @@
package com.ankurm.cors;
import java.util.List;
import com.ankurm.cors.spec.SpecCookieJar;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/** CSRF-for-SPAs behaviour, and the cookie rules that decide whether the token ever arrives. */
class CsrfAndCookieTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfnaive")
@DisplayName("CookieCsrfTokenRepository.withHttpOnlyFalse() on its own")
class Naive {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET sets no cookie, because the token is deferred")
void bootstrapGetSetsNoCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNull();
}
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfspa")
@DisplayName("csrf.spa()")
class Spa {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET does set the cookie, because spa() resolves the token eagerly")
void bootstrapGetSetsCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
// The mechanism: spa() installs a handler whose XOR delegate has a null
// csrfRequestAttributeName, so CsrfTokenRequestAttributeHandler.handle falls back
// to token.getParameterName() for the attribute key - and calling that method on
// the SupplierCsrfToken is what dereferences the deferred token.
assertThat(cookie.getValue()).isNotEmpty();
}
@Test
@DisplayName("the cookie carries no SameSite and no Secure attribute")
void cookieHasNoSameSite() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
assertThat(cookie.getSecure()).isFalse();
assertThat(cookie.getAttribute("SameSite")).isNull();
}
@Test
@DisplayName("the raw cookie value works in the header, which is the whole point of spa()")
void rawCookieValueIsAccepted() {
var tester = tester(this.context);
var bootstrap = tester.get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = bootstrap.getResponse().getCookie("XSRF-TOKEN");
assertThat(tester.post().uri("/api/data")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")
.cookie(cookie)
.header("X-XSRF-TOKEN", cookie.getValue())
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice")))
.hasStatus(200);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("spaorder")
@DisplayName("csrfTokenRepository(..) before spa()")
class Ordering {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the custom repository is discarded and the default cookie name comes back")
void customRepositoryIsDiscarded() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("MY-CSRF")).isNull();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNotNull();
}
}
@Nested
@DisplayName("SpecCookieJar - the storage and sending rules a browser applies")
class Spec {
@Test
@DisplayName("SameSite=None without Secure is ignored entirely")
void noneWithoutSecureIsDropped() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; SameSite=None", true))
.contains("RFC 6265bis");
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("Secure is not honoured from an untrustworthy origin, so None+Secure is dropped over plain http")
void secureNeedsATrustworthyOrigin() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; Secure; SameSite=None", false)).isNotNull();
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("a cookie with no SameSite attribute behaves as Lax and is not sent on a cross-site fetch")
void absentSameSiteIsLax() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/", true)).isNull();
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)).isEqualTo("XSRF-TOKEN=t");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("Lax is sent on a cross-site top-level navigation but not on a cross-site fetch")
void laxNavigationException() {
SpecCookieJar jar = new SpecCookieJar();
jar.setCookie("JSESSIONID=s; Path=/; SameSite=Lax", true);
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true))
.isEqualTo("JSESSIONID=s");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("only Secure + SameSite=None survives to a cross-site fetch")
void onlyNoneSecureSurvives() {
SpecCookieJar jar = new SpecCookieJar();
for (String header : List.of("a=1; Path=/", "b=2; Path=/; SameSite=Lax",
"c=3; Path=/; SameSite=Strict", "d=4; Path=/; Secure; SameSite=None")) {
jar.setCookie(header, true);
}
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false))
.isEqualTo("d=4");
}
@Test
@DisplayName("Partitioned without Secure is rejected too")
void partitionedNeedsSecure() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("x=1; Path=/; Partitioned", true)).isNotNull();
}
}
@SuppressWarnings("unused")
private static final HttpHeaders UNUSED = null;
}

View File

@@ -0,0 +1,103 @@
# `service-to-service` — token relay, client credentials, exchange and mTLS
Companion project for
[**Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS**](https://ankurm.com/spring-boot-microservices-token-relay-mtls/)
on ankurm.com.
Four real processes and a real Spring Authorization Server, so that questions like "whose
identity arrives at the last service?" and "what does that service actually check?" have
transcripts for answers. Everything under [`docs/output/`](docs/output/) was produced by
`scripts/run-all.sh`.
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent |
| Spring Framework | 7.0.9 | |
| Spring Security | 7.1.1 | resource server, OAuth2 client, authorization server |
| Spring Cloud | 2025.1.3 (gateway 5.0.3) | **built against Boot 4.0.8** — see [docs/01](docs/01-the-four-processes.md) |
| Tomcat | 11.0.24 | |
Versions were read from `repo1.maven.org/.../maven-metadata.xml`.
## Quickstart
```bash
./scripts/run.sh # all four processes, in order
TOKEN=$(./scripts/user-token.sh) # a real authorization_code + PKCE flow, in curl
./scripts/claims.sh "$TOKEN"
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
STRICT=true ./scripts/run.sh # issuer + audience + RFC 9068 validation
./scripts/certs.sh && ./scripts/run-all.sh
mvn test # the 8 validator assertions
./scripts/stop.sh
```
The user is `alice` / `password`.
## Processes
| Process | Port | Main class | Role |
|---|---|---|---|
| authserver | 9000 | [`AuthServerApplication`](src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java) | Spring Authorization Server. Mints every token used here |
| gateway | 8080 | [`GatewayApplication`](src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java) | Spring Cloud Gateway Server MVC, with and without `TokenRelay` |
| edge | 8081 | [`EdgeApplication`](src/main/java/com/ankurm/s2s/edge/EdgeApplication.java) | Resource server **and** OAuth2 client |
| downstream | 8082 | [`DownstreamApplication`](src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java) | Resource server. Reports who it thinks is calling |
| mtls | 8443 | [`MtlsApplication`](src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java) | Certificate authentication instead of tokens |
## Endpoints
| Endpoint | Strategy |
|---|---|
| `GET /edge/naive` | No token forwarded — the control |
| `GET /edge/relay` | The incoming bearer token, unchanged |
| `GET /edge/client-credentials` | The edge service's own identity |
| `GET /edge/exchange` | RFC 8693 token exchange |
| `GET /edge/relay-async` | Relay from another thread — the `ThreadLocal` trap |
| `GET /orders` | Downstream. Echoes `sub`, `aud`, `scope`, `client_id`, `cnf` |
| `GET /mtls/whoami` | The verified certificate identity |
| `GET /mtls/trusted-header` | An identity taken from a header, verified by nothing |
## Switches
| Switch | Effect |
|---|---|
| `STRICT=true ./scripts/run.sh` | Authorization server emits RFC 9068 tokens; downstream validates issuer, audience and the required-claim set |
| `RS_LOG_LEVEL` / `CLIENT_LOG_LEVEL` / `AS_LOG_LEVEL` / `GATEWAY_LOG_LEVEL` | `DEBUG` on the corresponding package |
## Documentation
| Chapter | |
|---|---|
| [01](docs/01-the-four-processes.md) | The four processes, a browser flow in curl, and four things that cost time |
| [02](docs/02-three-ways-to-get-a-token.md) | Relay, client credentials, token exchange — and the thread that loses the token |
| [03](docs/03-restclient-interceptors.md) | `OAuth2ClientHttpRequestInterceptor` and which `OAuth2AuthorizedClientManager` |
| [04](docs/04-what-is-not-validated.md) | **What a resource server does not validate by default** |
| [05](docs/05-the-gateway.md) | What `TokenRelay` actually relays |
| [06](docs/06-mtls.md) | Mesh mTLS versus in-application mTLS |
| [07](docs/07-choosing.md) | Choosing, and whether you need any of it |
## Captured output
| File | |
|---|---|
| [01-user-token.txt](docs/output/01-user-token.txt) | A complete authorization_code + PKCE flow, in curl |
| [02-five-strategies.txt](docs/output/02-five-strategies.txt) | Five propagation strategies, one request |
| [03-audience-ignored.txt](docs/output/03-audience-ignored.txt) | A token for another service, accepted with HTTP 200 |
| [04-gateway-token-relay.txt](docs/output/04-gateway-token-relay.txt) | The same route with and without `TokenRelay` |
| [05-strict-validation.txt](docs/output/05-strict-validation.txt) | Strict validation, and what turning it on breaks |
| [06-mtls.txt](docs/output/06-mtls.txt) | Two certificates with the same subject and different issuers |
| [07-tests.txt](docs/output/07-tests.txt) | `mvn test` |
## Related modules
- [`filter-chain/`](../filter-chain/README.md) — where `BearerTokenAuthenticationFilter` sits
- [`context-propagation/`](../context-propagation/README.md) — why `/edge/relay-async` returns 401
- [`cors-csrf/`](../cors-csrf/README.md) — the browser-facing half of the same problem
- [`method-security/`](../method-security/README.md) — turning a scope into an authorization decision

View File

@@ -0,0 +1,75 @@
# 1. The four processes
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
Everything in this module runs against four real processes, because the questions it answers
(“whose identity arrives?”, “what does the receiving service check?”) only have answers when
there is a second process to arrive at.
| Process | Port | What it is |
|---|---|---|
| `authserver` | 9000 | A real Spring Authorization Server. Mints every token used anywhere here |
| `gateway` | 8080 | Spring Cloud Gateway Server MVC, proxying to `edge` |
| `edge` | 8081 | Resource server **and** OAuth2 client. The middle hop |
| `downstream` | 8082 | Resource server. Reports who it thinks is calling |
| `mtls` | 8443 | Separate. Certificate authentication instead of tokens — chapter 6 |
`./scripts/run.sh` starts the first four in order and waits for each.
## Getting a user token without a browser
The "browser flow" is four HTTP requests and a cookie jar, and
[`scripts/user-token.sh`](../scripts/user-token.sh) does all of it with `curl`:
1. `GET /oauth2/authorize?…` unauthenticated → the request is saved, 302 to `/login`
2. `GET /login`, scrape the `_csrf` hidden field
3. `POST /login` with the credentials and that token → 302 back to the saved request
4. `GET` the authorize URL again, now authenticated → 302 to the redirect URI with `?code=`
5. `POST /oauth2/token` with the code and the PKCE `code_verifier`
[`docs/output/01-user-token.txt`](output/01-user-token.txt) is the token that comes out. Doing
this once by hand is the fastest way to understand what an OIDC library is doing on your behalf.
## Four things that cost time while building this
**`issuer-uri` creates a startup-ordering dependency.** Configuring an OAuth2 *client* with
`spring.security.oauth2.client.provider.<id>.issuer-uri` makes `ClientRegistrations` fetch
`/.well-known/openid-configuration` during context refresh. If the authorization server is not
up, the client will not start:
```
ResourceAccessException: I/O error on GET request for
"http://127.0.0.1:9000/.well-known/openid-configuration": Connection refused
```
Nothing in that message says "start order". Naming `token-uri` and `jwk-set-uri` explicitly
removes the coupling, which is why `edge.yml` does.
**`OAuth2AuthorizationServerConfiguration` no longer exists.** Every pre-7.0 Authorization
Server tutorial opens with
`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)`. That class, and the whole
`org.springframework.security.oauth2.server.authorization.config.annotation.web.*` package tree,
is **absent** from `spring-security-oauth2-authorization-server` 7.1.1. The configurer moved into
`spring-security-config` at
`org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization`,
and there is now a DSL method:
```java
http.oauth2AuthorizationServer(Customizer.withDefaults())
```
It is a compile error, not a deprecation warning.
**A public client is asked for consent.** `ClientAuthenticationMethod.NONE` plus
`authorization_code` produced a `Consent required` page mid-flow, which stops any scripted
redemption dead. `ClientSettings.builder().requireAuthorizationConsent(false)` is explicit in
`AuthServerApplication` for that reason.
**Spring Cloud is a different release train.** `spring-cloud-dependencies` 2025.1.3 —
gateway 5.0.3 — declares `<spring-boot.version>4.0.8</spring-boot.version>` in
`spring-cloud-build`. This module runs it under Boot **4.1.1** and it works, but that is a
combination nobody tested, and it is the reason a Boot upgrade can be blocked by a gateway.
Check `spring-cloud-build`'s POM before assuming the trains are aligned.
---
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*

View File

@@ -0,0 +1,100 @@
# 2. Three ways to get a token for the next hop
*Prev: [1. The four processes](01-the-four-processes.md) &middot; Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
The edge service has a valid token in its hands and needs to call `downstream`. There are three
answers, and [`docs/output/02-five-strategies.txt`](output/02-five-strategies.txt) runs all of
them against the same request so the difference is a diff.
| Endpoint | `sub` downstream sees | `scope` downstream sees |
|---|---|---|
| `/edge/naive` | — (401) | — |
| `/edge/relay` | `alice` | `[orders.write, orders.read]` |
| `/edge/client-credentials` | `edge-service` | `[orders.read]` |
| `/edge/exchange` | `alice` | `[orders.read]` |
| `/edge/relay-async` | — (401) | — |
## Relay
Forward the token you were given, unchanged. Ten lines, no dependency on the OAuth2 client
machinery:
```java
builder.requestInterceptor((request, body, execution) -> {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
return execution.execute(request, body);
})
```
Downstream sees `alice`, which is what you want in an audit log. It also sees
`orders.write`, which downstream had no business receiving: the token was minted for the
request the user made at the edge, and every service it is relayed to gets the union of
everything the user consented to. One compromised service holds a credential good against all
of them for the rest of its lifetime.
Relay is right for a hop inside one trust boundary. It is wrong the moment the next hop is
operated by someone who should not hold the user's full token.
## Client credentials
The service authenticates as itself:
```yaml
spring.security.oauth2.client.registration.edge-service:
authorization-grant-type: client_credentials
scope: orders.read
```
Downstream now sees `sub: edge-service` and exactly `orders.read`. The scope problem is solved.
The user is gone — `downstream`'s log says a service called it, and answering "who ordered
this?" requires correlating two logs by a request id you hope somebody propagated.
## Token exchange (RFC 8693)
Trade the user's token for one scoped to the next hop, keeping the user:
```yaml
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
scope: orders.read
```
`sub: alice`, `scope: [orders.read]`. Both properties, from one grant type that has existed
since 2020 and that almost nobody reaches for.
The catch is configuration, not concept. `OAuth2AuthorizedClientProviderBuilder`'s defaults do
**not** include token exchange, so you have to add the provider yourself:
```java
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
.build();
```
Leave it out and the manager returns `null` rather than raising anything, and the interceptor
sends the request with no `Authorization` header at all. A 401 that looks like a downstream
problem and is a wiring problem.
## The trap: relay from another thread
`/edge/relay-async` does exactly what `/edge/relay` does, on a virtual thread from an executor.
It returns 401.
`SecurityContextHolder` is a `ThreadLocal`. The interceptor reads it; the task runs on a thread
that never had a `SecurityContext` written to it; the `instanceof` fails; the header is never
set. Nothing throws. This is the same mechanism as
[Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/),
and the cure is the same: `DelegatingSecurityContextExecutorService`, or
`ContextPropagatingTaskDecorator`, or capture the token value on the request thread and pass it
as a parameter.
Any relay built on `SecurityContextHolder` inherits every one of those failure modes. A relay
built on an explicit token parameter inherits none of them, at the cost of a parameter on every
method between the controller and the client.
---
*Prev: [1. The four processes](01-the-four-processes.md) &middot; Next: [3. RestClient interceptors](03-restclient-interceptors.md)*

View File

@@ -0,0 +1,77 @@
# 3. `RestClient` interceptors
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) &middot; Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
`OAuth2ClientHttpRequestInterceptor`, in
`org.springframework.security.oauth2.client.web.client`, is the framework's answer for a
`RestClient` that needs a token. Its whole public surface:
```java
public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager manager);
public void setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler handler);
public void setClientRegistrationIdResolver(ClientRegistrationIdResolver resolver);
public void setPrincipalResolver(PrincipalResolver resolver);
```
Wiring it takes one line, and choosing the registration per call takes one more:
```java
RestClient client = builder
.baseUrl("http://127.0.0.1:8082")
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
.build();
client.get().uri("/orders")
.attributes(clientRegistrationId("edge-service")) // static import
.retrieve().body(Map.class);
```
`clientRegistrationId(..)` is a static method on `RequestAttributeClientRegistrationIdResolver`.
Without it, the default resolver finds nothing and the request goes out unauthenticated.
## Which `OAuth2AuthorizedClientManager`
This is the choice that decides whether the thing works off a request thread.
| Manager | Storage | Needs a request? |
|---|---|---|
| `DefaultOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientRepository` (session) | **Yes** |
| `AuthorizedClientServiceOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientService` | No |
For service-to-service calls there is no end user whose authorization is being stored per
session, so the second one is right — and it is the one that keeps working from a scheduled
task, a message listener or an `@Async` method.
Getting this wrong produces `ClientAuthorizationRequiredException` or a silent `null` in a
context that has no `HttpServletRequest`, which reads like an OAuth problem and is a bean
problem.
## What it caches, and what it does not
The manager stores the authorized client (access token and, if issued, refresh token) in the
`OAuth2AuthorizedClientService` and reuses it until it is within the clock skew of expiry.
So a `client_credentials` registration does **not** hit the token endpoint per request. What it
does do is re-request on expiry, synchronously, inside whichever call happens to be first —
worth knowing when a latency percentile spikes on a period that matches your token lifetime.
## The hand-rolled relay, and why it is still reasonable
The relay interceptor in
[`DownstreamClients`](../src/main/java/com/ankurm/s2s/edge/DownstreamClients.java) does not use
any of the above:
```java
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
```
That is not a worse version of `OAuth2ClientHttpRequestInterceptor`; it is a different thing.
The interceptor **obtains** a token under a client registration. This **forwards** the token
already in hand. There is no client registration for "the caller's token", and there should not
be. Just do not confuse the two: the hand-rolled one carries the `ThreadLocal` dependency from
chapter 2, and the framework one does not.
---
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) &middot; Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*

View File

@@ -0,0 +1,125 @@
# 4. What a resource server does not validate
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) &middot; Next: [5. The gateway](05-the-gateway.md)*
This is the most important chapter in the module.
## The demonstration
`reporting-service` is a client registered on the same authorization server, with nothing to do
with the downstream service. Its access tokens carry `aud: reporting-api`.
Send one to the downstream service, whose audience is `downstream-api`:
```
HTTP/1.1 200
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
```
Two hundred. Full transcript:
[`docs/output/03-audience-ignored.txt`](output/03-audience-ignored.txt).
## Why
`JwtValidators.createDefault()` is a `DelegatingOAuth2TokenValidator` over three validators,
read back by reflection in `ValidatorContractTests.defaultDelegates`:
- `JwtTypeValidator`
- `JwtTimestampValidator`
- `X509CertificateThumbprintValidator`
Structure, expiry, and certificate binding. **No issuer. No audience. No scope.** Setting
`spring.security.oauth2.resourceserver.jwt.issuer-uri` gets you a `JwtIssuerValidator` on top of
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
The practical consequence: in an estate where several services trust one authorization server —
which is the normal shape — **any token from that issuer is accepted by every service in it**.
A token a partner integration obtained for the reporting API is a valid credential for the
payments API. Scope may or may not save you; `scope: orders.read` did not, above.
## The fix, and its price
```java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtAudienceValidator("downstream-api")));
```
`JwtAudienceValidator` is a public class in 7.1. Two lines, and the demonstration above turns
into `The aud claim is not valid`.
The heavier option is the RFC 9068 profile:
```java
JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
```
That requires `typ`, `exp`, `sub`, `iat`, `jti`, `iss`, `aud` **and `client_id`** to all be
present. It is stricter and it is also where two surprises live.
## Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
Out of the box its access tokens carry `typ: JWT` in the header and no `client_id` claim. Point
`createAtJwtValidator()` at one and every token is rejected. Both are one line in an
`OAuth2TokenCustomizer`:
```java
context.getJwsHeader().type("at+jwt");
context.getClaims().claim("client_id", clientId);
```
## Surprise 2: making the token compliant breaks every resource server that was not updated
`NimbusJwtDecoder`'s default JOSE type verifier accepts `JWT` and an absent `typ`, and nothing
else. The moment the authorization server starts typing tokens `at+jwt`, every service using
Boot's auto-configured decoder answers:
```
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
the given typ value needs to be one of [JWT]"
```
[`docs/output/05-strict-validation.txt`](output/05-strict-validation.txt) has the downstream
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
them. The message mentions neither RFC 9068 nor the authorization server.
The fix on the receiving side:
```java
NimbusJwtDecoder.withJwkSetUri(jwks)
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
```
Roll that out **before** you change the authorization server, not after.
## A message worth recognising twice
Two components produce almost the same sentence and mean opposite things:
| Source | Message |
|---|---|
| Nimbus `DefaultJOSEObjectTypeVerifier` | `the given typ value needs to be one of [JWT]` |
| Spring `JwtTypeValidator` (at+jwt profile) | `the given typ value needs to be one of [at+jwt, application/at+jwt]` |
The first means "your authorization server is too modern for this decoder". The second means
"your authorization server is not modern enough for this validator".
## And one thing that *is* on by default
`X509CertificateThumbprintValidator` is in the default set. If a token carries a `cnf` claim
with `x5t#S256` — a certificate-bound access token, RFC 8705 — Spring Security **already**
checks it against the client certificate on the TLS connection, with no configuration. That is
the strongest available defence against a stolen bearer token, it costs nothing on the resource
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
6.
---
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) &middot; Next: [5. The gateway](05-the-gateway.md)*

View File

@@ -0,0 +1,74 @@
# 5. The gateway, and what `TokenRelay` actually relays
*Prev: [4. What is not validated](04-what-is-not-validated.md) &middot; Next: [6. mTLS](06-mtls.md)*
Spring Cloud Gateway Server MVC ships a filter that sounds like it solves the whole problem:
```yaml
filters:
- TokenRelay=
```
It is `TokenRelayFilterFunctions`, with two forms:
```java
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay();
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay(String clientRegistrationId);
```
The documentation is precise about what it does, and it is not what the name suggests to most
readers: it takes the access token **of the currently authenticated user** — the one obtained
by `oauth2Login()` — or of the named client registration, and puts it in the `Authorization`
header of the proxied request.
It does not mean "forward the incoming bearer token". There is nothing to forward from if the
gateway never performed a login.
## The A/B
`gateway.yml` defines two routes to the same destination, differing only in the filter.
[`docs/output/04-gateway-token-relay.txt`](output/04-gateway-token-relay.txt):
```
/edge/relay (TokenRelay=) → 200, sub: alice
/norelay/x (no TokenRelay) → 200, sub: alice
```
Identical. The token arrived because the gateway proxied the `Authorization` header the caller
sent, which it would have done anyway. In this configuration `TokenRelay` contributes nothing.
Where it earns its place is the other shape: a gateway that terminates a **browser session**
with `oauth2Login()`, keeps the tokens server-side, hands the browser nothing but a session
cookie, and attaches a token on the way through. That is the backend-for-frontend pattern, and
it is a genuinely good answer to "where do I keep the token in a SPA" — because the answer is
"not in the SPA".
## The 401 that has nothing to do with OAuth
Before `GatewaySecurityConfig` existed, every call through the gateway came back:
```
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
```
A perfectly valid bearer token, rejected by a gateway that had never been told to expect one.
Spring Boot applies a default filter chain — HTTP Basic and form login over every path — to any
application on the classpath with Spring Security and no `SecurityFilterChain` bean. A gateway
is an application. `WWW-Authenticate: Basic` in front of a token-based estate always means this.
## Choosing where the gateway sits
| Shape | Gateway does | Downstream sees |
|---|---|---|
| Pass-through | Routes; validates nothing | The caller's token; each service validates it |
| Edge validation | Validates the token, strips it, adds its own identity | The gateway's identity |
| BFF | `oauth2Login()`, holds tokens, `TokenRelay` | The user's token, obtained by the gateway |
Pass-through is the default and is fine while every service validates properly — chapter 4 is
about how often that assumption is wrong. Edge validation is the one that tempts teams into
"the gateway checked it, so we can trust the header", which is chapter 6's failure mode wearing
a different hat.
---
*Prev: [4. What is not validated](04-what-is-not-validated.md) &middot; Next: [6. mTLS](06-mtls.md)*

View File

@@ -0,0 +1,90 @@
# 6. mTLS: in the mesh or in the application
*Prev: [5. The gateway](05-the-gateway.md) &middot; Next: [7. Choosing](07-choosing.md)*
Run `./scripts/certs.sh`, then start `MtlsApplication` on 8443. The certificates it generates
are the point: `edge.crt` and `rogue.crt` have **identical subjects** and different issuers.
```
subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA
subject=CN = edge-service, OU = payments issuer=CN = Some Other CA
```
Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust
store vouched for it.
## In-application mTLS
```yaml
server:
ssl:
bundle: server
client-auth: need
spring:
ssl:
bundle:
pem:
server:
keystore: { certificate: "file:...server.crt", private-key: "file:...server.key" }
truststore: { certificate: "file:...internal-ca.crt" }
```
```java
http.x509((x509) -> x509
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
.userDetailsService(certificateUsers()));
```
A good certificate produces a `PreAuthenticatedAuthenticationToken` with the CN as the
principal, and — in Spring Security 7 — a `FACTOR_X509` authority alongside the roles, the
sibling of the `FACTOR_BEARER` you get from a JWT and the `FACTOR_PASSWORD` you get from Basic.
Any assertion using `containsExactly` on authorities will fail on it.
## Three things the transcript shows that a diagram does not
[`docs/output/06-mtls.txt`](output/06-mtls.txt):
**The rogue certificate produces no HTTP status at all.** `curl` exits 56; there is no response
line, no 401, no 403, and nothing in the application log at `INFO`. The handshake failed. Your
application-level metrics will show nothing, because from the application's point of view
nothing happened. Debugging this means `-Djavax.net.debug=ssl:handshake` or the load balancer's
own logs.
**`client-auth: need` is a property of the connector, not of a path.** `/mtls/trusted-header` is
`permitAll()` and it fails exactly the same way without a certificate. You cannot expose a
public health endpoint on an mTLS-only connector; it needs a second connector, or `want`
instead of `need` plus an explicit authorization rule that treats an absent certificate as
anonymous.
**Header-based identity is verified by nothing.** The last call in the transcript presents a
valid certificate for `edge-service` and a header claiming to be `payments-service`, and the
endpoint reports `payments-service`.
## Mesh mTLS
A service mesh terminates TLS in a sidecar. The application receives plain HTTP on localhost and
the peer identity arrives as a header — `X-Forwarded-Client-Cert` in Envoy, carrying the SPIFFE
URI SAN. The trade:
| | Mesh | In-application |
|---|---|---|
| Certificate lifecycle | Handled, rotated automatically | Yours: issuance, rotation, expiry alerts |
| Application code | None | An SSL bundle plus `x509(..)` |
| Identity in the app | A header | A verified `X509Certificate` |
| Works with cert-bound tokens (RFC 8705) | **No** | Yes |
| Failure mode | Anything that bypasses the sidecar can spoof the header | Handshake failure, no HTTP status |
The row that decides it for a security-sensitive service is the RFC 8705 one. Certificate-bound
access tokens — the `cnf` / `x5t#S256` claim, which `X509CertificateThumbprintValidator`
already checks by default (chapter 4) — bind a token to the TLS connection it was issued for, so
a stolen token is useless without the private key. That binding requires the application to see
the client certificate. Terminate mTLS in a sidecar and the strongest defence available against
token theft is off the table.
If you take mesh identity from a header, the header must be **stripped at ingress** on every
path into the pod, and the pod must not be reachable except through the proxy. Both are
infrastructure guarantees that no amount of application code can verify — which is exactly why
`/mtls/trusted-header` answers `"verifiedBy": "nothing. This endpoint believes a header."`.
---
*Prev: [5. The gateway](05-the-gateway.md) &middot; Next: [7. Choosing](07-choosing.md)*

View File

@@ -0,0 +1,53 @@
# 7. Choosing
*Prev: [6. mTLS](06-mtls.md)*
## Which propagation strategy
| Situation | Use |
|---|---|
| One hop, same team, same trust boundary, audit needs the user | Relay |
| A job with no user: scheduler, message listener, reconciliation | Client credentials |
| The next hop is another team's service, and audit needs the user | Token exchange |
| The next hop is outside your organisation | Client credentials, and a token minted for them |
The honest default is **relay inside a boundary, exchange across one**. Client credentials is
the right answer more often than it is used for work with no user attached, and the wrong answer
whenever somebody will later ask "who did this?".
## Do you need any of this?
A callout that belongs in every article on this subject and is usually missing:
> **If your services are a single deployment behind one ingress, all of this is cost with no
> benefit.** Two Spring Boot applications in one VPC, called by one another, with no
> multi-tenancy and no external partner, do not need an authorization server, a token exchange
> grant or a mesh. Network-level isolation plus a shared secret is a defensible design, and it
> is a design you can reason about at 3am. The machinery in this repository earns its keep when
> there are enough services, enough teams, or enough regulatory pressure that "who called this,
> on whose behalf, with what permission" has to be answerable from a log rather than from
> memory.
## A checklist that is short on purpose
1. Every resource server validates **audience**, not just signature and expiry (chapter 4).
2. No relay reads `SecurityContextHolder` from a thread the request did not create (chapter 2).
3. Every service has a `SecurityFilterChain` bean, so nothing falls back to Boot's Basic default
(chapter 5).
4. Client registrations name `token-uri` / `jwk-set-uri` rather than `issuer-uri`, unless you
want a startup-ordering dependency (chapter 1).
5. If identity arrives in a header, something upstream strips that header from every external
request, and you can name the component that does it (chapter 6).
6. Token lifetimes are short enough that the audience gap in item 1 is bounded even when
somebody forgets.
## Further reading
- [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) — why the async relay returns 401
- [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) — where `BearerTokenAuthenticationFilter` sits
- [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) — token exchange
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) — the `at+jwt` access token profile
- [RFC 8705](https://datatracker.ietf.org/doc/html/rfc8705) — mTLS client authentication and certificate-bound access tokens
---
*Prev: [6. mTLS](06-mtls.md)*

View File

@@ -0,0 +1,26 @@
==============================================================================
docs/output/01-user-token.txt
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
scripts/user-token.sh, then scripts/claims.sh
==============================================================================
{
"alg": "RS256",
"kid": "<uuid>"
}
{
"aud": "downstream-api",
"exp": <epoch>,
"iat": <epoch>,
"iss": "http://127.0.0.1:9000",
"jti": "<uuid>",
"nbf": <epoch>,
"scope": [
"orders.write",
"orders.read"
],
"sub": "alice"
}
# sub is the human. scope is what the human consented to. aud names the service the
# token was minted for - chapter 4 is about whether anybody looks at it.

View File

@@ -0,0 +1,110 @@
==============================================================================
docs/output/02-five-strategies.txt
The same request into the edge service, five ways of getting a token for the hop to
downstream. Read the sub and scope of each downstream response.
GET /edge/{naive,relay,client-credentials,exchange,relay-async}
==============================================================================
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/naive
{
"strategy": "no token forwarded",
"error": "Unauthorized: 401 Unauthorized: [no body]"
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
{
"strategy": "the edge service's own client_credentials token",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "edge-service",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
{
"strategy": "RFC 8693 token exchange",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay-async
{
"strategy": "relay attempted from a separate thread",
"error": "Unauthorized: 401 Unauthorized: [no body]"
}
# naive - 401. The control.
# relay - sub: alice, scope: [orders.write, orders.read]. The user's own
# token, unchanged, including scopes downstream did not need.
# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and
# the user has disappeared from downstream's audit log.
# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is
# for and it is the one nobody reaches for.
# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is
# a ThreadLocal, and the call was made on a different thread.

View File

@@ -0,0 +1,53 @@
==============================================================================
docs/output/03-audience-ignored.txt
A token minted for a DIFFERENT service, presented to the downstream service.
Default validators.
==============================================================================
# the token reporting-service was issued:
{
"alg": "RS256",
"kid": "<uuid>"
}
{
"aud": "reporting-api",
"exp": <epoch>,
"iat": <epoch>,
"iss": "http://127.0.0.1:9000",
"jti": "<uuid>",
"nbf": <epoch>,
"scope": [
"orders.read"
],
"sub": "reporting-service"
}
$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders
{
"service": "downstream:8082",
"strictValidation": false,
"sub": "reporting-service",
"aud": [
"reporting-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
# HTTP 200. The aud claim says reporting-api. The service is downstream-api.
# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three
# validators - JwtTypeValidator, JwtTimestampValidator and
# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding.
# No issuer. No audience. Read back by reflection in ValidatorContractTests.

View File

@@ -0,0 +1,80 @@
==============================================================================
docs/output/04-gateway-token-relay.txt
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
GET /edge/relay through the gateway on 8080.
==============================================================================
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/edge/relay
HTTP/1.1 200
cache-control: no-cache, no-store, max-age=0, must-revalidate
expires: 0
pragma: no-cache
x-content-type-options: nosniff
x-frame-options: DENY
x-xss-protection: 0
Content-Type: application/json
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all
status 401
# and the identical route with the TokenRelay filter REMOVED:
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/norelay/x
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
# TokenRelay relays the access token of the currently authenticated USER - the one
# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no
# authorized client to read a token from, and the filter contributes nothing. What
# reaches the edge service is whatever Authorization header the caller sent, because
# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'.

View File

@@ -0,0 +1,43 @@
==============================================================================
docs/output/05-strict-validation.txt
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
STRICT=true ./scripts/run.sh
==============================================================================
# the wrong-audience token that was accepted in 03:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8082/.well-known/oauth-protected-resource"
# a token minted for this service:
{
"service": "downstream:8082",
"strictValidation": true,
"sub": "edge-service",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": "edge-service",
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"id": 1,
"total": "42.00"
}
]
}
# and the edge service, which was NOT updated - it still uses Boot's
# auto-configured decoder:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8081/.well-known/oauth-protected-resource"
# Turning on RFC 9068 at the authorization server broke every resource server that
# still has NimbusJwtDecoder's default JOSE type verifier, and the error message
# mentions neither RFC 9068 nor the authorization server.

View File

@@ -0,0 +1,43 @@
==============================================================================
docs/output/06-mtls.txt
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
different issuers.
==============================================================================
$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer
subject=CN = edge-service, OU = payments
issuer=CN = Internal Mesh CA
$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
subject=CN = edge-service, OU = payments
issuer=CN = Some Other CA
$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami
{
"principal": "edge-service",
"authenticationType": "PreAuthenticatedAuthenticationToken",
"authorities": [
"ROLE_SERVICE",
"FACTOR_X509"
],
"certificateSubject": "OU=payments,CN=edge-service",
"certificateIssuer": "CN=Internal Mesh CA"
}
$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami
[curl exit 56, http 000]
$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint
[curl exit 56, http 000]
$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
https://localhost:8443/mtls/trusted-header
{"caller":"CN=payments-service","verifiedBy":"nothing. This endpoint believes a header."}
# Three things worth reading twice.
# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake
# is rejected; the application never sees a request and logs nothing at INFO.
# 2. So does the permitAll() endpoint. client-auth=need is a property of the
# CONNECTOR, not of a path. You cannot expose a public endpoint on that port.
# 3. The last call is what a mesh deployment usually looks like from inside the
# application: an identity taken from a header, verified by nothing.

View File

@@ -0,0 +1,8 @@
==============================================================================
docs/output/07-tests.txt
mvn -B test
==============================================================================
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in com.ankurm.s2s.ValidatorContractTests
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

View File

@@ -0,0 +1,82 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Same parent as the other servlet modules in this repository, so the whole series runs
on one verified stack. Spring Cloud is a separate release train with its own Boot
baseline - see docs/01-the-four-processes.md for what that costs. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>service-to-service</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-cloud.version>2025.1.3</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Generate a throwaway CA, a server certificate for the downstream service, and two client
# certificates - one signed by that CA and one signed by a different CA. Into target/, so
# nothing here is committed and nothing here should ever be trusted.
#
# ./scripts/certs.sh
set -eu
cd "$(dirname "$0")/.."
D=target/certs
rm -rf "$D" && mkdir -p "$D"
cd "$D"
gen_ca() { # gen_ca <name> <cn>
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$1-ca.key" -out "$1-ca.crt" -subj "/CN=$2" 2>/dev/null
}
sign() { # sign <ca> <name> <subject> [extfile-content]
openssl req -newkey rsa:2048 -nodes -keyout "$2.key" -out "$2.csr" -subj "$3" 2>/dev/null
if [ -n "${4:-}" ]; then printf '%s\n' "$4" > "$2.ext"; else : > "$2.ext"; fi
openssl x509 -req -in "$2.csr" -CA "$1-ca.crt" -CAkey "$1-ca.key" -CAcreateserial \
-out "$2.crt" -days 3650 -extfile "$2.ext" 2>/dev/null
}
gen_ca internal "Internal Mesh CA"
gen_ca other "Some Other CA"
sign internal server "/CN=localhost" "subjectAltName=DNS:localhost,IP:127.0.0.1"
sign internal edge "/CN=edge-service/OU=payments"
sign other rogue "/CN=edge-service/OU=payments"
echo "wrote:"
ls -1 *.crt *.key | sed 's/^/ target\/certs\//'
echo
echo "Note that rogue.crt carries the SAME subject as edge.crt. Identity in mTLS is not the"
echo "subject; it is the subject plus the fact that a trusted CA vouched for it."

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Print the header and payload of a JWT without verifying it. For reading, not for trusting.
#
# ./scripts/claims.sh "$TOKEN"
set -eu
python3 - "$1" <<'PY'
import base64, json, sys
token = sys.argv[1]
for part in token.split('.')[:2]:
padded = part + '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(padded)), indent=2, sort_keys=True))
PY

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
# hand-written; if a claim in the article disagrees with a file here, the file is right.
#
# ./scripts/run-all.sh
#
# The whole module is started twice - once loose, once strict - plus a separate mTLS process,
# because the difference between those runs IS the content.
set -eu
cd "$(dirname "$0")/.."
OUT=docs/output
mkdir -p "$OUT"
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/"(exp|iat|nbf)": [0-9]+/"\1": <epoch>/g' \
-e 's/"(jti|kid)": "[0-9a-f-]+"/"\1": "<uuid>"/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/issuedAt=[^]]*\]/issuedAt=<timestamp>]/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
claims() { ./scripts/claims.sh "$1"; }
json() { python3 -m json.tool 2>/dev/null || cat; }
cc_token() { # cc_token <client> <secret>
curl -s -u "$1:$2" -X POST http://127.0.0.1:9000/oauth2/token \
-d grant_type=client_credentials -d scope=orders.read \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))'
}
########################################################################################
# LOOSE RUN
########################################################################################
./scripts/run.sh > /dev/null 2>&1
USER_TOKEN=$(./scripts/user-token.sh)
{
hdr "docs/output/01-user-token.txt
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
scripts/user-token.sh, then scripts/claims.sh"
claims "$USER_TOKEN"
echo
echo "# sub is the human. scope is what the human consented to. aud names the service the"
echo "# token was minted for - chapter 4 is about whether anybody looks at it."
} | scrub > "$OUT/01-user-token.txt"
{
hdr "docs/output/02-five-strategies.txt
The same request into the edge service, five ways of getting a token for the hop to
downstream. Read the sub and scope of each downstream response.
GET /edge/{naive,relay,client-credentials,exchange,relay-async}"
for endpoint in naive relay client-credentials exchange relay-async; do
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8081/edge/$endpoint"
curl -s -H "Authorization: Bearer $USER_TOKEN" "http://127.0.0.1:8081/edge/$endpoint" | json
echo
done
echo "# naive - 401. The control."
echo "# relay - sub: alice, scope: [orders.write, orders.read]. The user's own"
echo "# token, unchanged, including scopes downstream did not need."
echo "# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and"
echo "# the user has disappeared from downstream's audit log."
echo "# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is"
echo "# for and it is the one nobody reaches for."
echo "# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is"
echo "# a ThreadLocal, and the call was made on a different thread."
} | scrub > "$OUT/02-five-strategies.txt"
{
hdr "docs/output/03-audience-ignored.txt
A token minted for a DIFFERENT service, presented to the downstream service.
Default validators."
WRONG=$(cc_token reporting-service reporting-secret)
echo "# the token reporting-service was issued:"
claims "$WRONG"
echo
echo "\$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders"
curl -s -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders | json
echo
echo "# HTTP 200. The aud claim says reporting-api. The service is downstream-api."
echo "# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three"
echo "# validators - JwtTypeValidator, JwtTimestampValidator and"
echo "# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding."
echo "# No issuer. No audience. Read back by reflection in ValidatorContractTests."
} | scrub > "$OUT/03-audience-ignored.txt"
{
hdr "docs/output/04-gateway-token-relay.txt
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
GET /edge/relay through the gateway on 8080."
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/edge/relay"
curl -s -i -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay \
| sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):'
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay | json
echo
echo "\$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all"
curl -s -o /dev/null -w 'status %{http_code}\n' http://127.0.0.1:8080/edge/relay
echo
echo "# and the identical route with the TokenRelay filter REMOVED:"
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/norelay/x"
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/norelay/x | json
echo
echo "# TokenRelay relays the access token of the currently authenticated USER - the one"
echo "# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no"
echo "# authorized client to read a token from, and the filter contributes nothing. What"
echo "# reaches the edge service is whatever Authorization header the caller sent, because"
echo "# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'."
} | scrub > "$OUT/04-gateway-token-relay.txt"
########################################################################################
# STRICT RUN
########################################################################################
STRICT=true ./scripts/run.sh > /dev/null 2>&1
{
hdr "docs/output/05-strict-validation.txt
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
STRICT=true ./scripts/run.sh"
WRONG=$(cc_token reporting-service reporting-secret)
RIGHT=$(cc_token edge-service edge-secret)
echo "# the wrong-audience token that was accepted in 03:"
curl -s -i -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# a token minted for this service:"
curl -s -H "Authorization: Bearer $RIGHT" http://127.0.0.1:8082/orders | json
echo
echo "# and the edge service, which was NOT updated - it still uses Boot's"
echo "# auto-configured decoder:"
STRICT_USER=$(./scripts/user-token.sh)
curl -s -i -H "Authorization: Bearer $STRICT_USER" http://127.0.0.1:8081/edge/relay \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# Turning on RFC 9068 at the authorization server broke every resource server that"
echo "# still has NimbusJwtDecoder's default JOSE type verifier, and the error message"
echo "# mentions neither RFC 9068 nor the authorization server."
} | scrub > "$OUT/05-strict-validation.txt"
./scripts/stop.sh
########################################################################################
# mTLS
########################################################################################
./scripts/certs.sh > /dev/null
CP="target/classes:$(cat target/cp.txt)"
setsid nohup java -Xmx160m -cp "$CP" com.ankurm.s2s.mtls.MtlsApplication \
> /tmp/s2s-Mtls.log 2>&1 < /dev/null &
for _ in $(seq 1 60); do
curl -s -o /dev/null -m 2 --cacert target/certs/internal-ca.crt \
--cert target/certs/edge.crt --key target/certs/edge.key \
https://localhost:8443/mtls/whoami && break
sleep 1
done
{
hdr "docs/output/06-mtls.txt
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
different issuers."
echo "\$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer"
openssl x509 -in target/certs/edge.crt -noout -subject -issuer
echo "\$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer"
openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
echo
echo "\$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key https://localhost:8443/mtls/whoami | json
echo
echo "\$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/rogue.crt \
--key target/certs/rogue.key https://localhost:8443/mtls/whoami \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint"
curl -sk https://localhost:8443/mtls/trusted-header \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \\"
echo " https://localhost:8443/mtls/trusted-header"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
https://localhost:8443/mtls/trusted-header
echo
echo
echo "# Three things worth reading twice."
echo "# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake"
echo "# is rejected; the application never sees a request and logs nothing at INFO."
echo "# 2. So does the permitAll() endpoint. client-auth=need is a property of the"
echo "# CONNECTOR, not of a path. You cannot expose a public endpoint on that port."
echo "# 3. The last call is what a mesh deployment usually looks like from inside the"
echo "# application: an identity taken from a header, verified by nothing."
} | scrub > "$OUT/06-mtls.txt"
for pid in $(ps -eo pid,ppid,comm,args | awk '$3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\.mtls/ {print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
########################################################################################
# TESTS
########################################################################################
{
hdr "docs/output/07-tests.txt
mvn -B test"
mvn -B test 2>&1 | grep -E 'Tests run|ERROR|BUILD' | head -30
} | scrub > "$OUT/07-tests.txt"
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Start all four processes and wait until each answers.
#
# ./scripts/run.sh
# STRICT=true ./scripts/run.sh # downstream validates issuer and audience
# RS_LOG_LEVEL=DEBUG ./scripts/run.sh # resource-server decisions at DEBUG
#
# | Process | Port | Main class |
# |-------------|------|---------------------------|
# | authserver | 9000 | AuthServerApplication |
# | gateway | 8080 | GatewayApplication |
# | edge | 8081 | EdgeApplication |
# | downstream | 8082 | DownstreamApplication |
#
# These are launched with plain `java`, not `spring-boot:run`. Four Maven JVMs each forking an
# application JVM is eight processes, and on a small machine that is how you meet the OOM
# killer rather than the demo. `mvn dependency:build-classpath` once, then `java -cp` four
# times, is two hundred megabytes of heap instead of two gigabytes.
set -eu
cd "$(dirname "$0")/.."
./scripts/stop.sh
mvn -B -q compile
if [ ! -f target/cp.txt ]; then
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
fi
CP="target/classes:$(cat target/cp.txt)"
start() { # start <main-class> <port> <health-path> <extra-jvm-args...>
local main="$1" port="$2" path="$3"
shift 3
setsid nohup java -Xmx192m -XX:TieredStopAtLevel=1 "$@" ${JVM_ARGS:-} \
-cp "$CP" "com.ankurm.s2s.$main" \
> "/tmp/s2s-${main##*.}.log" 2>&1 < /dev/null &
for _ in $(seq 1 60); do
if curl -s -o /dev/null "http://127.0.0.1:$port$path" 2>/dev/null; then
echo " ${main##*.} up on $port"
return 0
fi
sleep 1
done
echo "${main##*.} did not start; see /tmp/s2s-${main##*.}.log" >&2
tail -20 "/tmp/s2s-${main##*.}.log" >&2
return 1
}
# STRICT=true turns on RFC 9068 end to end: the authorization server types its access tokens
# `at+jwt` and adds a client_id claim, and the downstream service validates issuer, audience
# and the required-claim set instead of only signature and expiry.
STRICT_ARGS=()
AS_ARGS=()
if [ "${STRICT:-false}" = "true" ]; then
STRICT_ARGS=(-DSTRICT=true)
AS_ARGS=(-DAT_JWT=true)
fi
# The authorization server must be first and must be READY before the others: `edge` is an
# OAuth2 client, and a client whose provider is configured with issuer-uri fetches the
# discovery document during context refresh. See docs/01-the-four-processes.md.
start authserver.AuthServerApplication 9000 /oauth2/jwks "${AS_ARGS[@]}"
start downstream.DownstreamApplication 8082 /orders "${STRICT_ARGS[@]}"
start edge.EdgeApplication 8081 /edge/naive
start gateway.GatewayApplication 8080 /edge/naive
echo "all four up"

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Stop every process in the module.
#
# Two rules, both learned the hard way while building this repository:
#
# 1. Match the MAIN CLASS, never 'spring-boot' or 'java'. `pkill -f spring-boot` also matches
# the shell command line that launched the application, so it kills your own shell.
#
# 2. Restrict the match to processes that are actually a JVM, and exclude this script and its
# parent. A bracketed pattern like '[A]uthServerApplication' stops the pattern matching the
# grep itself - but it does NOT stop it matching an ancestor shell whose command line
# happens to contain that string, which is exactly what happens when you paste a here-doc
# containing the class name into a terminal and then run this script from it. The victim
# process dies with exit 137 and no output, which is a memorable afternoon.
set -eu
SELF=$$
PARENT=$PPID
ps -eo pid,ppid,comm,args | awk -v self="$SELF" -v parent="$PARENT" '
$1 != self && $1 != parent && $3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\./ { print $1 }
' | while read -r pid; do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Drive a complete authorization_code + PKCE flow with curl and print the access token.
#
# TOKEN=$(./scripts/user-token.sh)
#
# There is no browser here and none is needed: the "browser flow" is four HTTP requests and a
# cookie jar. Doing it by hand once is the fastest way to understand what your SPA's OIDC
# library is actually doing, and it makes every transcript in docs/output/ reproducible.
set -eu
AS=http://127.0.0.1:9000
JAR=$(mktemp)
trap 'rm -f "$JAR"' EXIT
# 1. PKCE: a random verifier, and its base64url-encoded SHA-256 as the challenge.
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=')
AUTHORIZE="$AS/oauth2/authorize?response_type=code&client_id=spa-client\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=orders.read%20orders.write\
&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 2. Ask for the code. Unauthenticated, so this saves the request and redirects to /login.
curl -s -o /dev/null -c "$JAR" -b "$JAR" "$AUTHORIZE"
# 3. Log in. The login page is CSRF-protected, so read the token out of the form.
CSRF=$(curl -s -c "$JAR" -b "$JAR" "$AS/login" \
| grep -oiE 'name="_csrf"[^>]*value="[^"]*"' | head -1 | sed 's/.*value="//; s/"//')
curl -s -o /dev/null -c "$JAR" -b "$JAR" -X POST "$AS/login" \
-d "username=alice" -d "password=password" -d "_csrf=$CSRF"
# 4. Follow the saved request. Now authenticated, so this redirects to the redirect_uri
# carrying ?code=... We never let curl follow it; we just read the Location header.
CODE=$(curl -s -o /dev/null -D- -c "$JAR" -b "$JAR" "$AUTHORIZE" \
| grep -i '^location:' | sed 's/.*code=//; s/[&\r].*//')
if [ -z "$CODE" ]; then
echo "no authorization code was issued - is the auth server up?" >&2
exit 1
fi
# 5. Redeem it. A public client, so no client secret: the code_verifier is the proof.
curl -s -X POST "$AS/oauth2/token" \
-d grant_type=authorization_code \
-d "code=$CODE" \
-d "redirect_uri=http://127.0.0.1:8080/authorized" \
-d client_id=spa-client \
-d "code_verifier=$VERIFIER" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'

View File

@@ -0,0 +1,205 @@
package com.ankurm.s2s.authserver;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* A real Spring Authorization Server, on port 9000. Everything downstream in this module is
* validated against tokens this process minted, so nothing in the article is a hand-written JWT.
*
* <p>Three clients are registered, one per pattern the article covers:
* <ul>
* <li>{@code spa-client} &mdash; authorization_code + PKCE. This is where the <em>user's</em>
* token comes from. {@code scripts/user-token.sh} drives the whole browser flow with curl.</li>
* <li>{@code edge-service} &mdash; client_credentials. The edge service's own identity, with no
* user anywhere in it.</li>
* <li>{@code edge-exchange} &mdash; urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693),
* for trading the user's token for one scoped to the downstream service.</li>
* </ul>
*
* <p>Tokens carry an {@code aud} claim naming {@code downstream-api}. Chapter 4 is about what
* happens to that claim by default, which is nothing.
*
* <p>See <a href="../../../../../docs/01-the-four-processes.md">docs/01-the-four-processes.md</a>.
*/
@SpringBootApplication
public class AuthServerApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "authserver");
SpringApplication.run(AuthServerApplication.class, args);
}
/**
* Note what this is <em>not</em>. Every Spring Authorization Server tutorial written
* before 7.0 starts with
* {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)}. That class,
* and the whole
* {@code org.springframework.security.oauth2.server.authorization.config.annotation.web.*}
* package tree, is <b>absent</b> from {@code spring-security-oauth2-authorization-server}
* 7.1.1. The configurer moved into {@code spring-security-config} at
* {@code org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization},
* and the entry point is now the DSL method {@code HttpSecurity.oauth2AuthorizationServer(..)}.
* The old call is a compile error, not a deprecation.
*/
@Bean
@Order(1)
SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer();
return http
.securityMatcher(configurer.getEndpointsMatcher())
.with(configurer, (server) -> server.oidc(Customizer.withDefaults()))
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.csrf((csrf) -> csrf.ignoringRequestMatchers(configurer.getEndpointsMatcher()))
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.build();
}
@Bean
@Order(2)
SecurityFilterChain loginChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.build();
}
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build());
}
@Bean
RegisteredClientRepository registeredClients() {
TokenSettings tokens = TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.accessTokenTimeToLive(Duration.ofMinutes(10))
.build();
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("spa-client")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://127.0.0.1:8080/authorized")
.scope("orders.read")
.scope("orders.write")
.tokenSettings(tokens)
// Explicit, because the default is not what the property name suggests - see
// docs/01-the-four-processes.md. Without this the authorization_code flow stops
// at a consent page even for a client that never asked for consent.
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.build();
RegisteredClient edge = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("edge-service")
.clientSecret("{noop}edge-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(tokens)
.build();
RegisteredClient exchange = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("edge-exchange")
.clientSecret("{noop}exchange-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
.scope("orders.read")
.tokenSettings(tokens)
.build();
// A client that has nothing to do with the downstream service. Its tokens carry
// aud: reporting-api. Chapter 4 sends one of them to the downstream service and
// watches it be accepted.
RegisteredClient reporting = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("reporting-service")
.clientSecret("{noop}reporting-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(tokens)
.build();
return new InMemoryRegisteredClientRepository(spa, edge, exchange, reporting);
}
/**
* Adds {@code aud: downstream-api} to every access token, so chapter 4 has something to not
* validate.
*/
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> audienceCustomizer() {
return (context) -> {
if (!"access_token".equals(context.getTokenType().getValue())) {
return;
}
String clientId = context.getRegisteredClient().getClientId();
// Every token names the service it was minted for. Whether the receiving service
// looks at that claim is chapter 4, and the answer by default is no.
String audience = "reporting-service".equals(clientId) ? "reporting-api" : "downstream-api";
context.getClaims().audience(java.util.List.of(audience));
// RFC 9068 says an access token should be typed `at+jwt` and carry `client_id`.
// Spring Authorization Server emits neither by default, and
// JwtValidators.createAtJwtValidator() requires both. Behind a flag, because
// turning it on breaks a stock NimbusJwtDecoder - see
// docs/04-what-is-not-validated.md.
if (Boolean.getBoolean("AT_JWT")) {
context.getJwsHeader().type("at+jwt");
context.getClaims().claim("client_id", clientId);
}
};
}
@Bean
JWKSource<SecurityContext> jwkSource() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
RSAKey key = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
return new ImmutableJWKSet<>(new JWKSet(key));
}
@Bean
AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().issuer("http://127.0.0.1:9000").build();
}
}

View File

@@ -0,0 +1,126 @@
package com.ankurm.s2s.downstream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The service at the end of the chain, on port 8082. It is a plain resource server, and its job
* in this repository is to report <em>who it thinks is calling</em>.
*
* <p>{@code /orders} echoes back the {@code sub}, {@code aud}, {@code scope} and {@code client_id}
* of the token that reached it. That is the whole demonstration: relay a user's token and
* {@code sub} is {@code alice}; use client credentials and {@code sub} is {@code edge-service}
* and the user has disappeared from the system of record.
*
* <p>Run with {@code -DSTRICT=true} to install the audience and issuer validation that the
* defaults leave out &mdash; see
* <a href="../../../../../docs/04-what-is-not-validated.md">docs/04-what-is-not-validated.md</a>.
*/
@SpringBootApplication
@RestController
public class DownstreamApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "downstream");
SpringApplication.run(DownstreamApplication.class, args);
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/orders/**").hasAuthority("SCOPE_orders.read")
.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.csrf((csrf) -> csrf.disable())
.build();
}
/**
* The decoder. Which validators it carries is the entire subject of chapter 4.
*
* <p>{@code JwtValidators.createDefault()} is a {@code DelegatingOAuth2TokenValidator} over
* three things: {@code JwtTypeValidator}, {@code JwtTimestampValidator} and
* {@code X509CertificateThumbprintValidator}. No issuer. No audience. Boot's
* auto-configuration adds an issuer validator when you set {@code issuer-uri}; nothing adds
* an audience validator unless you do.
*/
@Bean
JwtDecoder jwtDecoder() {
boolean strict = Boolean.getBoolean("STRICT");
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("http://127.0.0.1:9000/oauth2/jwks")
// NimbusJwtDecoder's default JOSE type verifier accepts `JWT` and an absent
// `typ`, and nothing else. An RFC 9068-compliant authorization server types its
// access tokens `at+jwt`, and against the default decoder every one of them is
// rejected with "the given typ value needs to be one of [JWT]" - a message that
// says nothing about RFC 9068 and reads like a corrupt token.
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
OAuth2TokenValidator<Jwt> validators = strict
// createAtJwtValidator() enforces the RFC 9068 profile: typ, exp, sub, iat,
// jti, iss, aud and client_id must all be present, on top of the issuer and
// audience values named here. A token from an authorization server that does
// not emit client_id fails this even when the issuer and audience are right.
? JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build()
// The default: a DelegatingOAuth2TokenValidator over three validators, read
// back by reflection in ValidatorContractTests - JwtTypeValidator,
// JwtTimestampValidator and X509CertificateThumbprintValidator. Note what is
// NOT in that list. No issuer. No audience. No scope. A structurally valid,
// unexpired token minted by this issuer for a completely different service
// passes, which is docs/output/03-audience-ignored.txt.
: JwtValidators.createDefault();
decoder.setJwtValidator(validators);
return decoder;
}
@GetMapping("/orders")
Map<String, Object> orders(Authentication authentication) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("service", "downstream:8082");
body.put("strictValidation", Boolean.getBoolean("STRICT"));
if (authentication instanceof JwtAuthenticationToken token) {
Jwt jwt = token.getToken();
body.put("sub", jwt.getSubject());
body.put("aud", jwt.getAudience());
body.put("iss", String.valueOf(jwt.getIssuer()));
body.put("scope", jwt.getClaimAsString("scope"));
body.put("client_id", jwt.getClaimAsString("client_id"));
body.put("authorities", token.getAuthorities().stream().map(Object::toString).toList());
body.put("cnf", jwt.getClaim("cnf"));
}
body.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
return body;
}
@GetMapping("/orders/whoami")
Map<String, Object> whoami(Authentication authentication) {
return Map.of("principal", authentication.getName(), "type",
authentication.getClass().getSimpleName());
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.s2s.edge;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.TokenExchangeOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
/**
* The {@code OAuth2AuthorizedClientManager} the interceptor asks for tokens.
*
* <p>Two details matter and both are easy to get wrong.
*
* <p><b>Which manager.</b> {@code DefaultOAuth2AuthorizedClientManager} is request-scoped and
* needs an {@code HttpServletRequest}; it is for a browser-facing client. For service-to-service
* calls, where there is no end user whose consent is being managed, the right one is
* {@code AuthorizedClientServiceOAuth2AuthorizedClientManager}, which stores authorized clients
* in an {@code OAuth2AuthorizedClientService} and works on any thread.
*
* <p><b>Which providers.</b> The builder's defaults do not include token exchange, so
* {@code TokenExchangeOAuth2AuthorizedClientProvider} has to be added explicitly. Leave it out
* and the {@code edge-exchange} registration silently produces no token &mdash; the manager
* returns {@code null} rather than raising anything.
*/
@Configuration
public class ClientManagerConfig {
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clientService) {
var provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
.build();
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
manager.setAuthorizedClientProvider(provider);
return manager;
}
}

View File

@@ -0,0 +1,89 @@
package com.ankurm.s2s.edge;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId;
/**
* Four {@link RestClient} instances, differing only in what they put in the
* {@code Authorization} header. Everything else &mdash; base URL, path, error handling &mdash;
* is identical, so the transcripts differ in exactly one variable.
*
* <p>See <a href="../../../../../docs/03-restclient-interceptors.md">docs/03-restclient-interceptors.md</a>.
*/
@Component
public class DownstreamClients {
private static final String DOWNSTREAM = "http://127.0.0.1:8082";
private final RestClient plain;
private final RestClient relay;
private final RestClient managed;
public DownstreamClients(RestClient.Builder builder,
OAuth2AuthorizedClientManager authorizedClientManager) {
this.plain = builder.clone().baseUrl(DOWNSTREAM).build();
// Hand-rolled relay. Ten lines, no dependency on the OAuth2 client machinery, and it
// forwards the caller's identity unchanged - which is exactly what you want for an
// internal hop and exactly what you do NOT want for a call that leaves your trust
// boundary. The token's audience and scope were minted for the original request.
this.relay = builder.clone()
.baseUrl(DOWNSTREAM)
.requestInterceptor((request, body, execution) -> {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
return execution.execute(request, body);
})
.build();
// The framework's interceptor. It resolves a ClientRegistration id from a request
// attribute, asks the OAuth2AuthorizedClientManager for a token under that
// registration, caches it, and refreshes it when it expires. Which registration is
// chosen per call, via clientRegistrationId(..) below.
this.managed = builder.clone()
.baseUrl(DOWNSTREAM)
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
.build();
}
public Object plain() {
return get(this.plain, null);
}
public Object relayed() {
return get(this.relay, null);
}
public Object clientCredentials() {
return get(this.managed, "edge-service");
}
public Object exchanged() {
return get(this.managed, "edge-exchange");
}
@SuppressWarnings("unchecked")
private static Object get(RestClient client, String registrationId) {
var request = client.get().uri("/orders");
if (registrationId != null) {
request = request.attributes(clientRegistrationId(registrationId));
}
return request.header(HttpHeaders.ACCEPT, "application/json")
.retrieve()
.body(Map.class);
}
}

View File

@@ -0,0 +1,115 @@
package com.ankurm.s2s.edge;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The middle service, on port 8081. It is simultaneously a resource server (it validates the
* token that arrives) and an OAuth2 client (it needs a token to call
* {@code downstream}). Every strategy for producing that second token is an endpoint here, so
* the difference between them is a diff of two JSON bodies rather than an argument.
*
* <table>
* <caption>Endpoints</caption>
* <tr><th>Endpoint</th><th>Strategy</th></tr>
* <tr><td>{@code /edge/naive}</td><td>No propagation at all &mdash; the control</td></tr>
* <tr><td>{@code /edge/relay}</td><td>Relay the incoming bearer token</td></tr>
* <tr><td>{@code /edge/client-credentials}</td><td>The service's own token</td></tr>
* <tr><td>{@code /edge/exchange}</td><td>RFC 8693 token exchange</td></tr>
* <tr><td>{@code /edge/relay-async}</td><td>Relay from a different thread &mdash; the trap</td></tr>
* </table>
*
* <p>See <a href="../../../../../docs/02-three-ways-to-get-a-token.md">docs/02-three-ways-to-get-a-token.md</a>.
*/
@SpringBootApplication
@RestController
public class EdgeApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "edge");
SpringApplication.run(EdgeApplication.class, args);
}
private final DownstreamClients clients;
public EdgeApplication(DownstreamClients clients) {
this.clients = clients;
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.csrf((csrf) -> csrf.disable())
.build();
}
@GetMapping("/edge/naive")
Map<String, Object> naive() {
return wrap("no token forwarded", this.clients::plain);
}
@GetMapping("/edge/relay")
Map<String, Object> relay() {
return wrap("bearer token relayed from the incoming request", this.clients::relayed);
}
@GetMapping("/edge/client-credentials")
Map<String, Object> clientCredentials() {
return wrap("the edge service's own client_credentials token", this.clients::clientCredentials);
}
@GetMapping("/edge/exchange")
Map<String, Object> exchange() {
return wrap("RFC 8693 token exchange", this.clients::exchanged);
}
/**
* The trap. The relay interceptor reads the token from {@code SecurityContextHolder}, which
* is a {@code ThreadLocal}. Make the downstream call on a thread the request did not create
* and there is nothing there to read.
*/
@GetMapping("/edge/relay-async")
Map<String, Object> relayAsync() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
return wrap("relay attempted from a separate thread",
() -> executor.submit(this.clients::relayed).get());
}
catch (Exception ex) {
return Map.of("strategy", "relay attempted from a separate thread",
"error", unwrap(ex));
}
}
private static Map<String, Object> wrap(String strategy, Callable<Object> call) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("strategy", strategy);
try {
out.put("downstream", call.call());
}
catch (Exception ex) {
out.put("error", unwrap(ex));
}
return out;
}
private static String unwrap(Throwable throwable) {
Throwable root = throwable;
while (root.getCause() != null) {
root = root.getCause();
}
return root.getClass().getSimpleName() + ": " + root.getMessage();
}
}

View File

@@ -0,0 +1,27 @@
package com.ankurm.s2s.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring Cloud Gateway Server MVC, on port 8080, configured entirely in
* {@code src/main/resources/gateway.yml}.
*
* <p>The point of interest is the {@code TokenRelay} filter, which Spring Cloud provides as
* {@code TokenRelayFilterFunctions.tokenRelay()} and
* {@code TokenRelayFilterFunctions.tokenRelay(String clientRegistrationId)}. It takes the access
* token of the currently authenticated user &mdash; or of the named client registration &mdash;
* and puts it in the {@code Authorization} header of the proxied request.
*
* <p>Read <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a> before
* copying any of this: Spring Cloud is a separate release train with its own Boot baseline, and
* the gateway's own {@code TokenRelay} does something narrower than the name suggests.
*/
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "gateway");
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,32 @@
package com.ankurm.s2s.gateway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The gateway does not authenticate anything. It proxies.
*
* <p>This bean exists because without it Boot applies its default chain &mdash; HTTP Basic and
* form login over every path &mdash; and a caller presenting a perfectly good
* {@code Authorization: Bearer} header receives {@code 401 WWW-Authenticate: Basic} from the
* gateway, having never reached anything. The token was fine. The gateway had never been told
* what to do with it.
*
* <p>A real gateway is usually the opposite of this: it terminates the user session with
* {@code oauth2Login()} and issues nothing downstream but a token it obtained itself. That
* configuration is what Spring Cloud's {@code TokenRelay} filter is written for &mdash; see
* <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a>.
*/
@Configuration
public class GatewaySecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,95 @@
package com.ankurm.s2s.mtls;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* A resource server that authenticates callers by client certificate rather than by token, on
* port 8443. It exists so the article's mTLS section is a transcript rather than a diagram.
*
* <p>Run {@code ./scripts/certs.sh} first. Then:
*
* <pre>
* curl --cacert target/certs/internal-ca.crt \
* --cert target/certs/edge.crt --key target/certs/edge.key \
* https://localhost:8443/mtls/whoami
* </pre>
*
* <p>{@code rogue.crt} carries an identical subject and a different issuer, and the TLS
* handshake &mdash; not Spring Security &mdash; is what rejects it.
*
* <p>{@code /mtls/trusted-header} is the counterpart: an endpoint that believes an
* {@code X-Client-Cert-Subject} header, the way an application behind a service mesh usually
* does. See <a href="../../../../../docs/06-mtls.md">docs/06-mtls.md</a>.
*/
@SpringBootApplication
@RestController
public class MtlsApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "mtls");
SpringApplication.run(MtlsApplication.class, args);
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/mtls/trusted-header").permitAll()
.anyRequest().authenticated())
// x509() maps the certificate's subject to a principal name and looks it up in a
// UserDetailsService. The certificate was already verified against the trust store
// by the TLS layer before any of this ran; Spring Security is deciding what the
// verified identity is allowed to do, not whether the certificate is genuine.
.x509((x509) -> x509
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
.userDetailsService(certificateUsers()))
.csrf((csrf) -> csrf.disable())
.build();
}
UserDetailsService certificateUsers() {
return (username) -> User.withUsername(username).password("{noop}unused").roles("SERVICE").build();
}
@GetMapping("/mtls/whoami")
Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("principal", authentication.getName());
out.put("authenticationType", authentication.getClass().getSimpleName());
out.put("authorities", authentication.getAuthorities().stream().map(Object::toString).toList());
var chain = (java.security.cert.X509Certificate[]) request
.getAttribute("jakarta.servlet.request.X509Certificate");
if (chain != null && chain.length > 0) {
out.put("certificateSubject", chain[0].getSubjectX500Principal().getName());
out.put("certificateIssuer", chain[0].getIssuerX500Principal().getName());
}
return out;
}
/**
* What an application inside a mesh usually does: trust a header the sidecar is supposed to
* have set. Nothing here verifies that the header came from the sidecar. If anything can
* reach this port without going through the proxy &mdash; another pod, a debug
* port-forward, a misconfigured NetworkPolicy &mdash; it can name itself whatever it likes.
*/
@GetMapping("/mtls/trusted-header")
Map<String, Object> trustedHeader(HttpServletRequest request) {
String claimed = request.getHeader("X-Client-Cert-Subject");
return Map.of("caller", (claimed != null) ? claimed : "(header absent)",
"verifiedBy", "nothing. This endpoint believes a header.");
}
}

View File

@@ -0,0 +1,8 @@
server:
port: 9000
spring:
application:
name: authserver
logging:
level:
org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,17 @@
server:
port: 8082
spring:
application:
name: downstream
security:
oauth2:
resourceserver:
jwt:
# Deliberately jwk-set-uri and not issuer-uri. With issuer-uri, Boot builds the
# decoder from the discovery document and installs a JwtIssuerValidator; the bean in
# DownstreamApplication overrides that so the article can show the two validator sets
# side by side. Chapter 4.
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
logging:
level:
org.springframework.security.oauth2.server.resource: ${RS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,39 @@
server:
port: 8081
spring:
application:
name: edge
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
client:
provider:
local:
# Deliberately NOT issuer-uri. `issuer-uri` makes ClientRegistrations fetch
# /.well-known/openid-configuration during context refresh, so this service cannot
# start unless the authorization server is already up - a hard startup-ordering
# dependency between two deployments, with a stack trace ending in
# `ResourceAccessException: I/O error on GET request for
# ".../.well-known/openid-configuration": Connection refused` rather than
# anything that mentions ordering. Naming the endpoints removes it.
# See docs/01-the-four-processes.md.
token-uri: http://127.0.0.1:9000/oauth2/token
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
registration:
edge-service:
provider: local
client-id: edge-service
client-secret: edge-secret
authorization-grant-type: client_credentials
scope: orders.read
edge-exchange:
provider: local
client-id: edge-exchange
client-secret: exchange-secret
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
scope: orders.read
logging:
level:
org.springframework.security.oauth2.client: ${CLIENT_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,32 @@
server:
port: 8080
spring:
application:
name: gateway
cloud:
gateway:
server:
webmvc:
routes:
# Two routes to the same place. The only difference is the TokenRelay filter,
# which is the point: compare docs/output/04-gateway-token-relay.txt.
- id: edge-with-relay
uri: http://127.0.0.1:8081
predicates:
- Path=/edge/**
filters:
# TokenRelay with no argument forwards the CURRENTLY AUTHENTICATED USER's
# access token - the one obtained by oauth2Login(). It does not forward an
# incoming bearer token; the incoming header is simply proxied like any other.
# Chapter 5.
- TokenRelay=
- id: edge-without-relay
uri: http://127.0.0.1:8081
predicates:
- Path=/norelay/**
filters:
- StripPrefix=1
- SetPath=/edge/relay
logging:
level:
org.springframework.cloud.gateway: ${GATEWAY_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,26 @@
server:
port: 8443
ssl:
bundle: server
# NEED, not WANT. With `want`, a client that presents no certificate still completes the
# handshake and arrives unauthenticated - which is what you want if some endpoints are
# public, and a silent hole if you assumed the connector was doing the authorizing.
client-auth: need
spring:
application:
name: mtls
ssl:
bundle:
pem:
server:
keystore:
certificate: file:target/certs/server.crt
private-key: file:target/certs/server.key
truststore:
# The list of CAs whose certificates this service will accept. This file is the
# entire trust decision. rogue.crt has the same subject as edge.crt and a
# different issuer, and this is the line that tells them apart.
certificate: file:target/certs/internal-ca.crt
logging:
level:
org.springframework.security.web.authentication.preauth.x509: ${X509_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,158 @@
package com.ankurm.s2s;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What the JWT validators actually check. These assertions exist because the answer surprised
* me while writing the companion repository, and because a future Spring Security release that
* changes the default set should break a test rather than a paragraph.
*
* <p>The full end-to-end evidence is in {@code docs/output/03-audience-ignored.txt} and
* {@code docs/output/05-strict-validation.txt}; these are the same facts pinned in-process.
*/
class ValidatorContractTests {
/** An access token typed the way RFC 9068 requires. */
private static Jwt atJwt(Map<String, Object> claims) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.header("typ", "at+jwt")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(600))
.subject("alice");
claims.forEach(builder::claim);
return builder.build();
}
@Test
@DisplayName("the at+jwt validator rejects a token typed JWT, with a message one word from the Nimbus one")
void atJwtRejectsPlainJwtType() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt plain = jwt(Map.of("aud", List.of("downstream-api"), "iss", "http://127.0.0.1:9000",
"jti", "id", "client_id", "edge-service"));
var result = validator.validate(plain);
assertThat(result.hasErrors()).isTrue();
// Spring's JwtTypeValidator: "the given typ value needs to be one of
// [at+jwt, application/at+jwt]"
// Nimbus's DefaultJOSEObjectTypeVerifier, one layer lower: "the given typ value needs
// to be one of [JWT]"
// Nearly the same sentence from two different components, meaning opposite things.
assertThat(result.getErrors().iterator().next().getDescription())
.contains("at+jwt", "application/at+jwt");
}
private static Jwt jwt(Map<String, Object> claims) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(600))
.subject("alice");
claims.forEach(builder::claim);
return builder.build();
}
@Test
@DisplayName("the default validator set does not look at the audience")
void defaultIgnoresAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
Jwt token = jwt(Map.of("aud", List.of("some-other-service"),
"iss", "https://an-issuer-we-never-heard-of.example.com"));
assertThat(validator.validate(token).hasErrors()).isFalse();
}
@Test
@DisplayName("the default validator set does not look at the issuer either")
void defaultIgnoresIssuer() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
assertThat(validator.validate(jwt(Map.of("iss", "https://evil.example.com"))).hasErrors())
.isFalse();
}
@Test
@DisplayName("the default validator set does reject an expired token")
void defaultRejectsExpired() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
Jwt expired = Jwt.withTokenValue("token")
.header("alg", "RS256")
.issuedAt(Instant.now().minusSeconds(7200))
.expiresAt(Instant.now().minusSeconds(3600))
.subject("alice")
.build();
assertThat(validator.validate(expired).hasErrors()).isTrue();
}
@Test
@DisplayName("createDefault() delegates to three validators, none of which is about identity")
void defaultDelegates() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
assertThat(validator).isInstanceOf(DelegatingOAuth2TokenValidator.class);
// Reading the delegate list back rather than trusting the disassembly.
var field = org.springframework.util.ReflectionUtils
.findField(DelegatingOAuth2TokenValidator.class, "tokenValidators");
org.springframework.util.ReflectionUtils.makeAccessible(field);
@SuppressWarnings("unchecked")
var delegates = (java.util.Collection<OAuth2TokenValidator<Jwt>>) org.springframework.util.ReflectionUtils
.getField(field, validator);
// Reading this back is why the article says three rather than two: the disassembly
// of createDefault() shows JwtTimestampValidator and X509CertificateThumbprintValidator
// being constructed, and JwtTypeValidator arrives from createDefaultWithValidators.
assertThat(delegates.stream().map((d) -> d.getClass().getSimpleName()))
.containsExactlyInAnyOrder("JwtTypeValidator", "JwtTimestampValidator",
"X509CertificateThumbprintValidator");
}
@Test
@DisplayName("the at+jwt validator rejects a token whose audience names another service")
void atJwtRejectsWrongAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt token = atJwt(Map.of("aud", List.of("reporting-api"), "iss", "http://127.0.0.1:9000",
"jti", "id", "client_id", "reporting-service"));
assertThat(validator.validate(token).hasErrors()).isTrue();
}
@Test
@DisplayName("the at+jwt validator requires a client_id claim, which Spring Authorization Server does not emit by default")
void atJwtRequiresClientId() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt withoutClientId = jwt(Map.of("aud", List.of("downstream-api"),
"iss", "http://127.0.0.1:9000", "jti", "id"));
assertThat(validator.validate(withoutClientId).hasErrors()).isTrue();
Jwt withClientId = atJwt(Map.of("aud", List.of("downstream-api"),
"iss", "http://127.0.0.1:9000", "jti", "id", "client_id", "edge-service"));
assertThat(validator.validate(withClientId).hasErrors()).isFalse();
}
@Test
@DisplayName("createDefaultWithIssuer adds an issuer check and still no audience check")
void withIssuerStillIgnoresAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithIssuer("http://127.0.0.1:9000");
Jwt wrongIssuer = jwt(Map.of("iss", "https://evil.example.com", "aud", List.of("x")));
assertThat(validator.validate(wrongIssuer).hasErrors()).isTrue();
Jwt rightIssuerWrongAudience = jwt(Map.of("iss", "http://127.0.0.1:9000",
"aud", List.of("some-other-service")));
assertThat(validator.validate(rightIssuerWrongAudience).hasErrors()).isFalse();
}
}