feat: implement long-term user login with refresh tokens
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s

- 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.
This commit is contained in:
2026-03-01 19:27:25 -05:00
parent d7316bb00a
commit ebaef16daf
32 changed files with 713 additions and 201 deletions

138
.github/specs/feat-login-security.md vendored Normal file
View File

@@ -0,0 +1,138 @@
# 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
- [x] Create `RefreshToken` model (`backend/models/refresh_token.py`)
- [x] Add `refresh_tokens_db` table to `backend/db/db.py`
- [x] Add error codes: `REFRESH_TOKEN_REUSE`, `REFRESH_TOKEN_EXPIRED`, `MISSING_REFRESH_TOKEN` in `error_codes.py`
- [x] Move secret key to `SECRET_KEY` env var in `main.py` (hard fail if missing)
- [x] Add `REFRESH_TOKEN_EXPIRY_DAYS` env var in `main.py` (hard fail if missing)
- [x] Remove CORS (`flask-cors` from requirements.txt, `CORS(app)` from main.py)
- [x] Add SSE authentication — `/events` endpoint uses `get_current_user_id()` from cookies instead of `user_id` query param
- [x] Consolidate `admin_required` decorator into `utils.py` (removed duplicates from `admin_api.py` and `tracking_api.py`)
- [x] Update cookie name from `token` to `access_token` in `utils.py`, `user_api.py`, `auth_api.py`
- [x] Refactor `auth_api.py` login: issue 15-min access token + refresh token (new family)
- [x] Add `POST /auth/refresh` endpoint with rotation and theft detection
- [x] Refactor `auth_api.py` logout: delete refresh token from DB, clear both cookies
- [x] Refactor `auth_api.py` reset-password: invalidate all refresh tokens + clear cookies
- [x] Add expired token cleanup (opportunistic, on login/refresh)
## Backend Tests
- [x] All 14 test files updated: `SECRET_KEY``TEST_SECRET_KEY` from conftest, cookie `token``access_token`
- [x] `test_login_with_correct_password`: asserts both `access_token` and `refresh_token` cookies
- [x] `test_reset_password_invalidates_existing_jwt`: verifies refresh tokens deleted from DB
- [x] `test_me_marked_for_deletion`: updated JWT payload with `token_version`
- [x] `test_admin_api.py`: all `set_cookie('token')``set_cookie('access_token')`; `jwt.encode` uses `TEST_SECRET_KEY`
- [x] All 258 backend tests pass
---
## Frontend Implementation
- [x] Update `api.ts` 401 interceptor: attempt `POST /api/auth/refresh` before logging out on 401
- [x] Add refresh mutex: concurrent 401s only trigger one refresh call
- [x] Skip refresh for auth endpoints (`/api/auth/refresh`, `/api/auth/login`)
- [x] Retry original request after successful refresh
- [x] Update `backendEvents.ts`: SSE URL changed from `/events?user_id=...` to `/api/events` (cookie-based auth)
## Frontend Tests
- [x] Interceptor tests rewritten: refresh-then-retry, refresh-fail-logout, auth-URL skip, concurrent mutex, non-401 passthrough (6 tests)
- [x] backendEvents tests updated: URL assertions use `/api/events`
- [x] All 287 frontend tests pass
---
## Security Hardening (included in this feature)
- [x] Secret key moved from hardcoded `'supersecretkey'` to required `SECRET_KEY` environment variable
- [x] Hardcoded secret removed from `admin_required` decorators (was copy-pasted with literal string)
- [x] SSE `/events` endpoint now requires authentication (was open to anyone with a user_id)
- [x] CORS middleware removed (unnecessary behind nginx same-origin proxy)
- [x] `admin_required` decorator consolidated into `utils.py` (was duplicated in `admin_api.py` and `tracking_api.py`)
- [x] Refresh tokens stored as SHA-256 hashes (never raw)
- [x] Token family tracking with automatic session kill on replay (theft detection)
- [x] 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
- [x] Login returns two cookies: `access_token` (session, 15-min JWT) and `refresh_token` (persistent, configurable-day, path=/auth)
- [x] `POST /auth/refresh` rotates refresh token and issues new access token
- [x] Replay of rotated-out refresh token kills all user sessions (theft detection)
- [x] Logout deletes refresh token from DB and clears both cookies
- [x] Password reset invalidates all refresh tokens
- [x] Secret key and refresh token expiry loaded from environment variables
- [x] SSE requires authentication
- [x] CORS removed
- [x] All 258 backend tests pass
### Frontend
- [x] 401 interceptor attempts refresh before logging out
- [x] Concurrent 401s trigger only one refresh call
- [x] SSE connects without user_id query param (cookie auth)
- [x] All 287 frontend tests pass

4
.vscode/launch.json vendored
View File

