Spring Security 7.1 JWT authentication on Spring Boot 4.1
Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
This commit is contained in:
132
docs/08-testing.md
Normal file
132
docs/08-testing.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 08 — Testing
|
||||
|
||||
[← edge cases](07-edge-cases.md) · [next: manual filter vs resource server →](09-manual-filter-vs-resource-server.md)
|
||||
|
||||
## The Boot 4 test-slice split
|
||||
|
||||
On Spring Boot 3, `spring-boot-starter-test` alone gave you `@AutoConfigureMockMvc`. On
|
||||
Boot 4 it does not — the test slices were moved into their own modules:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The package moved with it:
|
||||
|
||||
```java
|
||||
// Boot 3
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
// Boot 4
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
```
|
||||
|
||||
The compiler error is `package org.springframework.boot.test.autoconfigure.web.servlet
|
||||
does not exist`, which reads like a corrupt dependency rather than a relocation.
|
||||
|
||||
## What is worth pinning
|
||||
|
||||
13 tests, all passing — [`test-run.txt`](output/test-run.txt). The valuable ones assert
|
||||
things that are easy to break without noticing.
|
||||
|
||||
**The status-code contract.** Not "it works" but *which* failure code:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void missingTokenIs401NotA403() throws Exception {
|
||||
this.mvc.perform(get("/api/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate", containsString("Bearer")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenWithoutTheRoleIs403NotA401() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(header().string("WWW-Authenticate", containsString("insufficient_scope")));
|
||||
}
|
||||
```
|
||||
|
||||
**Non-disclosure.** A byte comparison, because a helpful message is a regression:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
|
||||
// ... both requests ...
|
||||
assertThat(locked.getResponse().getContentAsString())
|
||||
.isEqualTo(wrong.getResponse().getContentAsString());
|
||||
}
|
||||
```
|
||||
|
||||
**Filter order.** Ordering is configuration, and configuration drifts:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void csrfFilterRunsLongBeforeAuthorizationFilter() {
|
||||
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
|
||||
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
|
||||
|
||||
assertThat(filters.indexOf("AuthorizationFilter")).isEqualTo(filters.size() - 1);
|
||||
assertThat(filters.indexOf("CsrfFilter")).isLessThan(filters.indexOf("AuthorizationFilter"));
|
||||
}
|
||||
```
|
||||
|
||||
**Revocation and replay**, because both are easy to regress into no-ops.
|
||||
|
||||
## The `.with(csrf())` trap
|
||||
|
||||
`spring-security-test` provides a post-processor that attaches a valid CSRF token:
|
||||
|
||||
```java
|
||||
this.mvc.perform(post("/api/auth/login").with(csrf()) ... )
|
||||
```
|
||||
|
||||
Convenient, and it will make a test pass against a configuration that 403s in production.
|
||||
[`CsrfBreaksPermitAllTests`](../src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java)
|
||||
deliberately has both tests: one asserting the 403 **without** `csrf()`, one asserting the
|
||||
200 with it. If you only ever write the second, you have tested your test.
|
||||
|
||||
## `@WithMockUser` tests authorization, not authentication
|
||||
|
||||
```java
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void adminCanSeeStats() { ... }
|
||||
```
|
||||
|
||||
This installs an `Authentication` directly into the context and **bypasses the entire
|
||||
filter chain** — decoder, validators, `token_type` check, denylist. It is the right tool
|
||||
for testing `@PreAuthorize` rules and the wrong tool for testing that your JWT pipeline
|
||||
works. Every test in `AuthenticationFlowTests` goes through a real `POST /api/auth/login`
|
||||
and a real `Authorization` header for that reason.
|
||||
|
||||
`spring-security-test` also offers `SecurityMockMvcRequestPostProcessors.jwt()`, which
|
||||
constructs a `Jwt` without signing it. Same caveat: good for authorization rules, blind
|
||||
to decoder configuration.
|
||||
|
||||
## Testing expiry
|
||||
|
||||
A token with a 2-second TTL is **not** expired 5 seconds later — `JwtTimestampValidator`
|
||||
allows 60 seconds of clock skew ([doc 07 §3](07-edge-cases.md)). A test that sleeps past
|
||||
`exp` and asserts 401 either sleeps 61 seconds or is flaky.
|
||||
|
||||
Two better options: build the `JwtDecoder` under test with a small skew
|
||||
(`new JwtTimestampValidator(Duration.ZERO)`), or inject a fixed `Clock` and issue a token
|
||||
already in the past.
|
||||
|
||||
## Integration testing against the real server
|
||||
|
||||
`scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises
|
||||
a real Tomcat, a real HTTP client, real header parsing, and real base64url. Several
|
||||
findings in these docs (the `resource_metadata` parameter, the `FACTOR_BEARER` authority,
|
||||
the bare `WWW-Authenticate` on a wrapped `JwtException`) came from that script, not from
|
||||
the test suite.
|
||||
Reference in New Issue
Block a user