feat: enhance refresh token handling with grace period and rotation detection
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s
This commit is contained in:
@@ -10,3 +10,4 @@ backend/test-results/
|
|||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
frontend/cert.pem
|
frontend/cert.pem
|
||||||
frontend/key.pem
|
frontend/key.pem
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: "Executes a bugfix pipeline on one or more gitea issues: Developer
|
|||||||
|
|
||||||
## What I do
|
## What I do
|
||||||
|
|
||||||
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea. I will then forward information from the issues to the respective subagents.
|
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
|
||||||
|
|
||||||
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
||||||
|
|
||||||
|
|||||||
+67
-22
@@ -43,6 +43,12 @@ try:
|
|||||||
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||||
|
try:
|
||||||
|
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
|
||||||
|
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
|
||||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||||
E2E_TEST_PIN = '1234'
|
E2E_TEST_PIN = '1234'
|
||||||
@@ -412,18 +418,21 @@ def refresh():
|
|||||||
|
|
||||||
token_record = RefreshToken.from_dict(token_dict)
|
token_record = RefreshToken.from_dict(token_dict)
|
||||||
|
|
||||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
# Look up the user early (needed for both legitimate rotation and grace-period handling)
|
||||||
if token_record.is_used:
|
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||||
logger.warning(
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
|
if not user:
|
||||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||||
)
|
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||||
# 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)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
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
|
||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
try:
|
try:
|
||||||
exp = datetime.fromisoformat(token_record.expires_at)
|
exp = datetime.fromisoformat(token_record.expires_at)
|
||||||
@@ -440,23 +449,59 @@ def refresh():
|
|||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
return resp, 401
|
||||||
|
|
||||||
# Look up the user
|
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
if token_record.is_used:
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
# Grace period: tolerate a very recent rotation to avoid false positives
|
||||||
if not user:
|
# from legitimate concurrent refresh requests (race conditions).
|
||||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
grace_period = current_app.config.get(
|
||||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
|
||||||
|
)
|
||||||
|
rotated_at = token_record.rotated_at
|
||||||
|
is_race_condition = False
|
||||||
|
if rotated_at:
|
||||||
|
try:
|
||||||
|
rotated_dt = datetime.fromisoformat(rotated_at)
|
||||||
|
if rotated_dt.tzinfo is None:
|
||||||
|
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
|
||||||
|
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
|
||||||
|
is_race_condition = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_race_condition:
|
||||||
|
logger.info(
|
||||||
|
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
|
||||||
|
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||||
|
)
|
||||||
|
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||||
|
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
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
|
||||||
|
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||||
|
)
|
||||||
|
# Invalidate only the affected family, not every session for the user.
|
||||||
|
refresh_tokens_db.remove(
|
||||||
|
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
|
||||||
|
)
|
||||||
|
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
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
|
# ROTATION: mark old token as used, create new one in same family
|
||||||
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
|
refresh_tokens_db.update(
|
||||||
|
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
|
||||||
|
TokenQuery.id == token_record.id,
|
||||||
|
)
|
||||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||||
|
|
||||||
# Issue new access token
|
# Issue new access token
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RefreshToken(BaseModel):
|
|||||||
token_family: str = ''
|
token_family: str = ''
|
||||||
expires_at: str = ''
|
expires_at: str = ''
|
||||||
is_used: bool = False
|
is_used: bool = False
|
||||||
|
rotated_at: str | None = None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -18,6 +19,7 @@ class RefreshToken(BaseModel):
|
|||||||
'token_family': self.token_family,
|
'token_family': self.token_family,
|
||||||
'expires_at': self.expires_at,
|
'expires_at': self.expires_at,
|
||||||
'is_used': self.is_used,
|
'is_used': self.is_used,
|
||||||
|
'rotated_at': self.rotated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -31,4 +33,5 @@ class RefreshToken(BaseModel):
|
|||||||
token_family=data.get('token_family', ''),
|
token_family=data.get('token_family', ''),
|
||||||
expires_at=data.get('expires_at', ''),
|
expires_at=data.get('expires_at', ''),
|
||||||
is_used=data.get('is_used', False),
|
is_used=data.get('is_used', False),
|
||||||
|
rotated_at=data.get('rotated_at'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import pytest
|
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, _hash_token
|
||||||
from db.db import users_db, refresh_tokens_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, timedelta, timezone
|
||||||
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -188,4 +188,245 @@ def test_migration_script_hashes_plain_text_passwords():
|
|||||||
|
|
||||||
# Check user2 password unchanged
|
# Check user2 password unchanged
|
||||||
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
||||||
assert user2_dict['password'] == already_hashed
|
assert user2_dict['password'] == already_hashed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cookie_value(response, cookie_name):
|
||||||
|
"""Extract a raw cookie value from a response's Set-Cookie headers."""
|
||||||
|
for cookie in response.headers.getlist('Set-Cookie'):
|
||||||
|
if cookie.startswith(f'{cookie_name}='):
|
||||||
|
return cookie.split(f'{cookie_name}=', 1)[1].split(';', 1)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_verified_user(email, password):
|
||||||
|
"""Insert a verified user with the given credentials."""
|
||||||
|
users_db.remove(Query().email == email)
|
||||||
|
user = User(
|
||||||
|
first_name='Test',
|
||||||
|
last_name='User',
|
||||||
|
email=email,
|
||||||
|
password=generate_password_hash(password),
|
||||||
|
verified=True,
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _set_refresh_cookie(client, raw_token):
|
||||||
|
"""
|
||||||
|
Set the refresh token cookie on a test client so it is sent to /auth/refresh.
|
||||||
|
Production uses path='/api/auth' because the frontend calls /api/auth/refresh,
|
||||||
|
but the test fixture exposes the blueprint at /auth/refresh directly.
|
||||||
|
"""
|
||||||
|
client.set_cookie('refresh_token', raw_token, path='/')
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_rotates_token(client):
|
||||||
|
"""A successful refresh marks the old token used and issues a new one in the same family."""
|
||||||
|
email = 'refresh-rotate@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
_create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
old_refresh = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
assert old_refresh
|
||||||
|
|
||||||
|
_set_refresh_cookie(client, old_refresh)
|
||||||
|
refresh_response = client.post('/auth/refresh')
|
||||||
|
assert refresh_response.status_code == 200
|
||||||
|
new_refresh = _extract_cookie_value(refresh_response, 'refresh_token')
|
||||||
|
assert new_refresh
|
||||||
|
assert new_refresh != old_refresh
|
||||||
|
|
||||||
|
user_dict = users_db.get(Query().email == email)
|
||||||
|
old_hash = _hash_token(old_refresh)
|
||||||
|
new_hash = _hash_token(new_refresh)
|
||||||
|
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
assert old_record is not None
|
||||||
|
assert old_record['is_used'] is True
|
||||||
|
assert old_record['rotated_at'] is not None
|
||||||
|
|
||||||
|
new_record = refresh_tokens_db.get(Query().token_hash == new_hash)
|
||||||
|
assert new_record is not None
|
||||||
|
assert new_record['is_used'] is False
|
||||||
|
assert new_record['token_family'] == old_record['token_family']
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_only_invalidates_family(client):
|
||||||
|
"""Replay of a used refresh token only kills its own family, not other devices."""
|
||||||
|
email = 'refresh-family@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
# Device A logs in
|
||||||
|
client_a = client
|
||||||
|
login_a = client_a.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_a.status_code == 200
|
||||||
|
refresh_a = _extract_cookie_value(login_a, 'refresh_token')
|
||||||
|
|
||||||
|
# Device B logs in (separate client = separate cookie jar)
|
||||||
|
app = client_a.application
|
||||||
|
client_b = app.test_client()
|
||||||
|
login_b = client_b.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_b.status_code == 200
|
||||||
|
refresh_b = _extract_cookie_value(login_b, 'refresh_token')
|
||||||
|
|
||||||
|
assert refresh_a != refresh_b
|
||||||
|
|
||||||
|
# Device A refreshes normally
|
||||||
|
_set_refresh_cookie(client_a, refresh_a)
|
||||||
|
refresh_a_response = client_a.post('/auth/refresh')
|
||||||
|
assert refresh_a_response.status_code == 200
|
||||||
|
|
||||||
|
# Capture families before any purge so we can assert afterwards.
|
||||||
|
family_a = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_a))['token_family']
|
||||||
|
family_b = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_b))['token_family']
|
||||||
|
assert family_a != family_b
|
||||||
|
|
||||||
|
# Backdate rotation so the replay is past the grace period and treated as theft.
|
||||||
|
old_hash_a = _hash_token(refresh_a)
|
||||||
|
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||||
|
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash_a)
|
||||||
|
|
||||||
|
# Attacker replays device A's old token
|
||||||
|
_set_refresh_cookie(client_a, refresh_a)
|
||||||
|
reuse_response = client_a.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
# Device B's refresh token should still be valid
|
||||||
|
_set_refresh_cookie(client_b, refresh_b)
|
||||||
|
refresh_b_response = client_b.post('/auth/refresh')
|
||||||
|
assert refresh_b_response.status_code == 200
|
||||||
|
|
||||||
|
# Only family A should be purged; family B should remain
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert family_a not in remaining_families
|
||||||
|
assert family_b in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_within_grace_period_is_tolerated(client):
|
||||||
|
"""A replay within the grace period is treated as a race condition, not theft."""
|
||||||
|
email = 'refresh-race@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
_create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# First refresh marks the token as used
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
first_refresh = client.post('/auth/refresh')
|
||||||
|
assert first_refresh.status_code == 200
|
||||||
|
|
||||||
|
# Immediate replay (same legitimate client racing) should succeed
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
race_response = client.post('/auth/refresh')
|
||||||
|
assert race_response.status_code == 200
|
||||||
|
|
||||||
|
# The family should still be valid
|
||||||
|
user_dict = users_db.get(Query().email == email)
|
||||||
|
family = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_token))['token_family']
|
||||||
|
family_tokens = refresh_tokens_db.search(
|
||||||
|
(Query().user_id == user_dict['id']) & (Query().token_family == family)
|
||||||
|
)
|
||||||
|
assert len(family_tokens) >= 1
|
||||||
|
assert any(t['is_used'] is False for t in family_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_after_grace_period_invalidates_family(client):
|
||||||
|
"""A replay after the grace period is treated as theft and kills only that family."""
|
||||||
|
email = 'refresh-theft@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Refresh once, then backdate the rotation timestamp past the grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||||
|
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash)
|
||||||
|
|
||||||
|
# Replay now should be detected as theft
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_without_rotated_at_invalidates_family(client):
|
||||||
|
"""Legacy used tokens without rotated_at are treated as theft, not race conditions."""
|
||||||
|
email = 'refresh-legacy@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Rotate the token, then strip rotated_at to simulate pre-migration data
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
refresh_tokens_db.update({'rotated_at': None}, Query().token_hash == old_hash)
|
||||||
|
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
|
||||||
|
# Replay should be treated as theft because rotated_at is missing
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_with_zero_grace_period(client):
|
||||||
|
"""A grace period of 0 means any replay of a used token is treated as theft."""
|
||||||
|
email = 'refresh-zero-grace@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
# Configure the app with a 0-second grace period
|
||||||
|
client.application.config['REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS'] = 0
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Rotate the token; rotated_at is within the normal default grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
|
||||||
|
# Immediate replay should still be theft with a 0-second grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ vi.mock('@/stores/auth', () => ({
|
|||||||
logoutUser: () => mockLogoutUser(),
|
logoutUser: () => mockLogoutUser(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function makeLocalStorageStub() {
|
||||||
|
const store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store[key] = value
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
delete store[key]
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
for (const k of Object.keys(store)) delete store[k]
|
||||||
|
},
|
||||||
|
_store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStorageStub = makeLocalStorageStub()
|
||||||
|
|
||||||
describe('installUnauthorizedFetchInterceptor', () => {
|
describe('installUnauthorizedFetchInterceptor', () => {
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
@@ -13,10 +32,13 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
mockLogoutUser.mockReset()
|
mockLogoutUser.mockReset()
|
||||||
globalThis.fetch = vi.fn()
|
globalThis.fetch = vi.fn()
|
||||||
|
localStorageStub.clear()
|
||||||
|
vi.stubGlobal('localStorage', localStorageStub)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('attempts refresh on 401, retries the original request on success', async () => {
|
it('attempts refresh on 401, retries the original request on success', async () => {
|
||||||
@@ -181,4 +203,67 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||||
expect(redirectSpy).not.toHaveBeenCalled()
|
expect(redirectSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sets lastRefreshAt in localStorage after a successful refresh', async () => {
|
||||||
|
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200 } as Response)
|
||||||
|
.mockResolvedValueOnce({ status: 200 } as Response)
|
||||||
|
|
||||||
|
window.history.pushState({}, '', '/parent')
|
||||||
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
|
const {
|
||||||
|
installUnauthorizedFetchInterceptor,
|
||||||
|
setUnauthorizedRedirectHandlerForTests,
|
||||||
|
resetInterceptorStateForTests,
|
||||||
|
} = await import('../api')
|
||||||
|
resetInterceptorStateForTests()
|
||||||
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
|
await fetch('/api/user/profile')
|
||||||
|
|
||||||
|
const lastRefresh = localStorageStub.getItem('lastRefreshAt')
|
||||||
|
expect(lastRefresh).not.toBeNull()
|
||||||
|
expect(Number(lastRefresh)).toBeLessThanOrEqual(Date.now())
|
||||||
|
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||||
|
expect(redirectSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips refresh call when another tab recently refreshed', async () => {
|
||||||
|
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||||
|
// Only original request and retry; refresh should be skipped due to cross-tab coordination
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||||
|
.mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response)
|
||||||
|
|
||||||
|
window.history.pushState({}, '', '/parent')
|
||||||
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
|
const {
|
||||||
|
installUnauthorizedFetchInterceptor,
|
||||||
|
setUnauthorizedRedirectHandlerForTests,
|
||||||
|
resetInterceptorStateForTests,
|
||||||
|
} = await import('../api')
|
||||||
|
resetInterceptorStateForTests()
|
||||||
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
|
// Simulate another tab having refreshed 1 second ago, after resetInterceptorStateForTests
|
||||||
|
localStorageStub.setItem('lastRefreshAt', String(Date.now() - 1000))
|
||||||
|
|
||||||
|
const result = await fetch('/api/user/profile')
|
||||||
|
|
||||||
|
// Should not call /api/auth/refresh; only original + retry
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
'/api/user/profile',
|
||||||
|
'/api/user/profile',
|
||||||
|
])
|
||||||
|
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||||
|
expect(redirectSpy).not.toHaveBeenCalled()
|
||||||
|
expect((result as Response).status).toBe(200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-7
@@ -5,15 +5,20 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"command": [
|
"command": [
|
||||||
"ssh",
|
"docker",
|
||||||
"-T",
|
"run",
|
||||||
"devserver.lan",
|
"-i",
|
||||||
"/home/ryan/gitea-mcp-wrapper.sh"
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
"-e",
|
||||||
|
"GITEA_HOST",
|
||||||
|
"docker.gitea.com/gitea-mcp-server"
|
||||||
],
|
],
|
||||||
"environment": {
|
"environment": {
|
||||||
"GITEA_HOST": "https://git.ryankegel.com"
|
"GITEA_HOST": "https://git.ryankegel.com",
|
||||||
},
|
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
|
||||||
"timeout": 30000
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user