@@ -9,7 +9,9 @@
"python": "${command:python.interpreterPath}", "python": "${command:python.interpreterPath}",
"env": { "env": {
"FLASK_APP": "backend/main.py", "FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1",
"SECRET_KEY": "dev-secret-key-change-in-production",
"REFRESH_TOKEN_EXPIRY_DAYS": "90"
}, },
"args": [ "args": [
"run", "run",

View File

@@ -1,11 +1,10 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta from datetime import datetime, timedelta
from tinydb import Query from tinydb import Query
import jwt
from functools import wraps
from db.db import users_db from db.db import users_db
from models.user import User from models.user import User
from api.utils import admin_required
from config.deletion_config import ( from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS, ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS, MIN_THRESHOLD_HOURS,
@@ -16,49 +15,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually
admin_api = Blueprint('admin_api', __name__) admin_api = Blueprint('admin_api', __name__)
def admin_required(f):
"""
Decorator to require admin role for endpoints.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get JWT token from cookie
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
# Verify JWT token
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
# Get user from database
Query_ = Query()
user_dict = users_db.get(Query_.id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
# Check if user has admin role
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Pass user to the endpoint
request.current_user = user
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function
@admin_api.route('/admin/deletion-queue', methods=['GET']) @admin_api.route('/admin/deletion-queue', methods=['GET'])
@admin_required @admin_required

View File

@@ -1,7 +1,11 @@
import hashlib
import logging import logging
import secrets, jwt import secrets
import uuid
import jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from models.user import User from models.user import User
from models.refresh_token import RefreshToken
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from tinydb import Query from tinydb import Query
import os import os
@@ -11,17 +15,22 @@ from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email from api.utils import sanitize_email
from config.paths import get_user_image_dir from config.paths import get_user_image_dir
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \ from api.error_codes import (
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \ MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
from db.db import users_db INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION,
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
)
from db.db import users_db, refresh_tokens_db
from api.utils import normalize_email from api.utils import normalize_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_api = Blueprint('auth_api', __name__) auth_api = Blueprint('auth_api', __name__)
UserQuery = Query() UserQuery = Query()
TokenQuery = Query()
TOKEN_EXPIRY_MINUTES = 60 * 4 TOKEN_EXPIRY_MINUTES = 60 * 4
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
ACCESS_TOKEN_EXPIRY_MINUTES = 15
def send_verification_email(to_email, token): def send_verification_email(to_email, token):
@@ -30,6 +39,77 @@ def send_verification_email(to_email, token):
def send_reset_password_email(to_email, token): def send_reset_password_email(to_email, token):
email_sender.send_reset_password_email(to_email, token) email_sender.send_reset_password_email(to_email, token)
def _hash_token(raw_token: str) -> str:
"""SHA-256 hash a raw refresh token for secure storage."""
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
def _create_access_token(user: User) -> str:
"""Create a short-lived JWT access token."""
payload = {
'email': user.email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
"""
Create a refresh token: returns (raw_token, RefreshToken record).
If token_family is None, a new family is created (login).
Otherwise, the existing family is reused (rotation).
"""
raw_token = secrets.token_urlsafe(32)
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
family = token_family or str(uuid.uuid4())
record = RefreshToken(
user_id=user_id,
token_hash=_hash_token(raw_token),
token_family=family,
expires_at=expires_at,
is_used=False,
)
refresh_tokens_db.insert(record.to_dict())
return raw_token, record
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
"""Set both access and refresh token cookies on a response."""
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
resp.set_cookie('access_token', access_token, httponly=True, secure=True, samesite='Strict')
resp.set_cookie(
'refresh_token', raw_refresh_token,
httponly=True, secure=True, samesite='Strict',
max_age=expiry_days * 24 * 3600,
path='/auth',
)
def _clear_auth_cookies(resp):
"""Clear both access and refresh token cookies."""
resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict')
resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/auth')
def _purge_expired_tokens(user_id: str):
"""Remove expired refresh tokens for a user to prevent unbounded DB growth."""
now = datetime.now(timezone.utc)
all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id)
for t in all_tokens:
try:
exp = datetime.fromisoformat(t['expires_at'])
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
refresh_tokens_db.remove(TokenQuery.id == t['id'])
except (ValueError, KeyError):
refresh_tokens_db.remove(TokenQuery.id == t['id'])
@auth_api.route('/signup', methods=['POST']) @auth_api.route('/signup', methods=['POST'])
def signup(): def signup():
data = request.get_json() data = request.get_json()
@@ -159,21 +239,22 @@ def login():
if user.marked_for_deletion: if user.marked_for_deletion:
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
payload = { # Purge expired refresh tokens for this user
'email': norm_email, _purge_expired_tokens(user.id)
'user_id': user.id,
'token_version': user.token_version, # Create access token (short-lived JWT)
'exp': datetime.utcnow() + timedelta(days=62) access_token = _create_access_token(user)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') # Create refresh token (long-lived, new family for fresh login)
raw_refresh, _ = _create_refresh_token(user.id)
resp = jsonify({'message': 'Login successful'}) resp = jsonify({'message': 'Login successful'})
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict') _set_auth_cookies(resp, access_token, raw_refresh)
return resp, 200 return resp, 200
@auth_api.route('/me', methods=['GET']) @auth_api.route('/me', methods=['GET'])
def me(): def me():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401 return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
@@ -275,13 +356,100 @@ def reset_password():
user.token_version += 1 user.token_version += 1
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
# Invalidate ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'message': 'Password has been reset'}) resp = jsonify({'message': 'Password has been reset'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict') _clear_auth_cookies(resp)
return resp, 200 return resp, 200
@auth_api.route('/refresh', methods=['POST'])
def refresh():
raw_token = request.cookies.get('refresh_token')
if not raw_token:
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
token_hash = _hash_token(raw_token)
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
if not token_dict:
# Token not found — could be invalid or already purged
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
token_record = RefreshToken.from_dict(token_dict)
# THEFT DETECTION: token was already used (rotated out) but replayed
if token_record.is_used:
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Nuke ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp)
return resp, 401
# Check expiry
try:
exp = datetime.fromisoformat(token_record.expires_at)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > exp:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
_clear_auth_cookies(resp)
return resp, 401
except ValueError:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
# Look up the user
user_dict = users_db.get(UserQuery.id == token_record.user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
_clear_auth_cookies(resp)
return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
# Issue new access token
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
@auth_api.route('/logout', methods=['POST']) @auth_api.route('/logout', methods=['POST'])
def logout(): def logout():
# Delete the refresh token from DB if present
raw_token = request.cookies.get('refresh_token')
if raw_token:
token_hash = _hash_token(raw_token)
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
resp = jsonify({'message': 'Logged out'}) resp = jsonify({'message': 'Logged out'})
# Remove the token cookie by setting it to empty and expiring it _clear_auth_cookies(resp)
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200 return resp, 200

View File

@@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED" NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION" ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED" ALREADY_MARKED = "ALREADY_MARKED"
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
class ErrorCodes: class ErrorCodes:

View File

@@ -1,61 +1,12 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id from api.utils import get_validated_user_id, admin_required
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
from models.tracking_event import TrackingEvent from models.tracking_event import TrackingEvent
from functools import wraps
import jwt
from tinydb import Query
from db.db import users_db
from models.user import User
tracking_api = Blueprint('tracking_api', __name__) tracking_api = Blueprint('tracking_api', __name__)
def admin_required(f):
"""
Decorator to require admin role for endpoints.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get JWT token from cookie
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
# Verify JWT token
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
# Get user from database
Query_ = Query()
user_dict = users_db.get(Query_.id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
# Check if user has admin role
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user_id in request context
request.admin_user_id = user_id
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return decorated_function
@tracking_api.route('/admin/tracking', methods=['GET']) @tracking_api.route('/admin/tracking', methods=['GET'])
@admin_required @admin_required
def get_tracking(): def get_tracking():

View File

@@ -21,7 +21,7 @@ user_api = Blueprint('user_api', __name__)
UserQuery = Query() UserQuery = Query()
def get_current_user(): def get_current_user():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return None return None
try: try:

View File

@@ -1,10 +1,12 @@
import jwt import jwt
import re import re
from functools import wraps
from db.db import users_db from db.db import users_db
from tinydb import Query from tinydb import Query
from flask import request, current_app, jsonify from flask import request, current_app, jsonify
from events.sse import send_event_to_user from events.sse import send_event_to_user
from models.user import User
def normalize_email(email: str) -> str: def normalize_email(email: str) -> str:
@@ -21,7 +23,7 @@ def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_') return email.replace('@', '_at_').replace('.', '_dot_')
def get_current_user_id(): def get_current_user_id():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return None return None
try: try:
@@ -51,3 +53,45 @@ def send_event_for_current_user(event):
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
send_event_to_user(user_id, event) send_event_to_user(user_id, event)
return None return None
def admin_required(f):
"""
Decorator to require admin role for endpoints.
Validates JWT from access_token cookie and checks admin role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
user_dict = users_db.get(Query().id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user info in request context for the endpoint
request.current_user = user
request.admin_user_id = user_id
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function

View File

@@ -78,6 +78,7 @@ tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
child_overrides_path = os.path.join(base_dir, 'child_overrides.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json') chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json') task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
# Use separate TinyDB instances/files for each collection # Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2) _child_db = TinyDB(child_path, indent=2)
@@ -91,6 +92,7 @@ _tracking_events_db = TinyDB(tracking_events_path, indent=2)
_child_overrides_db = TinyDB(child_overrides_path, indent=2) _child_overrides_db = TinyDB(child_overrides_path, indent=2)
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2) _chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
_task_extensions_db = TinyDB(task_extensions_path, indent=2) _task_extensions_db = TinyDB(task_extensions_path, indent=2)
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
# Expose table objects wrapped with locking # Expose table objects wrapped with locking
child_db = LockedTable(_child_db) child_db = LockedTable(_child_db)
@@ -104,6 +106,7 @@ tracking_events_db = LockedTable(_tracking_events_db)
child_overrides_db = LockedTable(_child_overrides_db) child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db) chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db) task_extensions_db = LockedTable(_task_extensions_db)
refresh_tokens_db = LockedTable(_refresh_tokens_db)
if os.environ.get('DB_ENV', 'prod') == 'test': if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate() child_db.truncate()
@@ -117,4 +120,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
child_overrides_db.truncate() child_overrides_db.truncate()
chore_schedules_db.truncate() chore_schedules_db.truncate()
task_extensions_db.truncate() task_extensions_db.truncate()
refresh_tokens_db.truncate()

View File

@@ -3,7 +3,6 @@ import sys
import os import os
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS
from api.admin_api import admin_api from api.admin_api import admin_api
from api.auth_api import auth_api from api.auth_api import auth_api
@@ -23,6 +22,7 @@ from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from events.broadcaster import Broadcaster from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler from utils.account_deletion_scheduler import start_deletion_scheduler
# Configure logging once at application startup # Configure logging once at application startup
@@ -60,10 +60,23 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar', MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com', MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
SECRET_KEY='supersecretkey' # Replace with a secure key in production
) )
CORS(app) # Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
_secret_key = os.environ.get('SECRET_KEY')
if not _secret_key:
raise RuntimeError(
'SECRET_KEY environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['SECRET_KEY'] = _secret_key
_refresh_expiry = os.environ.get('REFRESH_TOKEN_EXPIRY_DAYS')
if not _refresh_expiry:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS environment variable is required (e.g. 90).')
try:
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = int(_refresh_expiry)
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
@app.route("/version") @app.route("/version")
def api_version(): def api_version():
@@ -71,11 +84,9 @@ def api_version():
@app.route("/events") @app.route("/events")
def events(): def events():
# Authenticate user or read a token user_id = get_current_user_id()
user_id = request.args.get("user_id")
if not user_id: if not user_id:
return {"error": "Missing user_id"}, 400 return {"error": "Authentication required"}, 401
return sse_response_for_user(user_id) return sse_response_for_user(user_id)

View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass, field
from models.base import BaseModel
@dataclass(kw_only=True)
class RefreshToken(BaseModel):
user_id: str = ''
token_hash: str = ''
token_family: str = ''
expires_at: str = ''
is_used: bool = False
def to_dict(self):
return {
**super().to_dict(),
'user_id': self.user_id,
'token_hash': self.token_hash,
'token_family': self.token_family,
'expires_at': self.expires_at,
'is_used': self.is_used,
}
@staticmethod
def from_dict(data: dict) -> 'RefreshToken':
return RefreshToken(
id=data.get('id', ''),
created_at=data.get('created_at', 0),
updated_at=data.get('updated_at', 0),
user_id=data.get('user_id', ''),
token_hash=data.get('token_hash', ''),
token_family=data.get('token_family', ''),
expires_at=data.get('expires_at', ''),
is_used=data.get('is_used', False),
)

Binary file not shown.

View File

@@ -1,11 +1,19 @@
import os import os
os.environ['DB_ENV'] = 'test' os.environ['DB_ENV'] = 'test'
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
import sys import sys
import pytest import pytest
# Ensure backend root is in sys.path for imports like 'config.paths' # Ensure backend root is in sys.path for imports like 'config.paths'
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Shared test constants — import these in test files instead of hardcoding
TEST_SECRET_KEY = 'test-secret-key'
TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def set_test_db_env(): def set_test_db_env():
os.environ['DB_ENV'] = 'test' os.environ['DB_ENV'] = 'test'
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)

View File

@@ -14,13 +14,13 @@ from models.user import User
from db.db import users_db from db.db import users_db
from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS
from tinydb import Query from tinydb import Query
from tests.conftest import TEST_SECRET_KEY
@pytest.fixture @pytest.fixture
def client(): def client():
"""Create test client.""" """Create test client."""
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client: with app.test_client() as client:
yield client yield client
@@ -45,7 +45,7 @@ def admin_user():
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
# Create JWT token # Create JWT token
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256') token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256')
return token return token
@@ -117,7 +117,7 @@ class TestGetDeletionQueue:
def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue): def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue):
"""Test getting deletion queue returns correct users.""" """Test getting deletion queue returns correct users."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
assert response.status_code == 200 assert response.status_code == 200
@@ -147,7 +147,7 @@ class TestGetDeletionQueue:
def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue): def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue):
"""Test that invalid token is rejected.""" """Test that invalid token is rejected."""
client.set_cookie('token', 'invalid_token') client.set_cookie('access_token', 'invalid_token')
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
assert response.status_code == 401 assert response.status_code == 401
@@ -161,11 +161,11 @@ class TestGetDeletionQueue:
# Create expired token # Create expired token
expired_token = jwt.encode( expired_token = jwt.encode(
{'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)}, {'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)},
'supersecretkey', TEST_SECRET_KEY,
algorithm='HS256' algorithm='HS256'
) )
client.set_cookie('token', expired_token) client.set_cookie('access_token', expired_token)
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
assert response.status_code == 401 assert response.status_code == 401
@@ -192,7 +192,7 @@ class TestGetDeletionQueue:
) )
users_db.insert(admin.to_dict()) users_db.insert(admin.to_dict())
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
assert response.status_code == 200 assert response.status_code == 200
@@ -206,7 +206,7 @@ class TestGetDeletionThreshold:
def test_get_threshold_success(self, client, admin_user): def test_get_threshold_success(self, client, admin_user):
"""Test getting current threshold configuration.""" """Test getting current threshold configuration."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-threshold') response = client.get('/admin/deletion-threshold')
assert response.status_code == 200 assert response.status_code == 200
@@ -232,7 +232,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_success(self, client, admin_user): def test_update_threshold_success(self, client, admin_user):
"""Test updating threshold with valid value.""" """Test updating threshold with valid value."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.put( response = client.put(
'/admin/deletion-threshold', '/admin/deletion-threshold',
json={'threshold_hours': 168} json={'threshold_hours': 168}
@@ -245,7 +245,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_validates_minimum(self, client, admin_user): def test_update_threshold_validates_minimum(self, client, admin_user):
"""Test that threshold below minimum is rejected.""" """Test that threshold below minimum is rejected."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.put( response = client.put(
'/admin/deletion-threshold', '/admin/deletion-threshold',
json={'threshold_hours': 23} json={'threshold_hours': 23}
@@ -258,7 +258,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_validates_maximum(self, client, admin_user): def test_update_threshold_validates_maximum(self, client, admin_user):
"""Test that threshold above maximum is rejected.""" """Test that threshold above maximum is rejected."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.put( response = client.put(
'/admin/deletion-threshold', '/admin/deletion-threshold',
json={'threshold_hours': 721} json={'threshold_hours': 721}
@@ -271,7 +271,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_missing_value(self, client, admin_user): def test_update_threshold_missing_value(self, client, admin_user):
"""Test that missing threshold value is rejected.""" """Test that missing threshold value is rejected."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.put( response = client.put(
'/admin/deletion-threshold', '/admin/deletion-threshold',
json={} json={}
@@ -284,7 +284,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_invalid_type(self, client, admin_user): def test_update_threshold_invalid_type(self, client, admin_user):
"""Test that non-integer threshold is rejected.""" """Test that non-integer threshold is rejected."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.put( response = client.put(
'/admin/deletion-threshold', '/admin/deletion-threshold',
json={'threshold_hours': 'invalid'} json={'threshold_hours': 'invalid'}
@@ -310,7 +310,7 @@ class TestTriggerDeletionQueue:
def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue): def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue):
"""Test manually triggering deletion queue.""" """Test manually triggering deletion queue."""
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.post('/admin/deletion-queue/trigger') response = client.post('/admin/deletion-queue/trigger')
assert response.status_code == 200 assert response.status_code == 200
@@ -348,7 +348,7 @@ class TestTriggerDeletionQueue:
) )
users_db.insert(admin.to_dict()) users_db.insert(admin.to_dict())
client.set_cookie('token', admin_user) client.set_cookie('access_token', admin_user)
response = client.post('/admin/deletion-queue/trigger') response = client.post('/admin/deletion-queue/trigger')
assert response.status_code == 200 assert response.status_code == 200
@@ -381,9 +381,9 @@ class TestAdminRoleValidation:
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
# Create token for non-admin # Create token for non-admin
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256') token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token) client.set_cookie('access_token', token)
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
# Should return 403 Forbidden # Should return 403 Forbidden
@@ -414,9 +414,9 @@ class TestAdminRoleValidation:
users_db.insert(admin.to_dict()) users_db.insert(admin.to_dict())
# Create token for admin # Create token for admin
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256') token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token) client.set_cookie('access_token', token)
response = client.get('/admin/deletion-queue') response = client.get('/admin/deletion-queue')
# Should succeed # Should succeed
@@ -439,9 +439,9 @@ class TestAdminRoleValidation:
) )
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256') token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token) client.set_cookie('access_token', token)
response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168}) response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168})
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -2,10 +2,11 @@ import pytest
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask from flask import Flask
from api.auth_api import auth_api from api.auth_api import auth_api
from db.db import users_db from db.db import users_db, refresh_tokens_db
from tinydb import Query from tinydb import Query
from models.user import User from models.user import User
from datetime import datetime from datetime import datetime
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture @pytest.fixture
def client(): def client():
@@ -13,7 +14,8 @@ def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173' app.config['FRONTEND_URL'] = 'http://localhost:5173'
with app.test_client() as client: with app.test_client() as client:
yield client yield client
@@ -54,7 +56,10 @@ def test_login_with_correct_password(client):
data = {'email': 'test@example.com', 'password': 'password123'} data = {'email': 'test@example.com', 'password': 'password123'}
response = client.post('/auth/login', json=data) response = client.post('/auth/login', json=data)
assert response.status_code == 200 assert response.status_code == 200
assert 'token' in response.headers.get('Set-Cookie', '') cookies = response.headers.getlist('Set-Cookie')
cookie_str = ' '.join(cookies)
assert 'access_token=' in cookie_str
assert 'refresh_token=' in cookie_str
def test_login_with_incorrect_password(client): def test_login_with_incorrect_password(client):
"""Test login fails with incorrect password.""" """Test login fails with incorrect password."""
@@ -116,18 +121,30 @@ def test_reset_password_invalidates_existing_jwt(client):
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'}) login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
assert login_response.status_code == 200 assert login_response.status_code == 200
login_cookie = login_response.headers.get('Set-Cookie', '') login_cookies = login_response.headers.getlist('Set-Cookie')
assert 'token=' in login_cookie login_cookie_str = ' '.join(login_cookies)
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0] assert 'access_token=' in login_cookie_str
# Extract the old access token
old_token = None
for c in login_cookies:
if c.startswith('access_token='):
old_token = c.split('access_token=', 1)[1].split(';', 1)[0]
break
assert old_token assert old_token
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'}) reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
assert reset_response.status_code == 200 assert reset_response.status_code == 200
reset_cookie = reset_response.headers.get('Set-Cookie', '') reset_cookies = reset_response.headers.getlist('Set-Cookie')
assert 'token=' in reset_cookie reset_cookie_str = ' '.join(reset_cookies)
assert 'access_token=' in reset_cookie_str
# Verify all refresh tokens for this user are deleted
user_dict = users_db.get(Query().email == 'test@example.com')
user_tokens = refresh_tokens_db.search(Query().user_id == user_dict['id'])
assert len(user_tokens) == 0
# Set the old token as a cookie and test that it's now invalid # Set the old token as a cookie and test that it's now invalid
client.set_cookie('token', old_token) client.set_cookie('access_token', old_token)
me_response = client.get('/auth/me') me_response = client.get('/auth/me')
assert me_response.status_code == 401 assert me_response.status_code == 401
assert me_response.json['code'] == 'INVALID_TOKEN' assert me_response.json['code'] == 'INVALID_TOKEN'

