Add refresh-tokens module: JWT refresh token rotation, token families and reuse detection on Spring Security 7.1

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b6XzpJxZ25cDyuw1Uk
This commit is contained in:
2026-11-15 01:19:11 +05:30
co-authored by Claude Sonnet 5.5
parent 760fbc2d09
commit 13e8de2193
41 changed files with 1575 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
login as alice
access token: JWT, sid=22badf84
access lifetime: 300 seconds
refresh token: 43 characters, dots in it: 0
after login, refresh(A) -> B, refresh(B) -> C, one family:
token A hash 7a05668b status USED
token B hash 7bb7862a status USED
token C hash a661d1ea status ACTIVE
sid of the access token minted from C: 22badf84
family id in the table: 22badf84
+4
View File
@@ -0,0 +1,4 @@
raw token handed to the client: 43 characters (32 random bytes, base64url)
column token_hash holds: 64 hex characters (SHA-256)
rows matching the raw token: 0
rows matching its SHA-256: 1
@@ -0,0 +1,9 @@
alice logs in and gets refresh token A.
An attacker copies A out of a log file.
alice's app refreshes: A -> B (A is now USED).
the attacker replays A: HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
the family after the replay:
token A hash 7131712c status REVOKED
token B hash 1c626483 status REVOKED
alice's app now presents B: HTTP 401 (the innocent holder is signed out too)
B's access token on /api/me, no sid check: HTTP 200 (still inside its 5 minutes)
@@ -0,0 +1,4 @@
alice logs in on a phone and on a laptop: two families.
phone family 1c2bb6d5, laptop family 8c9ca716
replay of the phone's spent token: HTTP 401
laptop refresh afterwards: HTTP 200
@@ -0,0 +1,6 @@
8 requests, same refresh token, released together, reuse-grace = 0
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 4659f797 status REVOKED
hash 180503f6 status REVOKED
@@ -0,0 +1,10 @@
8 requests, same refresh token, released together, reuse-grace = 5s
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 07e179bf status USED
hash f2fd3302 status ACTIVE
6 seconds later, replaying the spent token again:
HTTP 401, family now:
hash 07e179bf status REVOKED
hash f2fd3302 status REVOKED
+11
View File
@@ -0,0 +1,11 @@
alice is logged in on phone, laptop and tablet (3 families); bob on one device.
POST /api/auth/logout with the phone's refresh token: HTTP 204
phone refresh: HTTP 401
laptop refresh: HTTP 200
POST /api/auth/logout with a made-up token: HTTP 204 (same answer, no oracle)
POST /api/auth/logout-all with the laptop's access token: HTTP 200 {"revoked_tokens":3}
laptop refresh: HTTP 401
tablet refresh: HTTP 401
bob refresh: HTTP 200 (untouched)
alice's laptop ACCESS token on /api/me after logout-all: HTTP 200
(it is a signed JWT with 5 minutes left; nothing in the request path asked the store)
+3
View File
@@ -0,0 +1,3 @@
alice logs in. /api/me with the access token: HTTP 200
after logout-all, same access token, demo.check-sid=true: HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The session behind this token has ended", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost/.well-known/oauth-protected-resource"
+9
View File
@@ -0,0 +1,9 @@
token-ttl = 7d, family-max-age = 30d, access-ttl = 5m
5m01s after login, access token on /api/me: HTTP 401
8 days idle, refresh: HTTP 401 (token-ttl passed)
day 6: refresh OK (every token is only 6 days old)
day 12: refresh OK (every token is only 6 days old)
day 18: refresh OK (every token is only 6 days old)
day 24: refresh OK (every token is only 6 days old)
day 30: refresh HTTP 401, the token is 6 days old but the family is 30 days old
family rows: 5, all in the same family 721853e3
+3
View File
@@ -0,0 +1,3 @@
an access token that expired 30 seconds ago:
decoder with Spring Security's default validators accepts it: true
this module's decoder (clock skew set to zero) accepts it: false
@@ -0,0 +1,4 @@
never issued HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
already used (reuse) HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
revoked by logout HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
expired HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
@@ -0,0 +1,17 @@
$ curl -X POST /api/auth/login (alice)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"FeHO52BY0danWV...","token_type":"Bearer","expires_in":300}
$ curl /api/me (Bearer access token)
{"sub":"alice","sid":"c9ddb06a..."}
HTTP 200
$ curl -X POST /api/auth/refresh (token A)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"zgB_rx1Kaqu_AZ...","token_type":"Bearer","expires_in":300}
$ curl -X POST /api/auth/refresh (token A again: the replay)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
$ curl -X POST /api/auth/refresh (token B, the innocent holder)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401