Files
chore/.github/specs/feat-login-security.md
Ryan Kegel ebaef16daf
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
feat: implement long-term user login with refresh tokens
- Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token.
- Created a new RefreshToken model to manage refresh tokens securely.
- Updated auth_api.py to handle login, refresh, and logout processes with the new token system.
- Enhanced security measures including token rotation and theft detection.
- Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication.
- Removed CORS middleware as it's unnecessary behind the nginx proxy.
- Added tests to ensure functionality and security of the new token system.
2026-03-01 19:27:25 -05:00

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 named refresh_token with max-age and 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/refresh with 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 RefreshToken model (backend/models/refresh_token.py)
  • Add refresh_tokens_db table to backend/db/db.py
  • Add error codes: REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN in error_codes.py
  • Move secret key to SECRET_KEY env var in main.py (hard fail if missing)
  • Add REFRESH_TOKEN_EXPIRY_DAYS env var in main.py (hard fail if missing)
  • Remove CORS (flask-cors from requirements.txt, CORS(app) from main.py)
  • Add SSE authentication — /events endpoint uses get_current_user_id() from cookies instead of user_id query param
  • Consolidate admin_required decorator into utils.py (removed duplicates from admin_api.py and tracking_api.py)
  • Update cookie name from token to access_token in utils.py, user_api.py, auth_api.py
  • Refactor auth_api.py login: issue 15-min access token + refresh token (new family)
  • Add POST /auth/refresh endpoint with rotation and theft detection
  • Refactor auth_api.py logout: delete refresh token from DB, clear both cookies
  • Refactor auth_api.py reset-password: invalidate all refresh tokens + clear cookies
  • Add expired token cleanup (opportunistic, on login/refresh)

Backend Tests

  • All 14 test files updated: SECRET_KEYTEST_SECRET_KEY from conftest, cookie tokenaccess_token
  • test_login_with_correct_password: asserts both access_token and refresh_token cookies
  • test_reset_password_invalidates_existing_jwt: verifies refresh tokens deleted from DB
  • test_me_marked_for_deletion: updated JWT payload with token_version
  • test_admin_api.py: all set_cookie('token')set_cookie('access_token'); jwt.encode uses TEST_SECRET_KEY
  • All 258 backend tests pass

Frontend Implementation

  • Update api.ts 401 interceptor: attempt POST /api/auth/refresh before 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 required SECRET_KEY environment variable
  • Hardcoded secret removed from admin_required decorators (was copy-pasted with literal string)
  • SSE /events endpoint now requires authentication (was open to anyone with a user_id)
  • CORS middleware removed (unnecessary behind nginx same-origin proxy)
  • admin_required decorator consolidated into utils.py (was duplicated in admin_api.py and tracking_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) and refresh_token (persistent, configurable-day, path=/auth)
  • POST /auth/refresh rotates 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