View File

@@ -7,13 +7,15 @@ from models.user import User
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import datetime, timedelta from datetime import datetime, timedelta
import jwt import jwt
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture @pytest.fixture
def client(): def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
yield client yield client
@@ -70,12 +72,13 @@ def test_me_marked_for_deletion(client):
payload = { payload = {
'email': email, 'email': email,
'user_id': user.id, 'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(hours=24) 'exp': datetime.utcnow() + timedelta(hours=24)
} }
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256') token = jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256')
# Make request with token cookie # Make request with token cookie
client.set_cookie('token', token) client.set_cookie('access_token', token)
response = client.get('/auth/me') response = client.get('/auth/me')
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from flask import Flask from flask import Flask
@@ -33,8 +34,9 @@ def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200 assert resp.status_code == 200
# Set cookie for subsequent requests # Set cookie for subsequent requests
token = resp.headers.get("Set-Cookie") cookies = resp.headers.getlist("Set-Cookie")
assert token and "token=" in token cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
# Flask test client automatically handles cookies # Flask test client automatically handles cookies
@pytest.fixture @pytest.fixture
@@ -43,7 +45,8 @@ def client():
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,5 +1,6 @@
"""Tests for child override API endpoints and integration.""" """Tests for child override API endpoints and integration."""
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from flask import Flask from flask import Flask
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -61,7 +62,8 @@ def client():
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -34,7 +35,8 @@ def client():
app.register_blueprint(chore_api) app.register_blueprint(chore_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import date as date_type from datetime import date as date_type
@@ -42,7 +43,8 @@ def client():
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -53,7 +54,8 @@ def client():
app.register_blueprint(chore_schedule_api) app.register_blueprint(chore_schedule_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
add_test_child() add_test_child()

View File

@@ -5,6 +5,7 @@ import time
from config.paths import get_user_image_dir from config.paths import get_user_image_dir
from PIL import Image as PILImage from PIL import Image as PILImage
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from flask import Flask from flask import Flask
@@ -38,8 +39,9 @@ def add_test_user():
def login_and_set_cookie(client): def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") cookies = resp.headers.getlist("Set-Cookie")
assert token and "token=" in token cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
def safe_remove(path): def safe_remove(path):
try: try:
@@ -67,7 +69,8 @@ def client():
app.register_blueprint(image_api) app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as c: with app.test_client() as c:
add_test_user() add_test_user()
remove_test_data() remove_test_data()

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -34,7 +35,8 @@ def client():
app.register_blueprint(kindness_api) app.register_blueprint(kindness_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -34,7 +35,8 @@ def client():
app.register_blueprint(penalty_api) app.register_blueprint(penalty_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -30,8 +31,9 @@ def add_test_user():
def login_and_set_cookie(client): def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") cookies = resp.headers.getlist("Set-Cookie")
assert token and "token=" in token cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
@pytest.fixture @pytest.fixture
def client(): def client():
@@ -39,7 +41,8 @@ def client():
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -29,8 +30,9 @@ def add_test_user():
def login_and_set_cookie(client): def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") cookies = resp.headers.getlist("Set-Cookie")
assert token and "token=" in token cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
@pytest.fixture @pytest.fixture
def client(): def client():
@@ -38,7 +40,8 @@ def client():
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client: with app.test_client() as client:
add_test_user() add_test_user()
login_and_set_cookie(client) login_and_set_cookie(client)

View File

@@ -7,6 +7,7 @@ from db.db import users_db
from tinydb import Query from tinydb import Query
import jwt import jwt
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
# Test user credentials # Test user credentials
TEST_EMAIL = "usertest@example.com" TEST_EMAIL = "usertest@example.com"
@@ -50,9 +51,10 @@ def login_and_get_token(client, email, password):
"""Login and extract JWT token from response.""" """Login and extract JWT token from response."""
resp = client.post('/auth/login', json={"email": email, "password": password}) resp = client.post('/auth/login', json={"email": email, "password": password})
assert resp.status_code == 200 assert resp.status_code == 200
# Extract token from Set-Cookie header # Verify auth cookies are set
set_cookie = resp.headers.get("Set-Cookie") cookies = resp.headers.getlist('Set-Cookie')
assert set_cookie and "token=" in set_cookie cookie_str = ' '.join(cookies)
assert 'access_token=' in cookie_str
# Flask test client automatically handles cookies # Flask test client automatically handles cookies
return resp return resp
@@ -63,7 +65,8 @@ def client():
app.register_blueprint(user_api) app.register_blueprint(user_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
with app.test_client() as client: with app.test_client() as client:
add_test_users() add_test_users()
@@ -200,7 +203,7 @@ def test_mark_for_deletion_clears_tokens(authenticated_client):
def test_mark_for_deletion_with_invalid_jwt(client): def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token.""" """Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually # Set invalid cookie manually
client.set_cookie('token', 'invalid.jwt.token') client.set_cookie('access_token', 'invalid.jwt.token')
response = client.post('/user/mark-for-deletion', json={}) response = client.post('/user/mark-for-deletion', json={})
assert response.status_code == 401 assert response.status_code == 401

View File

@@ -19,15 +19,56 @@ describe('installUnauthorizedFetchInterceptor', () => {
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
}) })
it('logs out and redirects to /auth on 401 outside auth routes', async () => { it('attempts refresh on 401, retries the original request on success', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn> const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
fetchMock.mockResolvedValue({ status: 401 } as Response) // First call: original request → 401
// Second call: refresh → 200 (success)
// Third call: retry original → 200
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ ok: true, status: 200 } as Response)
.mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response)
window.history.pushState({}, '', '/parent/profile') window.history.pushState({}, '', '/parent/profile')
const redirectSpy = vi.fn() const redirectSpy = vi.fn()
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = const {
await import('../api') installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor()
const result = await fetch('/api/user/profile')
// Should NOT have logged out
expect(mockLogoutUser).not.toHaveBeenCalled()
expect(redirectSpy).not.toHaveBeenCalled()
// Should have called fetch 3 times: original, refresh, retry
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/auth/refresh', { method: 'POST' })
expect((result as Response).status).toBe(200)
})
it('logs out when refresh also fails with 401', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
// First call: original → 401
// Second call: refresh → 401 (fail)
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ ok: false, status: 401 } as Response)
window.history.pushState({}, '', '/parent/profile')
const redirectSpy = vi.fn()
const {
installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy) setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor() installUnauthorizedFetchInterceptor()
@@ -37,15 +78,46 @@ describe('installUnauthorizedFetchInterceptor', () => {
expect(redirectSpy).toHaveBeenCalledTimes(1) expect(redirectSpy).toHaveBeenCalledTimes(1)
}) })
it('does not redirect when already on auth route', async () => { it('does not attempt refresh for auth endpoint 401s', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn> const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
fetchMock.mockResolvedValue({ status: 401 } as Response) fetchMock.mockResolvedValue({ status: 401 } as Response)
window.history.pushState({}, '', '/parent/profile')
const redirectSpy = vi.fn()
const {
installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor()
await fetch('/api/auth/refresh', { method: 'POST' })
// Should log out immediately without attempting refresh
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
// Only 1 fetch call — no refresh attempt
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('does not redirect when already on auth route', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
// original → 401, refresh → fail
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ ok: false, status: 401 } as Response)
window.history.pushState({}, '', '/auth/login') window.history.pushState({}, '', '/auth/login')
const redirectSpy = vi.fn() const redirectSpy = vi.fn()
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = const {
await import('../api') installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy) setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor() installUnauthorizedFetchInterceptor()
@@ -55,23 +127,37 @@ describe('installUnauthorizedFetchInterceptor', () => {
expect(redirectSpy).not.toHaveBeenCalled() expect(redirectSpy).not.toHaveBeenCalled()
}) })
it('handles unauthorized redirect only once even for repeated 401 responses', async () => { it('only makes one refresh call for concurrent 401 responses', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn> const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
fetchMock.mockResolvedValue({ status: 401 } as Response) // Both original calls → 401, then one refresh → 200, then both retries → 200
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ ok: true, status: 200 } as Response) // refresh
.mockResolvedValueOnce({ status: 200 } as Response) // retry 1
.mockResolvedValueOnce({ status: 200 } as Response) // retry 2
window.history.pushState({}, '', '/parent/tasks') window.history.pushState({}, '', '/parent/tasks')
const redirectSpy = vi.fn() const redirectSpy = vi.fn()
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = const {
await import('../api') installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy) setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor() installUnauthorizedFetchInterceptor()
await fetch('/api/task/add', { method: 'PUT' }) const [r1, r2] = await Promise.all([
await fetch('/api/image/list?type=2') fetch('/api/task/add', { method: 'PUT' }),
fetch('/api/image/list?type=2'),
])
expect(mockLogoutUser).toHaveBeenCalledTimes(1) expect(mockLogoutUser).not.toHaveBeenCalled()
expect(redirectSpy).toHaveBeenCalledTimes(1) expect(redirectSpy).not.toHaveBeenCalled()
expect((r1 as Response).status).toBe(200)
expect((r2 as Response).status).toBe(200)
}) })
it('does not log out for non-401 responses', async () => { it('does not log out for non-401 responses', async () => {
@@ -81,8 +167,12 @@ describe('installUnauthorizedFetchInterceptor', () => {
window.history.pushState({}, '', '/parent') window.history.pushState({}, '', '/parent')
const redirectSpy = vi.fn() const redirectSpy = vi.fn()
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = const {
await import('../api') installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy) setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor() installUnauthorizedFetchInterceptor()

View File

@@ -59,7 +59,7 @@ describe('useBackendEvents', () => {
await wrapper.setProps({ userId: 'user-1' }) await wrapper.setProps({ userId: 'user-1' })
expect(MockEventSource.instances.length).toBe(1) expect(MockEventSource.instances.length).toBe(1)
expect(MockEventSource.instances[0]?.url).toBe('/events?user_id=user-1') expect(MockEventSource.instances[0]?.url).toBe('/api/events')
}) })
it('reconnects when user id changes and closes previous connection', async () => { it('reconnects when user id changes and closes previous connection', async () => {
@@ -72,7 +72,7 @@ describe('useBackendEvents', () => {
expect(firstConnection?.close).toHaveBeenCalledTimes(1) expect(firstConnection?.close).toHaveBeenCalledTimes(1)
expect(MockEventSource.instances.length).toBe(2) expect(MockEventSource.instances.length).toBe(2)
expect(MockEventSource.instances[1]?.url).toBe('/events?user_id=user-2') expect(MockEventSource.instances[1]?.url).toBe('/api/events')
}) })
it('emits parsed backend events on message', async () => { it('emits parsed backend events on message', async () => {

View File

@@ -4,10 +4,19 @@ let unauthorizedInterceptorInstalled = false
let unauthorizedRedirectHandler: (() => void) | null = null let unauthorizedRedirectHandler: (() => void) | null = null
let unauthorizedHandlingInProgress = false let unauthorizedHandlingInProgress = false
// Mutex for refresh token requests — prevents concurrent refresh calls
let refreshPromise: Promise<boolean> | null = null
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void { export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
unauthorizedRedirectHandler = handler unauthorizedRedirectHandler = handler
} }
/** Reset interceptor state (for tests). */
export function resetInterceptorStateForTests(): void {
unauthorizedHandlingInProgress = false
refreshPromise = null
}
function handleUnauthorizedResponse(): void { function handleUnauthorizedResponse(): void {
if (unauthorizedHandlingInProgress) return if (unauthorizedHandlingInProgress) return
unauthorizedHandlingInProgress = true unauthorizedHandlingInProgress = true
@@ -21,6 +30,33 @@ function handleUnauthorizedResponse(): void {
window.location.assign('/auth') window.location.assign('/auth')
} }
/**
* Attempt to refresh the access token by calling the refresh endpoint.
* Returns true if refresh succeeded, false otherwise.
* Uses a mutex so concurrent 401s only trigger one refresh call.
*/
async function attemptTokenRefresh(originalFetch: typeof fetch): Promise<boolean> {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
try {
const res = await originalFetch('/api/auth/refresh', { method: 'POST' })
return res.ok
} catch {
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
/** URLs that should NOT trigger a refresh attempt on 401. */
function isAuthUrl(url: string): boolean {
return url.includes('/api/auth/refresh') || url.includes('/api/auth/login')
}
export function installUnauthorizedFetchInterceptor(): void { export function installUnauthorizedFetchInterceptor(): void {
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
unauthorizedInterceptorInstalled = true unauthorizedInterceptorInstalled = true
@@ -28,9 +64,28 @@ export function installUnauthorizedFetchInterceptor(): void {
const originalFetch = globalThis.fetch.bind(globalThis) const originalFetch = globalThis.fetch.bind(globalThis)
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => { const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
const response = await originalFetch(...args) const response = await originalFetch(...args)
if (response.status === 401) { if (response.status === 401) {
// Determine the request URL
const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url
// Don't attempt refresh for auth endpoints themselves
if (isAuthUrl(requestUrl)) {
handleUnauthorizedResponse()
return response
}
// Attempt silent refresh
const refreshed = await attemptTokenRefresh(originalFetch)
if (refreshed) {
// Retry the original request with the new access token cookie
return originalFetch(...args)
}
// Refresh failed — log out
handleUnauthorizedResponse() handleUnauthorizedResponse()
} }
return response return response
}) as typeof fetch }) as typeof fetch

View File

@@ -26,7 +26,8 @@ export function useBackendEvents(userId: Ref<string>) {
const connect = () => { const connect = () => {
if (eventSource) eventSource.close() if (eventSource) eventSource.close()
if (userId.value) { if (userId.value) {
eventSource = new EventSource(`/events?user_id=${userId.value}`) // Auth is cookie-based; the browser sends the access_token cookie automatically
eventSource = new EventSource(`/api/events`)
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)