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:
@@ -11,6 +11,7 @@ Runnable companion code for six articles on [ankurm.com](https://ankurm.com):
|
|||||||
| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
|
| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
|
||||||
| 6 | [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/) | [`permission-evaluator/`](permission-evaluator) — Spring Boot 4.1.2 / Security 7.1.2 |
|
| 6 | [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/) | [`permission-evaluator/`](permission-evaluator) — Spring Boot 4.1.2 / Security 7.1.2 |
|
||||||
| 7 | [Spring Security 6 to 7 Migration Guide: Removed APIs, Moved Modules and What Breaks](https://ankurm.com/spring-security-6-to-7-migration-guide/) | [`security-7-migration/`](security-7-migration) — Security 6.5.11 vs 7.1.2 |
|
| 7 | [Spring Security 6 to 7 Migration Guide: Removed APIs, Moved Modules and What Breaks](https://ankurm.com/spring-security-6-to-7-migration-guide/) | [`security-7-migration/`](security-7-migration) — Security 6.5.11 vs 7.1.2 |
|
||||||
|
| 8 | [JWT Refresh Token Rotation in Spring Security 7 (With Reuse Detection)](https://ankurm.com/spring-security-jwt-refresh-token-rotation-reuse-detection/) | [`refresh-tokens/`](refresh-tokens) — Spring Boot 4.1.2 / Security 7.1.2 |
|
||||||
|
|
||||||
Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
|
Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
|
||||||
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
|
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
|
||||||
|
|||||||
@@ -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