From 13e8de21933070f61b061737b91424d74e966cbb Mon Sep 17 00:00:00 2001 From: Ankur Date: Sun, 15 Nov 2026 01:19:11 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b6XzpJxZ25cDyuw1Uk --- README.md | 1 + refresh-tokens/README.md | 27 ++++ refresh-tokens/output/01-rotation.txt | 10 ++ refresh-tokens/output/02-storage.txt | 4 + refresh-tokens/output/03-reuse-detection.txt | 9 ++ .../output/04-families-are-independent.txt | 4 + .../output/05-concurrent-refresh-strict.txt | 6 + .../output/06-concurrent-refresh-grace.txt | 10 ++ refresh-tokens/output/07-logout.txt | 11 ++ refresh-tokens/output/08-check-sid.txt | 3 + refresh-tokens/output/09-expiry.txt | 9 ++ refresh-tokens/output/10-clock-skew.txt | 3 + .../output/11-identical-refusals.txt | 4 + refresh-tokens/output/12-http-walkthrough.txt | 17 ++ refresh-tokens/pom.xml | 78 +++++++++ refresh-tokens/scripts/http-walkthrough.sh | 44 +++++ refresh-tokens/scripts/run-all.sh | 9 ++ .../refresh/RefreshTokensApplication.java | 12 ++ .../com/ankurm/refresh/config/AppConfig.java | 43 +++++ .../com/ankurm/refresh/config/JwtConfig.java | 64 ++++++++ .../ankurm/refresh/config/SecurityConfig.java | 28 ++++ .../ankurm/refresh/config/SidValidator.java | 35 ++++ .../refresh/token/AccessTokenService.java | 54 +++++++ .../refresh/token/InvalidGrantException.java | 23 +++ .../refresh/token/RefreshTokenService.java | 150 ++++++++++++++++++ .../refresh/token/RefreshTokenStore.java | 79 +++++++++ .../ankurm/refresh/web/AuthController.java | 107 +++++++++++++ .../java/com/ankurm/refresh/web/AuthDtos.java | 18 +++ .../src/main/resources/application.yaml | 31 ++++ refresh-tokens/src/main/resources/schema.sql | 14 ++ .../java/com/ankurm/refresh/CheckSidTest.java | 33 ++++ .../refresh/ConcurrentRefreshGraceTest.java | 43 +++++ .../refresh/ConcurrentRefreshStrictTest.java | 66 ++++++++ .../com/ankurm/refresh/ErrorBodyTest.java | 56 +++++++ .../java/com/ankurm/refresh/ExpiryTest.java | 114 +++++++++++++ .../java/com/ankurm/refresh/FlowSupport.java | 86 ++++++++++ .../java/com/ankurm/refresh/LogoutTest.java | 56 +++++++ .../java/com/ankurm/refresh/MutableClock.java | 38 +++++ .../ankurm/refresh/ReuseDetectionTest.java | 72 +++++++++ .../java/com/ankurm/refresh/RotationTest.java | 77 +++++++++ .../java/com/ankurm/refresh/Transcript.java | 27 ++++ 41 files changed, 1575 insertions(+) create mode 100644 refresh-tokens/README.md create mode 100644 refresh-tokens/output/01-rotation.txt create mode 100644 refresh-tokens/output/02-storage.txt create mode 100644 refresh-tokens/output/03-reuse-detection.txt create mode 100644 refresh-tokens/output/04-families-are-independent.txt create mode 100644 refresh-tokens/output/05-concurrent-refresh-strict.txt create mode 100644 refresh-tokens/output/06-concurrent-refresh-grace.txt create mode 100644 refresh-tokens/output/07-logout.txt create mode 100644 refresh-tokens/output/08-check-sid.txt create mode 100644 refresh-tokens/output/09-expiry.txt create mode 100644 refresh-tokens/output/10-clock-skew.txt create mode 100644 refresh-tokens/output/11-identical-refusals.txt create mode 100644 refresh-tokens/output/12-http-walkthrough.txt create mode 100644 refresh-tokens/pom.xml create mode 100755 refresh-tokens/scripts/http-walkthrough.sh create mode 100755 refresh-tokens/scripts/run-all.sh create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/RefreshTokensApplication.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/config/AppConfig.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/config/JwtConfig.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/config/SecurityConfig.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/config/SidValidator.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/token/AccessTokenService.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/token/InvalidGrantException.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthController.java create mode 100644 refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthDtos.java create mode 100644 refresh-tokens/src/main/resources/application.yaml create mode 100644 refresh-tokens/src/main/resources/schema.sql create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/CheckSidTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshGraceTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshStrictTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/ErrorBodyTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/ExpiryTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/FlowSupport.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/LogoutTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/MutableClock.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/ReuseDetectionTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/RotationTest.java create mode 100644 refresh-tokens/src/test/java/com/ankurm/refresh/Transcript.java diff --git a/README.md b/README.md index acc9691..1ec8799 100644 --- a/README.md +++ b/README.md @@ -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) | | 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 | +| 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 tokens with a hand-written filter. The second verifies tokens minted by somebody else — diff --git a/refresh-tokens/README.md b/refresh-tokens/README.md new file mode 100644 index 0000000..64fa104 --- /dev/null +++ b/refresh-tokens/README.md @@ -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. diff --git a/refresh-tokens/output/01-rotation.txt b/refresh-tokens/output/01-rotation.txt new file mode 100644 index 0000000..3cd4dca --- /dev/null +++ b/refresh-tokens/output/01-rotation.txt @@ -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 diff --git a/refresh-tokens/output/02-storage.txt b/refresh-tokens/output/02-storage.txt new file mode 100644 index 0000000..40187e7 --- /dev/null +++ b/refresh-tokens/output/02-storage.txt @@ -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 diff --git a/refresh-tokens/output/03-reuse-detection.txt b/refresh-tokens/output/03-reuse-detection.txt new file mode 100644 index 0000000..20e1760 --- /dev/null +++ b/refresh-tokens/output/03-reuse-detection.txt @@ -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) diff --git a/refresh-tokens/output/04-families-are-independent.txt b/refresh-tokens/output/04-families-are-independent.txt new file mode 100644 index 0000000..3cd2757 --- /dev/null +++ b/refresh-tokens/output/04-families-are-independent.txt @@ -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 diff --git a/refresh-tokens/output/05-concurrent-refresh-strict.txt b/refresh-tokens/output/05-concurrent-refresh-strict.txt new file mode 100644 index 0000000..8c79159 --- /dev/null +++ b/refresh-tokens/output/05-concurrent-refresh-strict.txt @@ -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 diff --git a/refresh-tokens/output/06-concurrent-refresh-grace.txt b/refresh-tokens/output/06-concurrent-refresh-grace.txt new file mode 100644 index 0000000..afc4f2e --- /dev/null +++ b/refresh-tokens/output/06-concurrent-refresh-grace.txt @@ -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 diff --git a/refresh-tokens/output/07-logout.txt b/refresh-tokens/output/07-logout.txt new file mode 100644 index 0000000..0224711 --- /dev/null +++ b/refresh-tokens/output/07-logout.txt @@ -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) diff --git a/refresh-tokens/output/08-check-sid.txt b/refresh-tokens/output/08-check-sid.txt new file mode 100644 index 0000000..2043c59 --- /dev/null +++ b/refresh-tokens/output/08-check-sid.txt @@ -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" diff --git a/refresh-tokens/output/09-expiry.txt b/refresh-tokens/output/09-expiry.txt new file mode 100644 index 0000000..4cf0e93 --- /dev/null +++ b/refresh-tokens/output/09-expiry.txt @@ -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 diff --git a/refresh-tokens/output/10-clock-skew.txt b/refresh-tokens/output/10-clock-skew.txt new file mode 100644 index 0000000..e9065dc --- /dev/null +++ b/refresh-tokens/output/10-clock-skew.txt @@ -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 diff --git a/refresh-tokens/output/11-identical-refusals.txt b/refresh-tokens/output/11-identical-refusals.txt new file mode 100644 index 0000000..258db59 --- /dev/null +++ b/refresh-tokens/output/11-identical-refusals.txt @@ -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"} diff --git a/refresh-tokens/output/12-http-walkthrough.txt b/refresh-tokens/output/12-http-walkthrough.txt new file mode 100644 index 0000000..afc63f2 --- /dev/null +++ b/refresh-tokens/output/12-http-walkthrough.txt @@ -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 diff --git a/refresh-tokens/pom.xml b/refresh-tokens/pom.xml new file mode 100644 index 0000000..8bd08bc --- /dev/null +++ b/refresh-tokens/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + refresh-tokens-demo + 1.0.0 + refresh-tokens-demo + JWT refresh token rotation with reuse detection on Spring Security 7.1 - runnable companion for ankurm.com + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/refresh-tokens/scripts/http-walkthrough.sh b/refresh-tokens/scripts/http-walkthrough.sh new file mode 100755 index 0000000..eaf45cb --- /dev/null +++ b/refresh-tokens/scripts/http-walkthrough.sh @@ -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 diff --git a/refresh-tokens/scripts/run-all.sh b/refresh-tokens/scripts/run-all.sh new file mode 100755 index 0000000..89f8515 --- /dev/null +++ b/refresh-tokens/scripts/run-all.sh @@ -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 diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/RefreshTokensApplication.java b/refresh-tokens/src/main/java/com/ankurm/refresh/RefreshTokensApplication.java new file mode 100644 index 0000000..5bfcd80 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/RefreshTokensApplication.java @@ -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); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/config/AppConfig.java b/refresh-tokens/src/main/java/com/ankurm/refresh/config/AppConfig.java new file mode 100644 index 0000000..3f5f68c --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/config/AppConfig.java @@ -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); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/config/JwtConfig.java b/refresh-tokens/src/main/java/com/ankurm/refresh/config/JwtConfig.java new file mode 100644 index 0000000..b0af83b --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/config/JwtConfig.java @@ -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) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build(); + JwtTimestampValidator timestamps = new JwtTimestampValidator(Duration.ZERO); + timestamps.setClock(clock); + List> validators = new ArrayList<>(); + validators.add(timestamps); + validators.add(new JwtIssuerValidator(issuer)); + sidValidator.ifAvailable(validators::add); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); + return decoder; + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/config/SecurityConfig.java b/refresh-tokens/src/main/java/com/ankurm/refresh/config/SecurityConfig.java new file mode 100644 index 0000000..6c428c9 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/config/SecurityConfig.java @@ -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(); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/config/SidValidator.java b/refresh-tokens/src/main/java/com/ankurm/refresh/config/SidValidator.java new file mode 100644 index 0000000..7de8bdf --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/config/SidValidator.java @@ -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 { + + 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)); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/AccessTokenService.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/AccessTokenService.java new file mode 100644 index 0000000..20cc6ae --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/AccessTokenService.java @@ -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()); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/InvalidGrantException.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/InvalidGrantException.java new file mode 100644 index 0000000..b267f15 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/InvalidGrantException.java @@ -0,0 +1,23 @@ +package com.ankurm.refresh.token; + +/** + * A refresh request that must be answered with 401 invalid_grant. + * + *

{@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; + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java new file mode 100644 index 0000000..0a9338f --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java @@ -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. + * + *

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. + * + *

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); + } + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java new file mode 100644 index 0000000..9c464a6 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java @@ -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. + * + *

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 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 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(); + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthController.java b/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthController.java new file mode 100644 index 0000000..6118c13 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthController.java @@ -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 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 logoutAll(@AuthenticationPrincipal Jwt jwt) { + return ordered("revoked_tokens", refreshTokens.revokeAll(jwt.getSubject())); + } + + @GetMapping("/me") + public Map me(@AuthenticationPrincipal Jwt jwt) { + Map 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> invalidGrant(InvalidGrantException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(error("The refresh token is not valid")); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> 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 error(String description) { + Map body = new LinkedHashMap<>(); + body.put("error", "invalid_grant"); + body.put("error_description", description); + return body; + } + + private static Map ordered(String key, Object value) { + Map body = new LinkedHashMap<>(); + body.put(key, value); + return body; + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthDtos.java b/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthDtos.java new file mode 100644 index 0000000..6d1b93d --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/web/AuthDtos.java @@ -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) { } +} diff --git a/refresh-tokens/src/main/resources/application.yaml b/refresh-tokens/src/main/resources/application.yaml new file mode 100644 index 0000000..d7dc130 --- /dev/null +++ b/refresh-tokens/src/main/resources/application.yaml @@ -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 diff --git a/refresh-tokens/src/main/resources/schema.sql b/refresh-tokens/src/main/resources/schema.sql new file mode 100644 index 0000000..fe9cdff --- /dev/null +++ b/refresh-tokens/src/main/resources/schema.sql @@ -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); diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/CheckSidTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/CheckSidTest.java new file mode 100644 index 0000000..b309634 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/CheckSidTest.java @@ -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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshGraceTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshGraceTest.java new file mode 100644 index 0000000..79f6212 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshGraceTest.java @@ -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 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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshStrictTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshStrictTest.java new file mode 100644 index 0000000..7d29940 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/ConcurrentRefreshStrictTest.java @@ -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 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> 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 statuses = new ArrayList<>(); + for (Future 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 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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/ErrorBodyTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/ErrorBodyTest.java new file mode 100644 index 0000000..f301921 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/ErrorBodyTest.java @@ -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 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); + } + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/ExpiryTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/ExpiryTest.java new file mode 100644 index 0000000..95cff18 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/ExpiryTest.java @@ -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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/FlowSupport.java b/refresh-tokens/src/test/java/com/ankurm/refresh/FlowSupport.java new file mode 100644 index 0000000..2190977 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/FlowSupport.java @@ -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 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 names) { + List 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()))); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/LogoutTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/LogoutTest.java new file mode 100644 index 0000000..827cade --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/LogoutTest.java @@ -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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/MutableClock.java b/refresh-tokens/src/test/java/com/ankurm/refresh/MutableClock.java new file mode 100644 index 0000000..139549b --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/MutableClock.java @@ -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(); + } + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/ReuseDetectionTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/ReuseDetectionTest.java new file mode 100644 index 0000000..7ea3f3f --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/ReuseDetectionTest.java @@ -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 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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/RotationTest.java b/refresh-tokens/src/test/java/com/ankurm/refresh/RotationTest.java new file mode 100644 index 0000000..b5783b8 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/RotationTest.java @@ -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 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(); + } +} diff --git a/refresh-tokens/src/test/java/com/ankurm/refresh/Transcript.java b/refresh-tokens/src/test/java/com/ankurm/refresh/Transcript.java new file mode 100644 index 0000000..f638cd0 --- /dev/null +++ b/refresh-tokens/src/test/java/com/ankurm/refresh/Transcript.java @@ -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/.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()); + } +}