-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Feature: Implement POST /api/v1/auth/refresh-token — Token Rotation Endpoint
Problem
Access tokens are intentionally short-lived (15 minutes) to limit the damage from theft. Without a token rotation endpoint, users would be forced to re-enter their credentials every 15 minutes, making the application unusable. Additionally, if refresh tokens are never rotated, a stolen refresh token remains valid indefinitely until its fixed expiry — a significant security risk.
Proposed Solution
Implement POST /api/v1/auth/refresh-token which reads the refresh token from the HttpOnly cookie set during login, validates it, issues a brand new access token and refresh token pair, and immediately revokes the old refresh token's jti in Redis. This implements the Refresh Token Rotation pattern: each use of a refresh token produces a new one, and the old one is invalidated, making stolen tokens detectable (reuse of a revoked token is treated as a breach).
User Stories
- As a logged-in user, I want my access token to be silently renewed without re-entering my password, so my session stays active without interruption.
- As a security engineer, I want each refresh token to be single-use, so that if a refresh token is stolen and used by an attacker, the server detects the reuse and can invalidate the session.
- As a developer, I want token rotation to be completely transparent to the client — the same cookie is updated — so the frontend requires no special logic beyond retrying a failed request.
Acceptance Criteria
POST /api/v1/auth/refresh-tokenreads the refresh token from theHttpOnlycookie (refresh_token), not from the request body.- Validation:
- If the cookie is absent, return
401 Unauthorized:{ "status": "error", "code": "MISSING_REFRESH_TOKEN", "message": "No refresh token provided.", "details": [] } - If the token signature is invalid or expired, return
401 Unauthorized:{ "status": "error", "code": "INVALID_REFRESH_TOKEN", "message": "Refresh token is invalid or has expired.", "details": [] } - If the token's
jtiis not found in Redis (already revoked), return401 Unauthorizedand trigger a full session invalidation (delete all refresh tokens for this user) to respond to a potential token theft replay attack:{ "status": "error", "code": "REFRESH_TOKEN_REUSE", "message": "Session has been invalidated. Please log in again.", "details": [] }
- If the cookie is absent, return
- Token Rotation:
- The old refresh token
jtiis deleted from Redis. - A new access token and refresh token pair is generated with fresh
jtivalues. - The new refresh token
jtiis stored in Redis with a full TTL reset.
- The old refresh token
- On success, the response is
200 OK:The new refresh token is set as a fresh{ "access_token": "<new_jwt>", "refresh_token": "<new_jwt>", "token_type": "bearer", "expires_in": 900 }HttpOnlycookie (overwriting the previous one):Set-Cookie: refresh_token=<new_jwt>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800 - The user's
is_activeanddeleted_atstatus are re-checked at rotation time. If the account was deactivated since the last login, the rotation returns403 Forbiddenwith codeACCOUNT_DEACTIVATED. - The endpoint is rate-limited to 30 requests/minute per IP (higher than login since clients call this automatically).
- Unit and integration tests cover: valid rotation, missing cookie, expired token, revoked
jti(reuse detection), and deactivated account.
Proposed Technical Details
- Router:
app/api/v1/endpoints/auth.py— newPOST /refresh-tokenroute. - Cookie Read:
request.cookies.get("refresh_token")inside the route handler. - JWT Decode:
jose.jwt.decode(token, SECRET_KEY, algorithms=["HS256"])inapp/core/security.py—decode_refresh_token(token) -> TokenData. - Redis Operations in
app/services/token_store.py:is_refresh_token_valid(jti) -> bool— checks key exists.revoke_refresh_token(jti)— deletes the key.revoke_all_user_tokens(email)— scans and deletes allrefresh_token:{jti}keys for a user (reuse breach response).
- Reuse Detection: On
jtinot found in Redis, callrevoke_all_user_tokens(email)before returning401. This ensures that even if an attacker obtained an old refresh token and used it while the legitimate user was active, the entire session family is torn down. - New/Modified Files:
app/api/v1/endpoints/auth.py— addPOST /refresh-token[MODIFY]app/core/security.py— adddecode_refresh_token[MODIFY]app/services/token_store.py— addis_refresh_token_valid,revoke_all_user_tokens[MODIFY]
Tasks
- Implement
decode_refresh_tokeninapp/core/security.py. - Implement
is_refresh_token_validandrevoke_all_user_tokensinapp/services/token_store.py. - Implement
POST /api/v1/auth/refresh-tokeninapp/api/v1/endpoints/auth.py. - Add re-check of
is_active/deleted_aton token rotation. - Implement reuse detection: revoke all user tokens on stale
jtiusage. - Apply
@limiter.limit("30/minute")rate limit to the refresh route. - Write unit tests for
decode_refresh_token,is_refresh_token_valid, andrevoke_all_user_tokens. - Write integration tests: valid rotation, expired token, missing cookie, revoked
jti(reuse), deactivated account.
Open Questions/Considerations
revoke_all_user_tokensrequires scanning Redis by a user email pattern. Should we maintain a Redis Set per user (user_tokens:{email} → Set{jti, ...}) to make this O(1) instead of a scan?- Should token rotation silently succeed if the user's account is active, or should it also extend the cookie
Max-Ageon each rotation (effectively creating a sliding session)? - If the client receives a
REFRESH_TOKEN_REUSEerror, should it silently redirect to login or display an explicit "your session was accessed from another location" security warning? - Should we implement refresh token families (track a root
family_idacross all rotations) to get even more precise reuse detection granularity?