feat(auth): refresh token rotation via jti tracking

Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.

Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-12 13:34:42 -07:00
parent 7d7c83ab28
commit b129664344
10 changed files with 280 additions and 31 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [
{
"name": "jti",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
}