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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Executable
+44
@@ -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
|
||||
Executable
+9
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user