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:
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::AppError, AppState};
|
||||
|
||||
/// The claims encoded in our JWT access tokens.
|
||||
/// The claims encoded in our JWTs.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject — the user's UUID string.
|
||||
@@ -24,6 +24,10 @@ pub struct Claims {
|
||||
pub exp: usize,
|
||||
/// Token kind: `"access"` or `"refresh"`.
|
||||
pub kind: String,
|
||||
/// JWT ID — UUID v4 embedded in refresh tokens for rotation tracking.
|
||||
/// Access tokens omit this field (`None`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jti: Option<String>,
|
||||
}
|
||||
|
||||
/// The authenticated user identity injected into request extensions after
|
||||
@@ -135,6 +139,7 @@ mod tests {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: kind.to_string(),
|
||||
jti: None,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user