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 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>() { }); } @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()); } }