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
+27
View File
@@ -0,0 +1,27 @@
# refresh-tokens
Companion code for [JWT Refresh Token Rotation in Spring Security 7 (With Reuse Detection)](https://ankurm.com/spring-security-jwt-refresh-token-rotation-reuse-detection/).
Short-lived access JWT, opaque rotating refresh token, token families, reuse detection, logout on one device and everywhere. H2 in memory, no external services.
Run it: `mvn test` (JDK 25, Spring Boot 4.1.2, Spring Security 7.1.2). Every test writes what it observed to [`output/`](output), and `scripts/run-all.sh` regenerates all of it, including a real HTTP walkthrough.
| file | what it shows |
|---|---|
| `01-rotation.txt` | login, two rotations, one family; the access token carries the family id as `sid` |
| `02-storage.txt` | the table holds the SHA-256 of the token, never the token |
| `03-reuse-detection.txt` | a replayed token revokes the family, including the innocent holder's newest token |
| `04-families-are-independent.txt` | a replay on the phone does not touch the laptop's login |
| `05-concurrent-refresh-strict.txt` | 8 simultaneous requests with one token: 1 wins, the family is then revoked |
| `06-concurrent-refresh-grace.txt` | the same stampede with `reuse-grace=5s`: the family survives, the late replay still kills it |
| `07-logout.txt` | single-device logout, logout everywhere, and the access token that outlives both |
| `08-check-sid.txt` | `demo.check-sid=true` makes the access token die with its family |
| `09-expiry.txt` | idle timeout versus the absolute family cap |
| `10-clock-skew.txt` | Spring Security's default validator accepts a token 30 seconds past `exp` |
| `11-identical-refusals.txt` | unknown, reused, revoked and expired answer with the same bytes |
| `12-http-walkthrough.txt` | the same story over real HTTP with curl |
Settings in `application.yaml`: `demo.jwt.access-ttl` (5m), `demo.refresh.token-ttl` (7d), `demo.refresh.family-max-age` (30d), `demo.refresh.reuse-grace` (0s), `demo.check-sid` (false).
Token fingerprints and family ids differ on every run because the tokens are random. Statuses, counts and HTTP codes do not.
The signing secret in `application.yaml` is a demo value. Read yours from a secret manager.
+10
View File
@@ -0,0 +1,10 @@
login as alice
access token: JWT, sid=22badf84
access lifetime: 300 seconds
refresh token: 43 characters, dots in it: 0
after login, refresh(A) -> B, refresh(B) -> C, one family:
token A hash 7a05668b status USED
token B hash 7bb7862a status USED
token C hash a661d1ea status ACTIVE
sid of the access token minted from C: 22badf84
family id in the table: 22badf84
+4
View File
@@ -0,0 +1,4 @@
raw token handed to the client: 43 characters (32 random bytes, base64url)
column token_hash holds: 64 hex characters (SHA-256)
rows matching the raw token: 0
rows matching its SHA-256: 1
@@ -0,0 +1,9 @@
alice logs in and gets refresh token A.
An attacker copies A out of a log file.
alice's app refreshes: A -> B (A is now USED).
the attacker replays A: HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
the family after the replay:
token A hash 7131712c status REVOKED
token B hash 1c626483 status REVOKED
alice's app now presents B: HTTP 401 (the innocent holder is signed out too)
B's access token on /api/me, no sid check: HTTP 200 (still inside its 5 minutes)
@@ -0,0 +1,4 @@
alice logs in on a phone and on a laptop: two families.
phone family 1c2bb6d5, laptop family 8c9ca716
replay of the phone's spent token: HTTP 401
laptop refresh afterwards: HTTP 200
@@ -0,0 +1,6 @@
8 requests, same refresh token, released together, reuse-grace = 0
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 4659f797 status REVOKED
hash 180503f6 status REVOKED
@@ -0,0 +1,10 @@
8 requests, same refresh token, released together, reuse-grace = 5s
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 07e179bf status USED
hash f2fd3302 status ACTIVE
6 seconds later, replaying the spent token again:
HTTP 401, family now:
hash 07e179bf status REVOKED
hash f2fd3302 status REVOKED
+11
View File
@@ -0,0 +1,11 @@
alice is logged in on phone, laptop and tablet (3 families); bob on one device.
POST /api/auth/logout with the phone's refresh token: HTTP 204
phone refresh: HTTP 401
laptop refresh: HTTP 200
POST /api/auth/logout with a made-up token: HTTP 204 (same answer, no oracle)
POST /api/auth/logout-all with the laptop's access token: HTTP 200 {"revoked_tokens":3}
laptop refresh: HTTP 401
tablet refresh: HTTP 401
bob refresh: HTTP 200 (untouched)
alice's laptop ACCESS token on /api/me after logout-all: HTTP 200
(it is a signed JWT with 5 minutes left; nothing in the request path asked the store)
+3
View File
@@ -0,0 +1,3 @@
alice logs in. /api/me with the access token: HTTP 200
after logout-all, same access token, demo.check-sid=true: HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The session behind this token has ended", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost/.well-known/oauth-protected-resource"
+9
View File
@@ -0,0 +1,9 @@
token-ttl = 7d, family-max-age = 30d, access-ttl = 5m
5m01s after login, access token on /api/me: HTTP 401
8 days idle, refresh: HTTP 401 (token-ttl passed)
day 6: refresh OK (every token is only 6 days old)
day 12: refresh OK (every token is only 6 days old)
day 18: refresh OK (every token is only 6 days old)
day 24: refresh OK (every token is only 6 days old)
day 30: refresh HTTP 401, the token is 6 days old but the family is 30 days old
family rows: 5, all in the same family 721853e3
+3
View File
@@ -0,0 +1,3 @@
an access token that expired 30 seconds ago:
decoder with Spring Security's default validators accepts it: true
this module's decoder (clock skew set to zero) accepts it: false
@@ -0,0 +1,4 @@
never issued HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
already used (reuse) HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
revoked by logout HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
expired HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
@@ -0,0 +1,17 @@
$ curl -X POST /api/auth/login (alice)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"FeHO52BY0danWV...","token_type":"Bearer","expires_in":300}
$ curl /api/me (Bearer access token)
{"sub":"alice","sid":"c9ddb06a..."}
HTTP 200
$ curl -X POST /api/auth/refresh (token A)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"zgB_rx1Kaqu_AZ...","token_type":"Bearer","expires_in":300}
$ curl -X POST /api/auth/refresh (token A again: the replay)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
$ curl -X POST /api/auth/refresh (token B, the innocent holder)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>refresh-tokens-demo</artifactId>
<version>1.0.0</version>
<name>refresh-tokens-demo</name>
<description>JWT refresh token rotation with reuse detection on Spring Security 7.1 - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Boot 4 split the test slices out: MockMvc support and Spring Security test support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Starts the packaged app, walks login -> refresh -> replay with curl, and writes output/12-http-walkthrough.txt.
# Tokens are shortened in the transcript; the real ones are used on the wire.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q -DskipTests package
JAR=$(ls target/refresh-tokens-demo-*.jar | head -1)
PIDFILE=$(mktemp)
setsid nohup java -jar "$JAR" > /tmp/refresh-demo.log 2>&1 < /dev/null &
echo $! > "$PIDFILE"
trap 'kill "$(cat "$PIDFILE")" 2>/dev/null || true' EXIT
for i in $(seq 1 60); do
curl -s -o /dev/null -X POST localhost:8080/api/auth/login && break
sleep 1
done
short() { echo "${1:0:14}..."; }
post() { curl -s -w '\nHTTP %{http_code}' -X POST -H 'Content-Type: application/json' -d "$2" "localhost:8080$1"; }
OUT=output/12-http-walkthrough.txt
{
echo '$ curl -X POST /api/auth/login (alice)'
LOGIN=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice-password"}' localhost:8080/api/auth/login)
A=$(echo "$LOGIN" | jq -r .refresh_token); ACCESS=$(echo "$LOGIN" | jq -r .access_token)
echo "$LOGIN" | jq -c --arg a "$(short "$ACCESS")" --arg r "$(short "$A")" '.access_token=$a | .refresh_token=$r'
echo; echo '$ curl /api/me (Bearer access token)'
curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $ACCESS" localhost:8080/api/me | sed -E 's/"sid":"([0-9a-f]{8})[^"]*"/"sid":"\1..."/'
echo; echo '$ curl -X POST /api/auth/refresh (token A)'
R1=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"refresh_token\":\"$A\"}" localhost:8080/api/auth/refresh)
B=$(echo "$R1" | jq -r .refresh_token)
echo "$R1" | jq -c --arg a "$(short "$(echo "$R1" | jq -r .access_token)")" --arg r "$(short "$B")" '.access_token=$a | .refresh_token=$r'
echo; echo '$ curl -X POST /api/auth/refresh (token A again: the replay)'
post /api/auth/refresh "{\"refresh_token\":\"$A\"}"
echo; echo; echo '$ curl -X POST /api/auth/refresh (token B, the innocent holder)'
post /api/auth/refresh "{\"refresh_token\":\"$B\"}"
echo
} > "$OUT"
cat "$OUT"
echo; echo "--- server log lines about reuse ---"
grep -i 'REUSE' /tmp/refresh-demo.log | sed -E 's/^[0-9T:.+-]+ +//' || true
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Regenerates every file in output/ from a real run. JDK 25 and Maven required.
# Fingerprints (hash prefixes, family ids) differ on every run because the tokens are random;
# the statuses, counts and HTTP codes do not.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q test
./scripts/http-walkthrough.sh
ls output
@@ -0,0 +1,12 @@
package com.ankurm.refresh;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RefreshTokensApplication {
public static void main(String[] args) {
SpringApplication.run(RefreshTokensApplication.class, args);
}
}
@@ -0,0 +1,43 @@
package com.ankurm.refresh.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class AppConfig {
/** One injectable clock so tests can move time instead of sleeping. */
@Bean
public Clock clock() {
return Clock.systemUTC();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password(encoder.encode("alice-password")).roles("USER").build(),
User.withUsername("bob").password(encoder.encode("bob-password")).roles("USER").build());
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
@@ -0,0 +1,64 @@
package com.ankurm.refresh.config;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration
public class JwtConfig {
private final SecretKey key;
private final String issuer;
public JwtConfig(@Value("${demo.jwt.hmac-secret}") String secret,
@Value("${demo.jwt.issuer}") String issuer) {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("demo.jwt.hmac-secret must be >= 32 bytes for HS256");
}
this.key = new SecretKeySpec(bytes, "HmacSHA256");
this.issuer = issuer;
}
@Bean
public JwtEncoder jwtEncoder() {
return NimbusJwtEncoder.withSecretKey(key).algorithm(MacAlgorithm.HS256).build();
}
/**
* Spring Security's default timestamp validator tolerates 60 seconds of clock skew, so a
* "5 minute" access token is accepted for 6. This decoder sets the skew to zero, which
* is a choice: it is only safe while the issuer and the resource server share a clock.
*/
@Bean
public JwtDecoder jwtDecoder(Clock clock, ObjectProvider<SidValidator> sidValidator) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
JwtTimestampValidator timestamps = new JwtTimestampValidator(Duration.ZERO);
timestamps.setClock(clock);
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(timestamps);
validators.add(new JwtIssuerValidator(issuer));
sidValidator.ifAvailable(validators::add);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
}
@@ -0,0 +1,28 @@
package com.ankurm.refresh.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Bearer tokens are never attached by the browser on its own, so there is nothing to forge.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
// The refresh token in the body IS the credential for these three.
.requestMatchers("/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
@@ -0,0 +1,35 @@
package com.ankurm.refresh.config;
import com.ankurm.refresh.token.RefreshTokenStore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
/**
* Optional (demo.check-sid=true). Rejects an access token whose refresh-token family has been
* revoked. It closes the "access token outlives logout" gap, and it does so by making every
* request hit the store, which is exactly the statelessness the access JWT was meant to buy.
*/
@Component
@ConditionalOnProperty(name = "demo.check-sid", havingValue = "true")
public class SidValidator implements OAuth2TokenValidator<Jwt> {
private final RefreshTokenStore store;
public SidValidator(RefreshTokenStore store) {
this.store = store;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
String sid = jwt.getClaimAsString("sid");
if (sid != null && store.familyAlive(sid)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "The session behind this token has ended", null));
}
}
@@ -0,0 +1,54 @@
package com.ankurm.refresh.token;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
/**
* Mints the short-lived access JWT. The {@code sid} claim carries the refresh-token family
* id, which is what lets a resource server (optionally) ask "is this login still alive?".
*/
@Service
public class AccessTokenService {
public record Access(String value, Instant expiresAt, long expiresInSeconds) { }
private final JwtEncoder encoder;
private final Clock clock;
private final String issuer;
private final Duration ttl;
public AccessTokenService(JwtEncoder encoder, Clock clock,
@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.access-ttl}") Duration ttl) {
this.encoder = encoder;
this.clock = clock;
this.issuer = issuer;
this.ttl = ttl;
}
public Access issue(String username, String familyId) {
Instant now = clock.instant();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.subject(username)
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiresAt(now.plus(ttl))
.claim("sid", familyId)
.claim("scope", "profile:read")
.build();
Jwt jwt = encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims));
return new Access(jwt.getTokenValue(), jwt.getExpiresAt(), ttl.toSeconds());
}
}
@@ -0,0 +1,23 @@
package com.ankurm.refresh.token;
/**
* A refresh request that must be answered with 401 invalid_grant.
*
* <p>{@link #reason()} is for logs and tests only. The HTTP body is identical for every
* reason on purpose: a caller must not be able to tell "never existed" from "already used".
*/
public class InvalidGrantException extends RuntimeException {
public enum Reason { UNKNOWN, REVOKED, EXPIRED, REUSE_DETECTED, REUSE_WITHIN_GRACE }
private final Reason reason;
public InvalidGrantException(Reason reason) {
super(reason.name());
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
@@ -0,0 +1,150 @@
package com.ankurm.refresh.token;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import com.ankurm.refresh.token.InvalidGrantException.Reason;
import com.ankurm.refresh.token.RefreshTokenStore.Row;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Issues, rotates and revokes refresh tokens.
*
* <p>A refresh token here is NOT a JWT. It is 32 random bytes from {@link SecureRandom},
* base64url encoded, and the database keeps only its SHA-256. A refresh token must be
* revocable and single-use, and a self-contained JWT is neither without a lookup, so the
* lookup is the design rather than a workaround.
*
* <p>Every login starts a family. Every rotation adds a child to the same family. A token
* presented a second time means two parties hold the same secret, and the service cannot
* tell which one is the thief, so it revokes the whole family.
*/
@Service
public class RefreshTokenService {
private static final Logger log = LoggerFactory.getLogger(RefreshTokenService.class);
private static final SecureRandom RANDOM = new SecureRandom();
/** What a caller gets back. {@code rawToken} exists only here, never in the database. */
public record Issued(String rawToken, String familyId, String username, Instant expiresAt) { }
private final RefreshTokenStore store;
private final TransactionTemplate tx;
private final Clock clock;
private final Duration tokenTtl;
private final Duration familyMaxAge;
private final Duration reuseGrace;
public RefreshTokenService(RefreshTokenStore store, TransactionTemplate tx, Clock clock,
@Value("${demo.refresh.token-ttl}") Duration tokenTtl,
@Value("${demo.refresh.family-max-age}") Duration familyMaxAge,
@Value("${demo.refresh.reuse-grace}") Duration reuseGrace) {
this.store = store;
this.tx = tx;
this.clock = clock;
this.tokenTtl = tokenTtl;
this.familyMaxAge = familyMaxAge;
this.reuseGrace = reuseGrace;
}
/** First token of a brand new family. Call after the password check succeeded. */
public Issued startFamily(String username) {
long now = clock.millis();
return insertNew(username, UUID.randomUUID().toString(), now, now);
}
/**
* Spend {@code rawToken} and hand out its successor, or throw {@link InvalidGrantException}.
* The order of the checks is the whole design: unknown, revoked, expired, then the
* ACTIVE / USED split that reuse detection lives in.
*/
public Issued rotate(String rawToken) {
Row row = store.findByHash(hash(rawToken))
.orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
long now = clock.millis();
if (RefreshTokenStore.REVOKED.equals(row.status())) {
throw new InvalidGrantException(Reason.REVOKED);
}
if (now >= row.expiresAt() || now >= row.familyStartedAt() + familyMaxAge.toMillis()) {
throw new InvalidGrantException(Reason.EXPIRED);
}
if (RefreshTokenStore.USED.equals(row.status())) {
throw reuse(row, now);
}
// ACTIVE. Spend it and mint the child in ONE transaction, so a crash between the two
// cannot leave the user holding a spent token with no successor.
Issued issued = tx.execute(status -> {
if (!store.markUsed(row.id(), now)) {
return null; // lost a race against another request
}
return insertNew(row.username(), row.familyId(), row.familyStartedAt(), now);
});
if (issued != null) {
return issued;
}
// Somebody else spent it between our read and our UPDATE. Re-read to see how.
Row fresh = store.findByHash(hash(rawToken)).orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
if (RefreshTokenStore.REVOKED.equals(fresh.status())) {
throw new InvalidGrantException(Reason.REVOKED);
}
throw reuse(fresh, now);
}
/** Single-device logout: revoke the family this token belongs to, whatever state it is in. */
public void revokeFamilyOf(String rawToken) {
store.findByHash(hash(rawToken)).ifPresent(row -> store.revokeFamily(row.familyId()));
}
/** Logout everywhere. */
public int revokeAll(String username) {
return store.revokeAllForUser(username);
}
private InvalidGrantException reuse(Row row, long now) {
if (row.usedAt() != null && reuseGrace.toMillis() > 0 && now - row.usedAt() <= reuseGrace.toMillis()) {
log.info("refresh token replayed {} ms after use, inside the {} ms grace window, family {} kept",
now - row.usedAt(), reuseGrace.toMillis(), row.familyId());
return new InvalidGrantException(Reason.REUSE_WITHIN_GRACE);
}
int revoked = store.revokeFamily(row.familyId());
log.warn("refresh token REUSE detected for user {}: family {} revoked ({} tokens)",
row.username(), row.familyId(), revoked);
return new InvalidGrantException(Reason.REUSE_DETECTED);
}
private Issued insertNew(String username, String familyId, long familyStartedAt, long now) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
long expiresAt = now + tokenTtl.toMillis();
store.insert(new Row(UUID.randomUUID().toString(), hash(raw), familyId, username,
RefreshTokenStore.ACTIVE, now, expiresAt, familyStartedAt, null));
return new Issued(raw, familyId, username, Instant.ofEpochMilli(expiresAt));
}
/** SHA-256 hex. Fast hashing is fine here: the input is 256 random bits, not a password. */
public static String hash(String rawToken) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
}
catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,79 @@
package com.ankurm.refresh.token;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* The only class that touches the refresh_token table.
*
* <p>Two methods carry the security argument. {@link #markUsed} is a single conditional
* UPDATE, so when several requests present the same token only one of them can change the
* row from ACTIVE to USED, no matter how the requests interleave. {@link #revokeFamily}
* is what reuse detection pulls when it sees a token that was already spent.
*/
@Repository
public class RefreshTokenStore {
public static final String ACTIVE = "ACTIVE";
public static final String USED = "USED";
public static final String REVOKED = "REVOKED";
/** One row. Times are epoch milliseconds. {@code usedAt} is null until the token is spent. */
public record Row(String id, String tokenHash, String familyId, String username, String status,
long issuedAt, long expiresAt, long familyStartedAt, Long usedAt) { }
private final JdbcClient jdbc;
public RefreshTokenStore(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public void insert(Row row) {
jdbc.sql("""
insert into refresh_token
(id, token_hash, family_id, username, status, issued_at, expires_at, family_started_at, used_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
.params(row.id(), row.tokenHash(), row.familyId(), row.username(), row.status(),
row.issuedAt(), row.expiresAt(), row.familyStartedAt(), row.usedAt())
.update();
}
public Optional<Row> findByHash(String tokenHash) {
return jdbc.sql("select * from refresh_token where token_hash = ?")
.param(tokenHash).query(Row.class).optional();
}
/** Spend a token. Returns false when somebody else already did, or it was revoked meanwhile. */
public boolean markUsed(String id, long nowMillis) {
return jdbc.sql("update refresh_token set status = 'USED', used_at = ? where id = ? and status = 'ACTIVE'")
.params(nowMillis, id).update() == 1;
}
/** Kill every token of one login chain, in whatever state. Returns the rows changed. */
public int revokeFamily(String familyId) {
return jdbc.sql("update refresh_token set status = 'REVOKED' where family_id = ? and status <> 'REVOKED'")
.param(familyId).update();
}
/** Logout everywhere. Returns the rows changed. */
public int revokeAllForUser(String username) {
return jdbc.sql("update refresh_token set status = 'REVOKED' where username = ? and status <> 'REVOKED'")
.param(username).update();
}
/** True while at least one token of the family is not revoked. Used by the optional sid check. */
public boolean familyAlive(String familyId) {
Integer n = jdbc.sql("select count(*) from refresh_token where family_id = ? and status <> 'REVOKED'")
.param(familyId).query(Integer.class).single();
return n != null && n > 0;
}
public List<Row> findFamily(String familyId) {
return jdbc.sql("select * from refresh_token where family_id = ? order by issued_at, case when used_at is null then 1 else 0 end, id")
.param(familyId).query(Row.class).list();
}
}
@@ -0,0 +1,107 @@
package com.ankurm.refresh.web;
import java.util.LinkedHashMap;
import java.util.Map;
import com.ankurm.refresh.token.AccessTokenService;
import com.ankurm.refresh.token.InvalidGrantException;
import com.ankurm.refresh.token.RefreshTokenService;
import com.ankurm.refresh.web.AuthDtos.LoginRequest;
import com.ankurm.refresh.web.AuthDtos.RefreshRequest;
import com.ankurm.refresh.web.AuthDtos.TokenResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final RefreshTokenService refreshTokens;
private final AccessTokenService accessTokens;
public AuthController(AuthenticationManager authenticationManager, RefreshTokenService refreshTokens,
AccessTokenService accessTokens) {
this.authenticationManager = authenticationManager;
this.refreshTokens = refreshTokens;
this.accessTokens = accessTokens;
}
@PostMapping("/auth/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
return respond(refreshTokens.startFamily(auth.getName()));
}
@PostMapping("/auth/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest request) {
return respond(refreshTokens.rotate(request.refreshToken()));
}
/** This device only. Always 204, so the endpoint is not an oracle for "is this token real". */
@PostMapping("/auth/logout")
public ResponseEntity<Void> logout(@Valid @RequestBody RefreshRequest request) {
refreshTokens.revokeFamilyOf(request.refreshToken());
return ResponseEntity.noContent().build();
}
/** Every device. Authenticated by an access token, because "all my sessions" needs an identity. */
@PostMapping("/auth/logout-all")
public Map<String, Object> logoutAll(@AuthenticationPrincipal Jwt jwt) {
return ordered("revoked_tokens", refreshTokens.revokeAll(jwt.getSubject()));
}
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("sub", jwt.getSubject());
body.put("sid", jwt.getClaimAsString("sid"));
return body;
}
private TokenResponse respond(RefreshTokenService.Issued refresh) {
AccessTokenService.Access access = accessTokens.issue(refresh.username(), refresh.familyId());
return new TokenResponse(access.value(), refresh.rawToken(), "Bearer", access.expiresInSeconds());
}
/** One body for every failure reason. RFC 6749 section 5.2 names it invalid_grant. */
@ExceptionHandler(InvalidGrantException.class)
public ResponseEntity<Map<String, String>> invalidGrant(InvalidGrantException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(error("The refresh token is not valid"));
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Map<String, String>> badCredentials(BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(error("Bad credentials"));
}
/** Map.of() iterates in a different order on every JVM start; a stable body keeps reruns diffable. */
private static Map<String, String> error(String description) {
Map<String, String> body = new LinkedHashMap<>();
body.put("error", "invalid_grant");
body.put("error_description", description);
return body;
}
private static Map<String, Object> ordered(String key, Object value) {
Map<String, Object> body = new LinkedHashMap<>();
body.put(key, value);
return body;
}
}
@@ -0,0 +1,18 @@
package com.ankurm.refresh.web;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public final class AuthDtos {
private AuthDtos() { }
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }
public record RefreshRequest(@NotBlank @JsonProperty("refresh_token") String refreshToken) { }
public record TokenResponse(@JsonProperty("access_token") String accessToken,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") long expiresIn) { }
}
@@ -0,0 +1,31 @@
spring:
application:
name: refresh-tokens-demo
datasource:
url: jdbc:h2:mem:refresh;DB_CLOSE_DELAY=-1
sql:
init:
mode: always
server:
port: 8080
demo:
jwt:
issuer: https://refresh-demo.ankurm.com
access-ttl: 5m
# 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
hmac-secret: ankurm-refresh-demo-hmac-secret-32-bytes-min
refresh:
# Lifetime of ONE refresh token. Each rotation hands out a fresh one, so this is an idle timeout.
token-ttl: 7d
# Absolute cap on a whole token family. Rotation cannot extend a login past this.
family-max-age: 30d
# 0 = strict: any replay of a spent token revokes the family. >0 = tolerate a replay this soon after use.
reuse-grace: 0s
# true adds a validator that rejects an access token whose family (sid claim) was revoked.
check-sid: false
logging:
level:
com.ankurm.refresh: INFO
@@ -0,0 +1,14 @@
-- One row per refresh token. The raw token is never stored, only its SHA-256.
create table if not exists refresh_token (
id varchar(36) primary key,
token_hash varchar(64) not null unique,
family_id varchar(36) not null,
username varchar(100) not null,
status varchar(8) not null, -- ACTIVE, USED or REVOKED
issued_at bigint not null, -- epoch millis
expires_at bigint not null,
family_started_at bigint not null,
used_at bigint
);
create index if not exists idx_refresh_family on refresh_token (family_id);
create index if not exists idx_refresh_user on refresh_token (username);
@@ -0,0 +1,33 @@
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.test.web.servlet.MvcResult;
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;
/** Output 08: the same logout-all with demo.check-sid=true. */
@SpringBootTest(properties = "demo.check-sid=true")
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class CheckSidTest extends FlowSupport {
@Test
void accessTokenDiesWithItsFamily() throws Exception {
Transcript t = new Transcript("08-check-sid");
Tokens laptop = login("alice");
t.line("alice logs in. /api/me with the access token: HTTP %d", me(laptop.access()));
mvc.perform(post("/api/auth/logout-all").header("Authorization", "Bearer " + laptop.access())).andReturn();
MvcResult after = mvc.perform(get("/api/me").header("Authorization", "Bearer " + laptop.access())).andReturn();
t.line("after logout-all, same access token, demo.check-sid=true: HTTP %d", after.getResponse().getStatus());
t.line(" WWW-Authenticate: %s", after.getResponse().getHeader("WWW-Authenticate"));
assertThat(after.getResponse().getStatus()).isEqualTo(401);
t.write();
}
}
@@ -0,0 +1,43 @@
package com.ankurm.refresh;
import java.util.List;
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 static org.assertj.core.api.Assertions.assertThat;
/** Output 06: the same stampede with demo.refresh.reuse-grace=5s. */
@SpringBootTest(properties = "demo.refresh.reuse-grace=5s")
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class ConcurrentRefreshGraceTest extends FlowSupport {
@Test
void theWinnerKeepsTheFamilyAlive() throws Exception {
Transcript t = new Transcript("06-concurrent-refresh-grace");
Tokens a = login("alice");
List<Integer> statuses = ConcurrentRefreshStrictTest.fireTogether(this, a.refresh(), 8);
long ok = statuses.stream().filter(s -> s == 200).count();
long denied = statuses.stream().filter(s -> s == 401).count();
t.line("8 requests, same refresh token, released together, reuse-grace = 5s");
t.line(" HTTP 200: %d", ok);
t.line(" HTTP 401: %d", denied);
t.line("family afterwards:");
store.findFamily(familyOf(a)).forEach(r -> t.line(" hash %s status %s", r.tokenHash().substring(0, 8), r.status()));
clock.advance(java.time.Duration.ofSeconds(6));
t.line("6 seconds later, replaying the spent token again:");
int late = refreshRaw(a.refresh()).getResponse().getStatus();
t.line(" HTTP %d, family now:", late);
store.findFamily(familyOf(a)).forEach(r -> t.line(" hash %s status %s", r.tokenHash().substring(0, 8), r.status()));
assertThat(ok).isEqualTo(1);
assertThat(denied).isEqualTo(7);
assertThat(late).isEqualTo(401);
assertThat(store.findFamily(familyOf(a))).allSatisfy(r -> assertThat(r.status()).isEqualTo("REVOKED"));
t.write();
}
}
@@ -0,0 +1,66 @@
package com.ankurm.refresh;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Output 05: eight requests present the SAME refresh token at the same instant, which is
* what a browser does when several tabs wake up on an expired access token.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class ConcurrentRefreshStrictTest extends FlowSupport {
static List<Integer> fireTogether(FlowSupport s, String refreshToken, int threads) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch go = new CountDownLatch(1);
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
go.await();
return s.refreshRaw(refreshToken).getResponse().getStatus();
}));
}
ready.await();
go.countDown();
List<Integer> statuses = new ArrayList<>();
for (Future<Integer> f : futures) {
statuses.add(f.get());
}
pool.shutdown();
return statuses;
}
@Test
void exactlyOneWinsAndTheFamilyDies() throws Exception {
Transcript t = new Transcript("05-concurrent-refresh-strict");
Tokens a = login("alice");
List<Integer> statuses = fireTogether(this, a.refresh(), 8);
long ok = statuses.stream().filter(s -> s == 200).count();
long denied = statuses.stream().filter(s -> s == 401).count();
t.line("8 requests, same refresh token, released together, reuse-grace = 0");
t.line(" HTTP 200: %d", ok);
t.line(" HTTP 401: %d", denied);
t.line("family afterwards:");
store.findFamily(familyOf(a)).forEach(r -> t.line(" hash %s status %s", r.tokenHash().substring(0, 8), r.status()));
assertThat(ok).as("the conditional UPDATE lets exactly one request spend the token").isEqualTo(1);
assertThat(ok + denied).isEqualTo(8);
assertThat(store.findFamily(familyOf(a))).allSatisfy(r -> assertThat(r.status()).isEqualTo("REVOKED"));
t.write();
}
}
@@ -0,0 +1,56 @@
package com.ankurm.refresh;
import java.util.LinkedHashMap;
import java.util.Map;
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 11: four different reasons, one answer. */
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class ErrorBodyTest extends FlowSupport {
@Test
void everyRefusalLooksTheSame() throws Exception {
Transcript t = new Transcript("11-identical-refusals");
Map<String, MvcResult> results = new LinkedHashMap<>();
results.put("never issued", refreshRaw("A".repeat(43)));
Tokens spent = login("alice");
refresh(spent.refresh());
results.put("already used (reuse)", refreshRaw(spent.refresh()));
Tokens revoked = login("alice");
mvc.perform(post("/api/auth/logout").contentType(MediaType.APPLICATION_JSON)
.content("{\"refresh_token\":\"%s\"}".formatted(revoked.refresh()))).andReturn();
results.put("revoked by logout", refreshRaw(revoked.refresh()));
Tokens old = login("alice");
clock.advance(java.time.Duration.ofDays(8));
results.put("expired", refreshRaw(old.refresh()));
results.forEach((why, r) -> t.line("%-22s HTTP %d %s", why, r.getResponse().getStatus(), body(r)));
assertThat(results.values().stream().map(r -> r.getResponse().getStatus() + body(r)).distinct()).hasSize(1);
t.write();
}
private static String body(MvcResult r) {
try {
return r.getResponse().getContentAsString();
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,114 @@
package com.ankurm.refresh;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import com.ankurm.refresh.token.RefreshTokenService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.beans.factory.annotation.Value;
import static org.assertj.core.api.Assertions.assertThat;
/** Outputs 09 and 10: idle expiry, the absolute family cap, and the 60-second skew default. */
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class ExpiryTest extends FlowSupport {
@Autowired JwtEncoder encoder;
@Autowired JwtDecoder ourDecoder;
@Value("${demo.jwt.hmac-secret}") String secret;
@Value("${demo.jwt.issuer}") String issuer;
@Test
void idleTimeoutAndAbsoluteFamilyCap() throws Exception {
Transcript t = new Transcript("09-expiry");
Tokens a = login("alice");
t.line("token-ttl = 7d, family-max-age = 30d, access-ttl = 5m");
clock.advance(Duration.ofMinutes(5).plusSeconds(1));
t.line("5m01s after login, access token on /api/me: HTTP %d", me(a.access()));
clock.advance(Duration.ofDays(8));
int idle = refreshRaw(a.refresh()).getResponse().getStatus();
t.line("8 days idle, refresh: HTTP %d (token-ttl passed)", idle);
assertThat(idle).isEqualTo(401);
clock.reset();
Tokens cur = login("alice");
String family = familyOf(cur);
int day = 0;
while (day < 24) {
clock.advance(Duration.ofDays(6));
day += 6;
cur = refresh(cur.refresh());
t.line("day %2d: refresh OK (every token is only 6 days old)", day);
}
clock.advance(Duration.ofDays(6));
day += 6;
int capped = refreshRaw(cur.refresh()).getResponse().getStatus();
t.line("day %2d: refresh HTTP %d, the token is 6 days old but the family is 30 days old", day, capped);
t.line("family rows: %d, all in the same family %s", store.findFamily(family).size(), family.substring(0, 8));
assertThat(capped).isEqualTo(401);
assertThat(store.findFamily(family)).hasSize(5);
t.write();
}
@Test
void defaultClockSkewAddsAMinute() throws Exception {
Transcript t = new Transcript("10-clock-skew");
Instant now = clock.instant();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(issuer).subject("alice")
.issuedAt(now.minusSeconds(330)).expiresAt(now.minusSeconds(30)).claim("sid", "x").build();
String expired = encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims))
.getTokenValue();
// A decoder with Spring Security's out-of-the-box validators, judged against the real clock.
// To compare like with like, build a token that expired 30 seconds before the REAL now.
Instant real = Instant.now();
JwtClaimsSet realClaims = JwtClaimsSet.builder().issuer(issuer).subject("alice")
.issuedAt(real.minusSeconds(330)).expiresAt(real.minusSeconds(30)).claim("sid", "x").build();
String expiredReal = encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), realClaims))
.getTokenValue();
NimbusJwtDecoder defaults = NimbusJwtDecoder.withSecretKey(
new javax.crypto.spec.SecretKeySpec(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"))
.macAlgorithm(MacAlgorithm.HS256).build();
boolean defaultAccepts;
try {
defaults.decode(expiredReal);
defaultAccepts = true;
}
catch (Exception e) {
defaultAccepts = false;
}
boolean oursAccepts;
try {
ourDecoder.decode(expired);
oursAccepts = true;
}
catch (Exception e) {
oursAccepts = false;
}
t.line("an access token that expired 30 seconds ago:");
t.line(" decoder with Spring Security's default validators accepts it: %s", defaultAccepts);
t.line(" this module's decoder (clock skew set to zero) accepts it: %s", oursAccepts);
assertThat(defaultAccepts).isTrue();
assertThat(oursAccepts).isFalse();
t.write();
}
}
@@ -0,0 +1,86 @@
package com.ankurm.refresh;
import java.util.List;
import java.util.Map;
import com.ankurm.refresh.token.RefreshTokenService;
import com.ankurm.refresh.token.RefreshTokenStore;
import com.ankurm.refresh.token.RefreshTokenStore.Row;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import tools.jackson.databind.json.JsonMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/** Shared plumbing: log in, refresh, and print a family of tokens the way the transcripts show them. */
abstract class FlowSupport {
static final JsonMapper JSON = new JsonMapper();
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired RefreshTokenStore store;
@Autowired MutableClock clock;
record Tokens(String access, String refresh) { }
@BeforeEach
void cleanSlate() {
jdbc.sql("delete from refresh_token").update();
clock.reset();
}
Tokens login(String user) throws Exception {
MvcResult r = mvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"%s-password\"}".formatted(user, user))).andReturn();
return parse(r);
}
MvcResult refreshRaw(String refreshToken) throws Exception {
return mvc.perform(post("/api/auth/refresh").contentType(MediaType.APPLICATION_JSON)
.content("{\"refresh_token\":\"%s\"}".formatted(refreshToken))).andReturn();
}
/** Refresh and expect success. */
Tokens refresh(String refreshToken) throws Exception {
MvcResult r = refreshRaw(refreshToken);
if (r.getResponse().getStatus() != 200) {
throw new AssertionError("refresh failed: " + r.getResponse().getStatus());
}
return parse(r);
}
int me(String accessToken) throws Exception {
return mvc.perform(get("/api/me").header("Authorization", "Bearer " + accessToken))
.andReturn().getResponse().getStatus();
}
@SuppressWarnings("unchecked")
Tokens parse(MvcResult r) throws Exception {
Map<String, Object> body = JSON.readValue(r.getResponse().getContentAsString(), Map.class);
return new Tokens((String) body.get("access_token"), (String) body.get("refresh_token"));
}
String familyOf(Tokens t) {
return store.findByHash(RefreshTokenService.hash(t.refresh())).orElseThrow().familyId();
}
/** First 8 hex chars of the stored hash: enough to tell tokens apart, useless to an attacker. */
static String fp(String rawToken) {
return RefreshTokenService.hash(rawToken).substring(0, 8);
}
/** One line per named token, in the order of {@code names}, with the status the table holds for it. */
void printFamily(Transcript t, String familyId, Map<String, String> names) {
List<Row> rows = store.findFamily(familyId);
names.forEach((raw, label) -> rows.stream()
.filter(row -> RefreshTokenService.hash(raw).equals(row.tokenHash()))
.findFirst()
.ifPresent(row -> t.line(" token %-3s hash %s status %s", label, row.tokenHash().substring(0, 8), row.status())));
}
}
@@ -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();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.refresh;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
/** A clock the test moves by hand, so "7 days later" costs no waiting. */
final class MutableClock extends Clock {
private volatile Instant now = Instant.parse("2026-11-15T10:00:00Z");
void reset() {
now = Instant.parse("2026-11-15T10:00:00Z");
}
void advance(Duration d) {
now = now.plus(d);
}
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
@TestConfiguration
static class Config {
@Bean
@Primary
MutableClock mutableClock() {
return new MutableClock();
}
}
}
@@ -0,0 +1,72 @@
package com.ankurm.refresh;
import java.util.LinkedHashMap;
import java.util.Map;
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.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
/** Output 03: the theft scenario. Whoever replays second takes the whole family down. */
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class ReuseDetectionTest extends FlowSupport {
@Test
void replayOfASpentTokenRevokesTheFamily() throws Exception {
Transcript t = new Transcript("03-reuse-detection");
Tokens a = login("alice");
t.line("alice logs in and gets refresh token A.");
t.line("An attacker copies A out of a log file.");
Tokens b = refresh(a.refresh());
t.line("alice's app refreshes: A -> B (A is now USED).");
MvcResult replay = refreshRaw(a.refresh());
t.line("the attacker replays A: HTTP %d %s", replay.getResponse().getStatus(),
replay.getResponse().getContentAsString());
Map<String, String> names = new LinkedHashMap<>();
names.put(a.refresh(), "A");
names.put(b.refresh(), "B");
t.line("the family after the replay:");
printFamily(t, familyOf(b), names);
MvcResult legit = refreshRaw(b.refresh());
t.line("alice's app now presents B: HTTP %d (the innocent holder is signed out too)",
legit.getResponse().getStatus());
int apiWithOldAccess = me(b.access());
t.line("B's access token on /api/me, no sid check: HTTP %d (still inside its 5 minutes)", apiWithOldAccess);
assertThat(replay.getResponse().getStatus()).isEqualTo(401);
assertThat(legit.getResponse().getStatus()).isEqualTo(401);
assertThat(store.findFamily(familyOf(b))).allSatisfy(r -> assertThat(r.status()).isEqualTo("REVOKED"));
assertThat(apiWithOldAccess).isEqualTo(200);
t.write();
}
@Test
void otherFamiliesOfTheSameUserSurvive() throws Exception {
Transcript t = new Transcript("04-families-are-independent");
Tokens phone = login("alice");
Tokens laptop = login("alice");
t.line("alice logs in on a phone and on a laptop: two families.");
t.line("phone family %s, laptop family %s", familyOf(phone).substring(0, 8), familyOf(laptop).substring(0, 8));
refresh(phone.refresh());
MvcResult replay = refreshRaw(phone.refresh());
t.line("replay of the phone's spent token: HTTP %d", replay.getResponse().getStatus());
MvcResult laptopRefresh = refreshRaw(laptop.refresh());
t.line("laptop refresh afterwards: HTTP %d", laptopRefresh.getResponse().getStatus());
assertThat(familyOf(phone)).isNotEqualTo(familyOf(laptop));
assertThat(replay.getResponse().getStatus()).isEqualTo(401);
assertThat(laptopRefresh.getResponse().getStatus()).isEqualTo(200);
t.write();
}
}
@@ -0,0 +1,77 @@
package com.ankurm.refresh;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import com.ankurm.refresh.token.RefreshTokenStore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import static org.assertj.core.api.Assertions.assertThat;
/** Output 01 and 02: what a login and two rotations leave behind, and what is stored. */
@SpringBootTest
@AutoConfigureMockMvc
@Import(MutableClock.Config.class)
class RotationTest extends FlowSupport {
@Autowired JwtDecoder decoder;
@Test
void loginThenRotateTwice() throws Exception {
Transcript t = new Transcript("01-rotation");
Tokens a = login("alice");
Jwt jwt = decoder.decode(a.access());
t.line("login as alice");
t.line(" access token: JWT, sid=%s", jwt.getClaimAsString("sid").substring(0, 8));
t.line(" access lifetime: %d seconds", Duration.between(jwt.getIssuedAt(), jwt.getExpiresAt()).toSeconds());
t.line(" refresh token: %d characters, dots in it: %d", a.refresh().length(),
a.refresh().chars().filter(c -> c == '.').count());
Tokens b = refresh(a.refresh());
Tokens c = refresh(b.refresh());
String family = familyOf(c);
Map<String, String> names = new LinkedHashMap<>();
names.put(a.refresh(), "A");
names.put(b.refresh(), "B");
names.put(c.refresh(), "C");
t.line("after login, refresh(A) -> B, refresh(B) -> C, one family:");
printFamily(t, family, names);
t.line("sid of the access token minted from C: %s", decoder.decode(c.access()).getClaimAsString("sid").substring(0, 8));
t.line("family id in the table: %s", family.substring(0, 8));
assertThat(jwt.getClaimAsString("sid")).isEqualTo(familyOf(a));
assertThat(Duration.between(jwt.getIssuedAt(), jwt.getExpiresAt())).isEqualTo(Duration.ofMinutes(5));
assertThat(a.refresh()).hasSize(43).doesNotContain(".");
assertThat(store.findFamily(family)).extracting(RefreshTokenStore.Row::status)
.containsExactly("USED", "USED", "ACTIVE");
assertThat(familyOf(a)).isEqualTo(familyOf(b)).isEqualTo(family);
assertThat(java.util.Set.of(a.refresh(), b.refresh(), c.refresh())).hasSize(3);
t.write();
}
@Test
void databaseNeverHoldsTheRawToken() throws Exception {
Transcript t = new Transcript("02-storage");
Tokens a = login("alice");
String hash = jdbc.sql("select token_hash from refresh_token").query(String.class).single();
int byRaw = jdbc.sql("select count(*) from refresh_token where token_hash = ?")
.param(a.refresh()).query(Integer.class).single();
int byHash = jdbc.sql("select count(*) from refresh_token where token_hash = ?")
.param(com.ankurm.refresh.token.RefreshTokenService.hash(a.refresh())).query(Integer.class).single();
t.line("raw token handed to the client: %d characters (32 random bytes, base64url)", a.refresh().length());
t.line("column token_hash holds: %d hex characters (SHA-256)", hash.length());
t.line("rows matching the raw token: %d", byRaw);
t.line("rows matching its SHA-256: %d", byHash);
assertThat(byRaw).isZero();
assertThat(byHash).isEqualTo(1);
assertThat(hash).hasSize(64).isNotEqualTo(a.refresh());
t.write();
}
}
@@ -0,0 +1,27 @@
package com.ankurm.refresh;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Collects lines and writes them to {@code output/<name>.txt} (relative to the module directory). */
final class Transcript {
private final String name;
private final StringBuilder text = new StringBuilder();
Transcript(String name) {
this.name = name;
}
Transcript line(String format, Object... args) {
text.append(args.length == 0 ? format : String.format(format, args)).append('\n');
return this;
}
void write() throws IOException {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.writeString(dir.resolve(name + ".txt"), text.toString());
}
}