Add refresh-tokens module: JWT refresh token rotation, token families and reuse detection on Spring Security 7.1

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b6XzpJxZ25cDyuw1Uk
This commit is contained in:
2026-11-15 01:19:11 +05:30
co-authored by Claude Sonnet 5.5
parent 760fbc2d09
commit 13e8de2193
41 changed files with 1575 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.ankurm.refresh;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/** Output 07: logout on one device, logout everywhere, and the access token that outlives both. */
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class LogoutTest extends FlowSupport {
int logout(String refreshToken) throws Exception {
return mvc.perform(post("/api/auth/logout").contentType(MediaType.APPLICATION_JSON)
.content("{\"refresh_token\":\"%s\"}".formatted(refreshToken))).andReturn().getResponse().getStatus();
}
@Test
void logoutOneDeviceThenEverywhere() throws Exception {
Transcript t = new Transcript("07-logout");
Tokens phone = login("alice");
Tokens laptop = login("alice");
Tokens tablet = login("alice");
Tokens bob = login("bob");
t.line("alice is logged in on phone, laptop and tablet (3 families); bob on one device.");
t.line("POST /api/auth/logout with the phone's refresh token: HTTP %d", logout(phone.refresh()));
t.line(" phone refresh: HTTP %d", refreshRaw(phone.refresh()).getResponse().getStatus());
Tokens laptop2 = refresh(laptop.refresh());
t.line(" laptop refresh: HTTP 200");
t.line("POST /api/auth/logout with a made-up token: HTTP %d (same answer, no oracle)", logout("not-a-real-token"));
MvcResult all = mvc.perform(post("/api/auth/logout-all").header("Authorization", "Bearer " + laptop2.access())).andReturn();
t.line("POST /api/auth/logout-all with the laptop's access token: HTTP %d %s",
all.getResponse().getStatus(), all.getResponse().getContentAsString());
t.line(" laptop refresh: HTTP %d", refreshRaw(laptop2.refresh()).getResponse().getStatus());
t.line(" tablet refresh: HTTP %d", refreshRaw(tablet.refresh()).getResponse().getStatus());
int bobRefresh = refreshRaw(bob.refresh()).getResponse().getStatus();
t.line(" bob refresh: HTTP %d (untouched)", bobRefresh);
int stillValid = me(laptop2.access());
t.line("alice's laptop ACCESS token on /api/me after logout-all: HTTP %d", stillValid);
t.line(" (it is a signed JWT with 5 minutes left; nothing in the request path asked the store)");
assertThat(all.getResponse().getStatus()).isEqualTo(200);
assertThat(bobRefresh).isEqualTo(200);
assertThat(stillValid).isEqualTo(200);
t.write();
}
}