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.
143 lines
6.2 KiB
Java
143 lines
6.2 KiB
Java
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());
|
|
}
|
|
|
|
}
|