Compare commits
2 Commits
3170adef18
...
0fceb2cd4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fceb2cd4e | |||
| cad813e1ae |
43
README.md
43
README.md
@@ -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 |
|
| [`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 |
|
| [`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 |
|
| [`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
|
They are related more closely than they look. `filter-chain` is about how an `Authentication`
|
||||||
`Authentication` gets into `SecurityContextHolder` in the first place and in what order;
|
gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
|
||||||
`context-propagation` is about whether it survives leaving the request thread; `method-security`
|
about whether it survives leaving the request thread; `method-security` reads it back on whatever
|
||||||
reads it back on whatever thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails
|
thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with
|
||||||
with `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module,
|
`AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, not
|
||||||
not the third — and a custom authentication filter that never populated the context in the first
|
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.
|
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
|
## 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**
|
**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
|
manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from
|
||||||
release announcements.
|
release announcements.
|
||||||
|
|
||||||
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
|
`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
|
still a preview API on JDK 25. `method-security` does not. `filter-chain`, `cors-csrf` and
|
||||||
that is a real servlet application: it inherits `spring-boot-starter-parent` and runs on Tomcat,
|
`service-to-service` are real servlet applications: they inherit `spring-boot-starter-parent`
|
||||||
because the thing it demonstrates only exists inside a servlet container.
|
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
|
## Running a module
|
||||||
|
|
||||||
```bash
|
```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/
|
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
|
||||||
mvn test # just the assertions
|
mvn test # just the assertions
|
||||||
```
|
```
|
||||||
|
|
||||||
`filter-chain` also has `./scripts/run.sh <profile>` and `./scripts/stop.sh`, because its
|
`filter-chain` and `cors-csrf` also have `./scripts/run.sh <profile>` and `./scripts/stop.sh`,
|
||||||
scenarios are a running web application rather than a `main()` method.
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
117
cors-csrf/README.md
Normal file
117
cors-csrf/README.md
Normal 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
|
||||||
82
cors-csrf/docs/01-two-layers.md
Normal file
82
cors-csrf/docs/01-two-layers.md
Normal 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** — 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** — 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` — the builder behind `addCorsMappings` — 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)*
|
||||||
99
cors-csrf/docs/02-who-resolves-the-source.md
Normal file
99
cors-csrf/docs/02-who-resolves-the-source.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 2. Who resolves the `CorsConfigurationSource`
|
||||||
|
|
||||||
|
*Prev: [1. Two layers, one word](01-two-layers.md) · 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` — "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 — 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) · Next: [3. The three identical 403s](03-three-identical-403s.md)*
|
||||||
79
cors-csrf/docs/03-three-identical-403s.md
Normal file
79
cors-csrf/docs/03-three-identical-403s.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 3. The three identical 403s
|
||||||
|
|
||||||
|
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · 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 — 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 — chapter 1 |
|
||||||
|
| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path — chapter 2 |
|
||||||
|
| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers — 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 — **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) · Next: [4. Preflight handlers](04-preflight-handlers.md)*
|
||||||
73
cors-csrf/docs/04-preflight-handlers.md
Normal file
73
cors-csrf/docs/04-preflight-handlers.md
Normal 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) · 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 — `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 — 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 — 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` — 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) · Next: [5. The /error dispatch](05-the-error-dispatch.md)*
|
||||||
79
cors-csrf/docs/05-the-error-dispatch.md
Normal file
79
cors-csrf/docs/05-the-error-dispatch.md
Normal 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) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*
|
||||||
|
|
||||||
|
Two of the failures in this module — the wildcard/credentials clash in chapter 4 and the
|
||||||
|
CSRF rejection in chapter 6 — 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 —
|
||||||
|
`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) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*
|
||||||
114
cors-csrf/docs/06-csrf-for-spas.md
Normal file
114
cors-csrf/docs/06-csrf-for-spas.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 6. CSRF for SPAs, and what `spa()` actually assigns
|
||||||
|
|
||||||
|
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · 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 — 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`** — 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 — including a
|
||||||
|
`HttpOnly` refresh cookie — then yes, and `spa()` is the shortest correct configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*
|
||||||
111
cors-csrf/docs/07-samesite.md
Normal file
111
cors-csrf/docs/07-samesite.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 7. SameSite, `Secure`, and the cookie that is never stored
|
||||||
|
|
||||||
|
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) · 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". Chromium-based browsers treat it as `Lax`; Firefox has
|
||||||
|
**not** enabled Lax-by-default on its release channel (`network.cookie.sameSite.laxByDefault` is on in
|
||||||
|
Nightly only). The two disagree, which is why "it works in Firefox and not in Chrome" is so often a
|
||||||
|
missing `SameSite` attribute. `SpecCookieJar` models the Chromium behaviour, because that is the one
|
||||||
|
you have to survive.
|
||||||
|
|
||||||
|
## The two rules that matter
|
||||||
|
|
||||||
|
**Storage (RFC 6265bis §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 §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** | — |
|
||||||
|
| `JSESSIONID=s3; Secure; HttpOnly; SameSite=None` | yes | **yes** |
|
||||||
|
| `XSRF-TOKEN=t1` (no SameSite) | yes | no |
|
||||||
|
| `XSRF-TOKEN=t2; SameSite=None` | **no** | — |
|
||||||
|
| `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`
|
||||||
|
— 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 — 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) · Next: [8. Debugging recipes](08-debugging-recipes.md)*
|
||||||
77
cors-csrf/docs/08-debugging-recipes.md
Normal file
77
cors-csrf/docs/08-debugging-recipes.md
Normal 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 — 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 — no `CorsFilter` |
|
||||||
|
| Preflight `200`, no CORS headers | Chapter 2 — bean name |
|
||||||
|
| Preflight `403`, `Invalid CORS request` | Chapter 3 — read the DEBUG line |
|
||||||
|
| `401` on a request with valid credentials | Chapter 5 — the `/error` dispatch |
|
||||||
|
| `403` on a POST, `GET` is fine | Chapter 6 — CSRF |
|
||||||
|
| Cookie visible in DevTools' response, absent from the jar | Chapter 7 — `SameSite=None` with no `Secure` |
|
||||||
|
| Every request preflights, latency doubled | Chapter 1 — 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 → 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)*
|
||||||
58
cors-csrf/docs/output/01-mvc-only.txt
Normal file
58
cors-csrf/docs/output/01-mvc-only.txt
Normal 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.
|
||||||
56
cors-csrf/docs/output/02-mvc-bridge.txt
Normal file
56
cors-csrf/docs/output/02-mvc-bridge.txt
Normal 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.
|
||||||
36
cors-csrf/docs/output/03-security-source.txt
Normal file
36
cors-csrf/docs/output/03-security-source.txt
Normal 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.
|
||||||
47
cors-csrf/docs/output/04-three-identical-403s.txt
Normal file
47
cors-csrf/docs/output/04-three-identical-403s.txt
Normal 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
|
||||||
39
cors-csrf/docs/output/05-misnamed-bean.txt
Normal file
39
cors-csrf/docs/output/05-misnamed-bean.txt
Normal 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.
|
||||||
44
cors-csrf/docs/output/06-two-sources.txt
Normal file
44
cors-csrf/docs/output/06-two-sources.txt
Normal 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
|
||||||
41
cors-csrf/docs/output/07-wildcard-credentials.txt
Normal file
41
cors-csrf/docs/output/07-wildcard-credentials.txt
Normal 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.
|
||||||
54
cors-csrf/docs/output/08-csrf-naive.txt
Normal file
54
cors-csrf/docs/output/08-csrf-naive.txt
Normal 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
|
||||||
25
cors-csrf/docs/output/09-error-dispatch.txt
Normal file
25
cors-csrf/docs/output/09-error-dispatch.txt
Normal 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.
|
||||||
47
cors-csrf/docs/output/10-csrf-spa.txt
Normal file
47
cors-csrf/docs/output/10-csrf-spa.txt
Normal 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.
|
||||||
24
cors-csrf/docs/output/11-spa-ordering.txt
Normal file
24
cors-csrf/docs/output/11-spa-ordering.txt
Normal 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.
|
||||||
59
cors-csrf/docs/output/12-samesite.txt
Normal file
59
cors-csrf/docs/output/12-samesite.txt
Normal 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.)
|
||||||
34
cors-csrf/docs/output/13-tests.txt
Normal file
34
cors-csrf/docs/output/13-tests.txt
Normal 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
55
cors-csrf/pom.xml
Normal 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
25
cors-csrf/scripts/preflight.sh
Executable 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
355
cors-csrf/scripts/run-all.sh
Executable 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
37
cors-csrf/scripts/run.sh
Executable 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
11
cors-csrf/scripts/stop.sh
Executable 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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* — a plain {@code CsrfTokenRequestAttributeHandler} with
|
||||||
|
* {@code setCsrfRequestAttributeName(null)}, and an {@code XorCsrfTokenRequestAttributeHandler}
|
||||||
|
* — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* — {@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 §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 — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — before {@code CsrfFilter} (1100) and a long way before
|
||||||
|
* {@code AuthorizationFilter} (4200) — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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 — 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 —
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
cors-csrf/src/main/java/com/ankurm/cors/config/Users.java
Normal file
18
cors-csrf/src/main/java/com/ankurm/cors/config/Users.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 —
|
||||||
|
* {@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
cors-csrf/src/main/java/com/ankurm/cors/spec/SpecCookieJar.java
Normal file
144
cors-csrf/src/main/java/com/ankurm/cors/spec/SpecCookieJar.java
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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>§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 — the cookie simply never
|
||||||
|
* exists.</li>
|
||||||
|
* <li><b>§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} — by Chromium-based browsers.
|
||||||
|
* Firefox has not enabled Lax-by-default on its release channel, so it still treats an absent
|
||||||
|
* attribute as unrestricted. This jar models the Chromium behaviour, because that is the one
|
||||||
|
* a deployment has to survive.</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 in Chromium-based browsers, which is where the modern
|
||||||
|
// default bites. Firefox's release channel still treats an absent attribute as
|
||||||
|
// unrestricted; modelling the stricter of the two is the useful choice.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
cors-csrf/src/main/resources/application.yml
Normal file
25
cors-csrf/src/main/resources/application.yml
Normal 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}
|
||||||
221
cors-csrf/src/test/java/com/ankurm/cors/CorsContractTests.java
Normal file
221
cors-csrf/src/test/java/com/ankurm/cors/CorsContractTests.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
193
cors-csrf/src/test/java/com/ankurm/cors/CsrfAndCookieTests.java
Normal file
193
cors-csrf/src/test/java/com/ankurm/cors/CsrfAndCookieTests.java
Normal 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;
|
||||||
|
}
|
||||||
103
service-to-service/README.md
Normal file
103
service-to-service/README.md
Normal 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
|
||||||
75
service-to-service/docs/01-the-four-processes.md
Normal file
75
service-to-service/docs/01-the-four-processes.md
Normal 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)*
|
||||||
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal 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) · 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) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||||
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 3. `RestClient` interceptors
|
||||||
|
|
||||||
|
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · 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) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||||
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 4. What a resource server does not validate
|
||||||
|
|
||||||
|
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · 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) · Next: [5. The gateway](05-the-gateway.md)*
|
||||||
74
service-to-service/docs/05-the-gateway.md
Normal file
74
service-to-service/docs/05-the-gateway.md
Normal 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) · 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) · Next: [6. mTLS](06-mtls.md)*
|
||||||
90
service-to-service/docs/06-mtls.md
Normal file
90
service-to-service/docs/06-mtls.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 6. mTLS: in the mesh or in the application
|
||||||
|
|
||||||
|
*Prev: [5. The gateway](05-the-gateway.md) · 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) · Next: [7. Choosing](07-choosing.md)*
|
||||||
53
service-to-service/docs/07-choosing.md
Normal file
53
service-to-service/docs/07-choosing.md
Normal 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)*
|
||||||
26
service-to-service/docs/output/01-user-token.txt
Normal file
26
service-to-service/docs/output/01-user-token.txt
Normal 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.
|
||||||
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
110
service-to-service/docs/output/02-five-strategies.txt
Normal 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.
|
||||||
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
53
service-to-service/docs/output/03-audience-ignored.txt
Normal 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.
|
||||||
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal 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'.
|
||||||
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
43
service-to-service/docs/output/05-strict-validation.txt
Normal 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.
|
||||||
43
service-to-service/docs/output/06-mtls.txt
Normal file
43
service-to-service/docs/output/06-mtls.txt
Normal 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.
|
||||||
8
service-to-service/docs/output/07-tests.txt
Normal file
8
service-to-service/docs/output/07-tests.txt
Normal 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
|
||||||
82
service-to-service/pom.xml
Normal file
82
service-to-service/pom.xml
Normal 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>
|
||||||
36
service-to-service/scripts/certs.sh
Executable file
36
service-to-service/scripts/certs.sh
Executable 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."
|
||||||
12
service-to-service/scripts/claims.sh
Executable file
12
service-to-service/scripts/claims.sh
Executable 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
|
||||||
214
service-to-service/scripts/run-all.sh
Executable file
214
service-to-service/scripts/run-all.sh
Executable 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"
|
||||||
64
service-to-service/scripts/run.sh
Executable file
64
service-to-service/scripts/run.sh
Executable 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"
|
||||||
23
service-to-service/scripts/stop.sh
Executable file
23
service-to-service/scripts/stop.sh
Executable 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
|
||||||
48
service-to-service/scripts/user-token.sh
Executable file
48
service-to-service/scripts/user-token.sh
Executable 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"])'
|
||||||
@@ -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} — 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} — client_credentials. The edge service's own identity, with no
|
||||||
|
* user anywhere in it.</li>
|
||||||
|
* <li>{@code edge-exchange} — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — base URL, path, error handling —
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 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 — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — or of the named client registration —
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — HTTP Basic and
|
||||||
|
* form login over every path — 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 — 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — not Spring Security — 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 — another pod, a debug
|
||||||
|
* port-forward, a misconfigured NetworkPolicy — 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
service-to-service/src/main/resources/authserver.yml
Normal file
8
service-to-service/src/main/resources/authserver.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
server:
|
||||||
|
port: 9000
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: authserver
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO}
|
||||||
17
service-to-service/src/main/resources/downstream.yml
Normal file
17
service-to-service/src/main/resources/downstream.yml
Normal 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}
|
||||||
39
service-to-service/src/main/resources/edge.yml
Normal file
39
service-to-service/src/main/resources/edge.yml
Normal 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}
|
||||||
32
service-to-service/src/main/resources/gateway.yml
Normal file
32
service-to-service/src/main/resources/gateway.yml
Normal 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}
|
||||||
26
service-to-service/src/main/resources/mtls.yml
Normal file
26
service-to-service/src/main/resources/mtls.yml
Normal 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}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user