Add refresh-tokens module: JWT refresh token rotation, token families and reuse detection on Spring Security 7.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b6XzpJxZ25cDyuw1Uk
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user