- Introduced LandingHero component with logo, tagline, and action buttons. - Created LandingFeatures component to showcase chore system benefits. - Developed LandingProblem component explaining the importance of a structured chore system. - Implemented LandingFooter for navigation and copyright information. - Added LandingPage to assemble all components and manage navigation. - Included unit tests for LandingHero component to ensure functionality.
7.4 KiB
Feature: Long term user login through refresh tokens.
Overview
Currently, JWT tokens have a long expiration date (62 days). However, the token cookie has no max-age so it's treated as a session cookie — lost when the browser closes. This feature replaces the single long-lived JWT with a dual-token system: a short-lived access token and a long-lived rotating refresh token, plus security hardening.
Goal: Implement long-term user login through a short-lived access token (HttpOnly session cookie) and a configurable-duration refresh token (persistent HttpOnly cookie). Include token family tracking for theft detection, and harden related security gaps.
User Story: As a user, I should be able to log in, and have my credentials remain valid for a configurable number of days (default 90).
Rules:
- Access token is a short-lived JWT in an HttpOnly session cookie (no max-age) — cleared on browser close.
- Refresh token is a random string in a persistent HttpOnly cookie (with max-age) — survives browser close.
- API changes are in auth_api.py.
- Login screen does not change.
- Secret key and refresh token expiry are required environment variables.
Design:
- Access Token (Short-lived): A JWT that lasts 15 minutes. Used for every API call. Stored as an HttpOnly session cookie named
access_token. - Refresh Token (Long-lived): A
secrets.token_urlsafe(32)string stored as a persistent HttpOnly cookie namedrefresh_tokenwithmax-ageand path restricted to/auth. - The Flow: When you open the app after a restart, the access token is gone (session cookie). The frontend's 401 interceptor detects this, sends
POST /auth/refreshwith the refresh token cookie, and the server returns a brand new 15-minute access token + rotated refresh token. - Token Rotation: Every refresh rotates the token. Old tokens are marked
is_used=True. Replay of a used token triggers theft detection — ALL sessions for that user are killed and logged. - On logout, the refresh token is deleted from the DB and both cookies are cleared.
Data Model Changes
Backend Model
New model: RefreshToken dataclass in backend/models/refresh_token.py
| Field | Type | Description |
|---|---|---|
id |
str |
UUID (from BaseModel) |
user_id |
str |
FK to User |
token_hash |
str |
SHA-256 hash of raw token (never store raw) |
token_family |
str |
UUID grouping tokens from one login session |
expires_at |
str |
ISO datetime |
is_used |
bool |
True after rotation; replay of used token = theft |
created_at |
float |
From BaseModel |
updated_at |
float |
From BaseModel |
New TinyDB table: refresh_tokens_db in backend/db/db.py backed by refresh_tokens.json.
Frontend Model
No changes — refresh tokens are entirely server-side.
Backend Implementation
- Create
RefreshTokenmodel (backend/models/refresh_token.py) - Add
refresh_tokens_dbtable tobackend/db/db.py - Add error codes:
REFRESH_TOKEN_REUSE,REFRESH_TOKEN_EXPIRED,MISSING_REFRESH_TOKENinerror_codes.py - Move secret key to
SECRET_KEYenv var inmain.py(hard fail if missing) - Add
REFRESH_TOKEN_EXPIRY_DAYSenv var inmain.py(hard fail if missing) - Remove CORS (
flask-corsfrom requirements.txt,CORS(app)from main.py) - Add SSE authentication —
/eventsendpoint usesget_current_user_id()from cookies instead ofuser_idquery param - Consolidate
admin_requireddecorator intoutils.py(removed duplicates fromadmin_api.pyandtracking_api.py) - Update cookie name from
tokentoaccess_tokeninutils.py,user_api.py,auth_api.py - Refactor
auth_api.pylogin: issue 15-min access token + refresh token (new family) - Add
POST /auth/refreshendpoint with rotation and theft detection - Refactor
auth_api.pylogout: delete refresh token from DB, clear both cookies - Refactor
auth_api.pyreset-password: invalidate all refresh tokens + clear cookies - Add expired token cleanup (opportunistic, on login/refresh)
Backend Tests
- All 14 test files updated:
SECRET_KEY→TEST_SECRET_KEYfrom conftest, cookietoken→access_token test_login_with_correct_password: asserts bothaccess_tokenandrefresh_tokencookiestest_reset_password_invalidates_existing_jwt: verifies refresh tokens deleted from DBtest_me_marked_for_deletion: updated JWT payload withtoken_versiontest_admin_api.py: allset_cookie('token')→set_cookie('access_token');jwt.encodeusesTEST_SECRET_KEY- All 258 backend tests pass
Frontend Implementation
- Update
api.ts401 interceptor: attemptPOST /api/auth/refreshbefore logging out on 401 - Add refresh mutex: concurrent 401s only trigger one refresh call
- Skip refresh for auth endpoints (
/api/auth/refresh,/api/auth/login) - Retry original request after successful refresh
- Update
backendEvents.ts: SSE URL changed from/events?user_id=...to/api/events(cookie-based auth)
Frontend Tests
- Interceptor tests rewritten: refresh-then-retry, refresh-fail-logout, auth-URL skip, concurrent mutex, non-401 passthrough (6 tests)
- backendEvents tests updated: URL assertions use
/api/events - All 287 frontend tests pass
Security Hardening (included in this feature)
- Secret key moved from hardcoded
'supersecretkey'to requiredSECRET_KEYenvironment variable - Hardcoded secret removed from
admin_requireddecorators (was copy-pasted with literal string) - SSE
/eventsendpoint now requires authentication (was open to anyone with a user_id) - CORS middleware removed (unnecessary behind nginx same-origin proxy)
admin_requireddecorator consolidated intoutils.py(was duplicated inadmin_api.pyandtracking_api.py)- Refresh tokens stored as SHA-256 hashes (never raw)
- Token family tracking with automatic session kill on replay (theft detection)
- Refresh token cookie path restricted to
/auth(not sent with every API call)
Future Considerations
- Rate limiting on login, signup, and refresh endpoints
- Configurable access token lifetime via env var
- Background job for expired token cleanup (currently opportunistic)
Acceptance Criteria (Definition of Done)
Backend
- Login returns two cookies:
access_token(session, 15-min JWT) andrefresh_token(persistent, configurable-day, path=/auth) POST /auth/refreshrotates refresh token and issues new access token- Replay of rotated-out refresh token kills all user sessions (theft detection)
- Logout deletes refresh token from DB and clears both cookies
- Password reset invalidates all refresh tokens
- Secret key and refresh token expiry loaded from environment variables
- SSE requires authentication
- CORS removed
- All 258 backend tests pass
Frontend
- 401 interceptor attempts refresh before logging out
- Concurrent 401s trigger only one refresh call
- SSE connects without user_id query param (cookie auth)
- All 287 frontend tests pass