Careeroza
Refresh Endpoint and Rotation
The RefreshToken Entity
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String tokenHash; // SHA-256 hex of the raw token
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User user;
@Column(nullable = false)
private String family; // all rotations of one login share a family id
private Instant expiresAt;
private boolean used;
private boolean revoked;
// constructors, getters, setters...
}RefreshTokenService
@Service
public class RefreshTokenService {
private static final Duration TTL = Duration.ofDays(7);
private final RefreshTokenRepository repo;
private final SecureRandom random = new SecureRandom();
public RefreshTokenService(RefreshTokenRepository repo) { this.repo = repo; }
public record Issued(String rawToken, User user) {}
@Transactional
public Issued issue(User user, String family) {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
repo.save(new RefreshToken(sha256(raw), user, family, Instant.now().plus(TTL)));
return new Issued(raw, user);
}
/** Uses up the old token and returns a new one (rotation). */
@Transactional
public Issued rotate(String raw) {
RefreshToken old = repo.findByTokenHash(sha256(raw))
.orElseThrow(() -> new BadCredentialsException("Unknown refresh token"));
if (old.isUsed() || old.isRevoked()) {
// reuse of an old token = probably stolen: kill the whole family
repo.revokeFamily(old.getFamily());
throw new BadCredentialsException("Refresh token reuse detected");
}
if (old.getExpiresAt().isBefore(Instant.now())) {
throw new BadCredentialsException("Refresh token expired");
}
old.setUsed(true);
return issue(old.getUser(), old.getFamily());
}
@Transactional
public void revoke(String raw) {
repo.findByTokenHash(sha256(raw)).ifPresent(t -> repo.revokeFamily(t.getFamily()));
}
static String sha256(String s) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}The Endpoints
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private static final String COOKIE = "shopease_rt";
// ... constructor with authenticationManager, jwtService, refreshTokens, users, userDetailsService
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest req) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password()));
User user = users.findByEmail(auth.getName()).orElseThrow();
var issued = refreshTokens.issue(user, UUID.randomUUID().toString());
return withCookie(issued.rawToken(), body((UserDetails) auth.getPrincipal()));
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(@CookieValue(name = COOKIE, required = false) String raw) {
if (raw == null) throw new BadCredentialsException("No refresh token");
var issued = refreshTokens.rotate(raw);
UserDetails details = userDetailsService.loadUserByUsername(issued.user().getEmail());
return withCookie(issued.rawToken(), body(details)); // fresh roles every refresh
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue(name = COOKIE, required = false) String raw) {
if (raw != null) refreshTokens.revoke(raw);
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, cookie("", Duration.ZERO).toString()).build();
}
private ResponseEntity<LoginResponse> withCookie(String raw, LoginResponse body) {
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie(raw, Duration.ofDays(7)).toString())
.body(body);
}
private ResponseCookie cookie(String value, Duration maxAge) {
return ResponseCookie.from(COOKIE, value)
.httpOnly(true) // JavaScript cannot read it
.secure(true) // HTTPS only
.sameSite("Strict") // not sent on cross-site requests
.path("/api/auth") // only sent to the auth endpoints
.maxAge(maxAge)
.build();
}
}Rotation and Reuse Detection in Plain Java
Example
Every refresh token works once. If an old one shows up again, either the user or a thief is replaying it, and we cannot tell who, so the whole login (family) is revoked:
import java.util.*;
public class Main {
record Token(String user, String family, long expiresAt) {}
static final Map<String, Token> active = new LinkedHashMap<>();
static final Set<String> used = new HashSet<>();
static final Set<String> revokedFamilies = new HashSet<>();
static int counter = 0;
static String issue(String user, String family, long now) {
String id = "rt-" + (++counter);
active.put(id, new Token(user, family, now + 7 * 24 * 3600));
return id;
}
static String refresh(String id, long now) {
if (used.contains(id)) {
String family = active.get(id).family();
revokedFamilies.add(family);
return "REUSE DETECTED -> family " + family + " revoked, 401";
}
Token t = active.get(id);
if (t == null) return "unknown token, 401";
if (revokedFamilies.contains(t.family())) return "family revoked, 401";
if (now >= t.expiresAt()) return "expired, 401";
used.add(id);
return "200 new access token + " + issue(t.user(), t.family(), now);
}
public static void main(String[] args) {
long now = 1767225600L;
String r1 = issue("[email protected]", "login-A", now);
System.out.println("login -> " + r1);
System.out.println("refresh " + r1 + " (user) -> " + refresh(r1, now + 900));
System.out.println("refresh rt-2 (user) -> " + refresh("rt-2", now + 1800));
System.out.println("refresh " + r1 + " (thief) -> " + refresh(r1, now + 2000));
System.out.println("refresh rt-3 (user) -> " + refresh("rt-3", now + 2700));
String r4 = issue("[email protected]", "login-B", now);
System.out.println("ravi after 8 days -> " + refresh(r4, now + 8 * 24 * 3600));
}
}Output
login -> rt-1 refresh rt-1 (user) -> 200 new access token + rt-2 refresh rt-2 (user) -> 200 new access token + rt-3 refresh rt-1 (thief) -> REUSE DETECTED -> family login-A revoked, 401 refresh rt-3 (user) -> family revoked, 401 ravi after 8 days -> expired, 401