1
0

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:
2026-08-22 06:22:25 +00:00
commit 4a8dab6739
57 changed files with 4339 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
package com.ankurm.jwtauth;
import com.ankurm.jwtauth.auth.RevokedTokenStore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The 401-vs-403 contract, pinned as tests.
*
* @see docs/03-401-vs-403.md
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("hs256")
class AuthenticationFlowTests {
@Autowired MockMvc mvc;
@Autowired RevokedTokenStore revokedTokens;
private static final tools.jackson.databind.ObjectMapper JSON =
new tools.jackson.databind.ObjectMapper();
private Map<String, String> login(String user, String password) throws Exception {
MvcResult result = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password)))
.andExpect(status().isOk())
.andReturn();
return JSON.readValue(result.getResponse().getContentAsString(),
new tools.jackson.core.type.TypeReference<Map<String, String>>() { });
}
@Test
void publicEndpointNeedsNoToken() throws Exception {
this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk());
}
@Test
void missingTokenIs401NotA403() throws Exception {
this.mvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.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",
org.hamcrest.Matchers.containsString("insufficient_scope")));
}
@Test
void adminTokenReachesAdminEndpoint() throws Exception {
String token = login("root", "root-password").get("accessToken");
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
@Test
void tamperedSignatureIs401WithInvalidToken() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
String tampered = token.substring(0, token.length() - 4) + "AAAA";
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.containsString("invalid_token")));
}
@Test
void refreshTokenIsNotAnAccessToken() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh))
.andExpect(status().isUnauthorized());
}
@Test
void badPasswordIs401AndSaysNothingUseful() throws Exception {
this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
MvcResult locked = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"locked\",\"password\":\"locked-password\"}"))
.andExpect(status().isUnauthorized()).andReturn();
MvcResult wrong = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized()).andReturn();
assertThat(locked.getResponse().getContentAsString())
.isEqualTo(wrong.getResponse().getContentAsString());
}
@Test
void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token))
.andExpect(status().isNoContent());
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isUnauthorized());
}
@Test
void spentRefreshTokenCannotBeReplayed() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isUnauthorized());
}
}