From 028a5b86b5bac2514ec4874a677a90f329d0f7ba Mon Sep 17 00:00:00 2001 From: sricharan varanasi <59170910+sricharan-varanasi@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:19:25 -0400 Subject: [PATCH 1/5] feat: token family claim and rotation grace setting --- .env.default | 3 +++ src/apps/authentication/api/auth.py | 19 ++++++++++++++++--- .../authentication/domain/token/internal.py | 5 +++++ src/apps/authentication/tests/test_auth.py | 11 +++++++++++ .../tests/unit/test_token_payload.py | 12 ++++++++++++ src/config/authentication.py | 4 ++++ 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.env.default b/.env.default index eabefa70d8c..734f8e511e7 100644 --- a/.env.default +++ b/.env.default @@ -41,6 +41,9 @@ AUTHENTICATION__REFRESH_TOKEN__EXPIRATION=540 # per environment if needed. #AUTHENTICATION__ACCESS_TOKEN__WEB_ADMIN_EXPIRATION=15 #AUTHENTICATION__REFRESH_TOKEN__WEB_ADMIN_EXPIRATION=30 +# Grace period (seconds) after a web/admin refresh token rotates during which the old +# token still redeems for the same replacement pair (default 60 in code). +#AUTHENTICATION__REFRESH_TOKEN__ROTATION_GRACE_SECONDS=60 AUTHENTICATION__ALGORITHM="HS256" AUTHENTICATION__TOKEN_TYPE="Bearer" AUTHENTICATION__PASSWORD_RECOVER__EXPIRATION=900 diff --git a/src/apps/authentication/api/auth.py b/src/apps/authentication/api/auth.py index 356ceaf26c1..6775dfba50c 100644 --- a/src/apps/authentication/api/auth.py +++ b/src/apps/authentication/api/auth.py @@ -113,13 +113,14 @@ async def get_token( rjti = str(uuid.uuid4()) refresh_token = AuthenticationService.create_refresh_token( - {JWTClaim.sub: str(user.id), JWTClaim.jti: rjti, **client_token_claims(content_source)} + {JWTClaim.sub: str(user.id), JWTClaim.jti: rjti, JWTClaim.family: rjti, **client_token_claims(content_source)} ) access_token = AuthenticationService.create_access_token( { JWTClaim.sub: str(user.id), JWTClaim.rjti: rjti, + JWTClaim.family: rjti, **client_token_claims(content_source), } ) @@ -308,13 +309,19 @@ async def verify_mfa_totp( # Issue refresh and access tokens rjti = str(uuid.uuid4()) refresh_token = AuthenticationService.create_refresh_token( - {JWTClaim.sub: str(user.id), JWTClaim.jti: rjti, **client_token_claims(content_source)} + { + JWTClaim.sub: str(user.id), + JWTClaim.jti: rjti, + JWTClaim.family: rjti, + **client_token_claims(content_source), + } ) access_token = AuthenticationService.create_access_token( { JWTClaim.sub: str(user.id), JWTClaim.rjti: rjti, + JWTClaim.family: rjti, **client_token_claims(content_source), } ) @@ -585,13 +592,19 @@ async def verify_mfa_recovery_code( # Step 6: Issue refresh and access tokens rjti = str(uuid.uuid4()) refresh_token = AuthenticationService.create_refresh_token( - {JWTClaim.sub: str(user_id), JWTClaim.jti: rjti, **client_token_claims(content_source)} + { + JWTClaim.sub: str(user_id), + JWTClaim.jti: rjti, + JWTClaim.family: rjti, + **client_token_claims(content_source), + } ) access_token = AuthenticationService.create_access_token( { JWTClaim.sub: str(user_id), JWTClaim.rjti: rjti, + JWTClaim.family: rjti, **client_token_claims(content_source), } ) diff --git a/src/apps/authentication/domain/token/internal.py b/src/apps/authentication/domain/token/internal.py index d3b1255240e..aaf00bd937f 100644 --- a/src/apps/authentication/domain/token/internal.py +++ b/src/apps/authentication/domain/token/internal.py @@ -23,6 +23,7 @@ class JWTClaim(StrEnum): rjti = "rjti" mfa_session_id = "mfa_session_id" client = "client" + family = "family" class TokenPayload(InternalModel): @@ -34,6 +35,10 @@ class TokenPayload(InternalModel): # None for tokens issued before the claim existed or to clients that do not # send the header client: MindloggerContentSource | None = None + # Token family (the login refresh token's jti). Shared by every access/refresh + # token descended from one login, so a whole rotated chain can be revoked at once. + # None for tokens issued before the claim existed. + family: str | None = None class InternalToken(InternalModel): diff --git a/src/apps/authentication/tests/test_auth.py b/src/apps/authentication/tests/test_auth.py index 01d9044d8b4..c388e75f841 100644 --- a/src/apps/authentication/tests/test_auth.py +++ b/src/apps/authentication/tests/test_auth.py @@ -443,6 +443,17 @@ async def test_login_embeds_client_claim(self, client: TestClient, user: User, c self._assert_expires_in(access_payload["exp"], expected_access, before, after) self._assert_expires_in(refresh_payload["exp"], expected_refresh, before, after) + async def test_login_stamps_matching_family_claim(self, client: TestClient, user: User): + """Both tokens carry the same family claim, equal to the login refresh token's jti.""" + resp = await client.post( + self.get_token_url, + data={"email": user.email_encrypted, "password": TEST_PASSWORD}, + headers={"Mindlogger-Content-Source": "admin"}, + ) + assert resp.status_code == http.HTTPStatus.OK + access_payload, refresh_payload = self._decode_tokens(resp.json()["result"]) + assert access_payload["family"] == refresh_payload["family"] == refresh_payload["jti"] + async def test_login_audit_event_records_client_source(self, client: TestClient, user: User, mocker: MockerFixture): audit_log = mocker.patch("apps.authentication.api.auth.log") resp = await client.post( diff --git a/src/apps/authentication/tests/unit/test_token_payload.py b/src/apps/authentication/tests/unit/test_token_payload.py index ef15e27e0e4..5513ec8eff4 100644 --- a/src/apps/authentication/tests/unit/test_token_payload.py +++ b/src/apps/authentication/tests/unit/test_token_payload.py @@ -31,3 +31,15 @@ def test_token_payload_with_client_claim(client: MindloggerContentSource): def test_token_payload_with_unknown_client_value(): with pytest.raises(ValidationError): TokenPayload(**payload_data(client="invalid-content-source")) + + +def test_token_payload_without_family_claim(): + """Tokens issued before the family claim existed must still parse.""" + payload = TokenPayload(**payload_data()) + assert payload.family is None + + +def test_token_payload_with_family_claim(): + family = str(uuid.uuid4()) + payload = TokenPayload(**payload_data(family=family)) + assert payload.family == family diff --git a/src/config/authentication.py b/src/config/authentication.py index 11e1e9e967a..f1dcb1d7e2a 100644 --- a/src/config/authentication.py +++ b/src/config/authentication.py @@ -26,6 +26,10 @@ class RefreshTokenSettings(BaseModel): # Shorter lifetime (minutes) for web/admin clients. None = same as `expiration`. # See AuthenticationService.token_expiration_minutes. web_admin_expiration: int | None = 30 + # Grace period (seconds) after a web/admin refresh token is rotated during which + # the old token still redeems for the same replacement pair (absorbs tab races / + # dropped responses). After it, presenting the old token is treated as reuse. + rotation_grace_seconds: int = 60 transition_key: str | None = None transition_expire_date: datetime.date | None = None From 1a0abf97c5af6f0467d94d5e2d3f31cf0cad9591 Mon Sep 17 00:00:00 2001 From: sricharan varanasi <59170910+sricharan-varanasi@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:32:51 -0400 Subject: [PATCH 2/5] feat: rotation service --- src/apps/authentication/services/rotation.py | 54 +++++++++++++++++++ .../tests/unit/test_token_rotation.py | 43 +++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/apps/authentication/services/rotation.py create mode 100644 src/apps/authentication/tests/unit/test_token_rotation.py diff --git a/src/apps/authentication/services/rotation.py b/src/apps/authentication/services/rotation.py new file mode 100644 index 00000000000..21b7c12c375 --- /dev/null +++ b/src/apps/authentication/services/rotation.py @@ -0,0 +1,54 @@ +import json +import uuid +from datetime import datetime, timedelta, timezone + +from apps.authentication.crud import TokenBlacklistCRUD +from apps.authentication.domain.token import InternalToken, Token, TokenPayload, TokenPurpose +from apps.authentication.services.core import TokensService +from config import settings +from infrastructure.utility.redis_client import RedisCache + +__all__ = ["TokenRotationService"] + + +class TokenRotationService: + """Refresh-token rotation support for web/admin clients. + + Two responsibilities: + - a short-lived Redis "grace record" mapping a just-rotated refresh token's jti to + the replacement token pair, so the old token redeems idempotently for a brief window; + - family revocation via the existing token blacklist, keyed by the family id, so a whole + rotated chain can be killed at once (on reuse detection or logout). + """ + + def __init__(self, session) -> None: + self.session = session + self.redis_client = RedisCache() + + @staticmethod + def _grace_key(old_jti: str) -> str: + return f"token_rotation:{old_jti}" + + async def get_rotation_replacement(self, old_jti: str) -> Token | None: + raw = await self.redis_client.get(self._grace_key(old_jti)) + if not raw: + return None + data = json.loads(raw) + return Token(access_token=data["access_token"], refresh_token=data["refresh_token"]) + + async def store_rotation_record(self, old_jti: str, token: Token) -> None: + await self.redis_client.set( + self._grace_key(old_jti), + json.dumps({"access_token": token.access_token, "refresh_token": token.refresh_token}), + ex=settings.authentication.refresh_token.rotation_grace_seconds, + ) + + async def is_family_revoked(self, family_id: str) -> bool: + return await TokenBlacklistCRUD(self.session).exist_by_key("jti", family_id) + + async def revoke_family(self, family_id: str, user_id: uuid.UUID) -> None: + # Blacklist a synthetic token whose jti is the family id, retained comfortably past + # any live token in the family (refresh lifetime is the longest a token can survive). + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.authentication.refresh_token.expiration) + family_token = InternalToken(payload=TokenPayload(sub=user_id, exp=int(expire.timestamp()), jti=family_id)) + await TokensService(self.session).revoke(family_token, TokenPurpose.REFRESH) diff --git a/src/apps/authentication/tests/unit/test_token_rotation.py b/src/apps/authentication/tests/unit/test_token_rotation.py new file mode 100644 index 00000000000..996198fcce1 --- /dev/null +++ b/src/apps/authentication/tests/unit/test_token_rotation.py @@ -0,0 +1,43 @@ +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from apps.authentication.domain.token import Token +from apps.authentication.services.rotation import TokenRotationService +from apps.users.domain import User + + +class TestTokenRotationService: + async def test_store_and_get_rotation_replacement(self, session: AsyncSession): + service = TokenRotationService(session) + old_jti = str(uuid.uuid4()) + pair = Token(access_token="access-abc", refresh_token="refresh-def") + + await service.store_rotation_record(old_jti, pair) + replacement = await service.get_rotation_replacement(old_jti) + + assert replacement is not None + assert replacement.access_token == "access-abc" + assert replacement.refresh_token == "refresh-def" + + async def test_get_rotation_replacement_missing_returns_none(self, session: AsyncSession): + service = TokenRotationService(session) + assert await service.get_rotation_replacement(str(uuid.uuid4())) is None + + async def test_revoke_family_and_is_family_revoked(self, session: AsyncSession, user: User): + service = TokenRotationService(session) + family_id = str(uuid.uuid4()) + + assert await service.is_family_revoked(family_id) is False + await service.revoke_family(family_id, user.id) + assert await service.is_family_revoked(family_id) is True + + async def test_revoke_family_scoped_to_its_id(self, session: AsyncSession, user: User): + service = TokenRotationService(session) + revoked = str(uuid.uuid4()) + other = str(uuid.uuid4()) + + await service.revoke_family(revoked, user.id) + + assert await service.is_family_revoked(revoked) is True + assert await service.is_family_revoked(other) is False From d4232d9faa112a65008b11f4b0b275eb94111ea1 Mon Sep 17 00:00:00 2001 From: sricharan varanasi <59170910+sricharan-varanasi@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:05:21 -0400 Subject: [PATCH 3/5] feat: rotate refresh tokens for web/admin clients --- src/apps/authentication/api/auth.py | 102 +++++++++++---- src/apps/authentication/services/rotation.py | 15 ++- src/apps/authentication/tests/test_auth.py | 130 ++++++++++++++++++- 3 files changed, 220 insertions(+), 27 deletions(-) diff --git a/src/apps/authentication/api/auth.py b/src/apps/authentication/api/auth.py index 6775dfba50c..e6fefceca14 100644 --- a/src/apps/authentication/api/auth.py +++ b/src/apps/authentication/api/auth.py @@ -35,6 +35,7 @@ from apps.authentication.services.mfa_notifications import MFANotificationService from apps.authentication.services.mfa_session import MFASessionService from apps.authentication.services.recovery_codes import send_recovery_code_notifications, verify_recovery_code_service +from apps.authentication.services.rotation import TokenRotationService from apps.authentication.services.security import AuthenticationService from apps.shared.domain.response import Response from apps.shared.exception import BaseError @@ -653,6 +654,7 @@ async def refresh_access_token( ) -> Response[Token]: """Refresh access token.""" user_id: uuid.UUID | None = None + reuse_family: str | None = None try: async with atomic(session): try: @@ -684,37 +686,91 @@ async def refresh_access_token( raise InvalidRefreshToken() from e user_id = token_data.sub + family = token_data.family or token_data.jti + is_web_admin = token_data.client in (MindloggerContentSource.web, MindloggerContentSource.admin) + + if is_web_admin: + # Rotating clients: slide the refresh window by issuing a fresh token each time, + # with a grace window that idempotently redeems the old token, and reuse detection + # that revokes the whole family. + rotation = TokenRotationService(session) + + if await rotation.is_family_revoked(family): + raise AuthenticationError + + replacement = await rotation.get_rotation_replacement(token_data.jti) + if replacement is not None: + # Within the grace window: hand back the same replacement pair. + access_token = replacement.access_token + refresh_token = replacement.refresh_token + elif await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)): + # Old token replayed after its grace window -> treat as theft. Defer the + # family revocation to its own committed transaction (raising here would roll + # back this atomic block and undo it). + reuse_family = family + else: + new_rjti = str(uuid.uuid4()) + refresh_token = AuthenticationService.create_refresh_token( + { + JWTClaim.sub: str(user_id), + JWTClaim.jti: new_rjti, + JWTClaim.family: family, + **client_token_claims(token_data.client), + } + ) + access_token = AuthenticationService.create_access_token( + { + JWTClaim.sub: str(user_id), + JWTClaim.rjti: new_rjti, + JWTClaim.family: family, + **client_token_claims(token_data.client), + } + ) + # Mark the old refresh token used, and record the replacement for the grace window. + await AuthenticationService(session).revoke_token( + InternalToken(payload=token_data), TokenPurpose.REFRESH + ) + await rotation.store_rotation_record( + token_data.jti, + Token(access_token=access_token, refresh_token=refresh_token), + ) + else: + # Mobile / unknown / legacy: reuse the same refresh token (unchanged behavior). + revoked = await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)) + if revoked: + raise AuthenticationError + + rjti = token_data.jti + refresh_token = schema.refresh_token + if regenerate_refresh_token: + # blacklist current refresh token + await AuthenticationService(session).revoke_token( + InternalToken(payload=token_data), TokenPurpose.REFRESH + ) - # Check if the token is in the blacklist - revoked = await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)) - if revoked: - raise AuthenticationError - - rjti = token_data.jti - refresh_token = schema.refresh_token - if regenerate_refresh_token: - # blacklist current refresh token - await AuthenticationService(session).revoke_token( - InternalToken(payload=token_data), TokenPurpose.REFRESH - ) + rjti = str(uuid.uuid4()) + refresh_token = AuthenticationService.create_refresh_token( + { + JWTClaim.sub: str(user_id), + JWTClaim.jti: rjti, + JWTClaim.exp: token_data.exp, + **client_token_claims(token_data.client), + } + ) - rjti = str(uuid.uuid4()) - refresh_token = AuthenticationService.create_refresh_token( + access_token = AuthenticationService.create_access_token( { JWTClaim.sub: str(user_id), - JWTClaim.jti: rjti, - JWTClaim.exp: token_data.exp, + JWTClaim.rjti: rjti, **client_token_claims(token_data.client), } ) - access_token = AuthenticationService.create_access_token( - { - JWTClaim.sub: str(user_id), - JWTClaim.rjti: rjti, - **client_token_claims(token_data.client), - } - ) + if reuse_family is not None: + # Commit the family revocation in its own transaction, then reject the request. + async with atomic(session): + await TokenRotationService(session).revoke_family(reuse_family, user_id) + raise AuthenticationError except BaseError as e: await log( AuditEvent( diff --git a/src/apps/authentication/services/rotation.py b/src/apps/authentication/services/rotation.py index 21b7c12c375..c506e256d5e 100644 --- a/src/apps/authentication/services/rotation.py +++ b/src/apps/authentication/services/rotation.py @@ -29,6 +29,13 @@ def __init__(self, session) -> None: def _grace_key(old_jti: str) -> str: return f"token_rotation:{old_jti}" + @staticmethod + def _family_blacklist_jti(family_id: str) -> str: + # Namespaced so a family-revocation row never collides with a real token jti + # (the family id equals the login refresh token's jti, which itself gets + # blacklisted on its first rotation). + return f"family:{family_id}" + async def get_rotation_replacement(self, old_jti: str) -> Token | None: raw = await self.redis_client.get(self._grace_key(old_jti)) if not raw: @@ -44,11 +51,13 @@ async def store_rotation_record(self, old_jti: str, token: Token) -> None: ) async def is_family_revoked(self, family_id: str) -> bool: - return await TokenBlacklistCRUD(self.session).exist_by_key("jti", family_id) + return await TokenBlacklistCRUD(self.session).exist_by_key("jti", self._family_blacklist_jti(family_id)) async def revoke_family(self, family_id: str, user_id: uuid.UUID) -> None: - # Blacklist a synthetic token whose jti is the family id, retained comfortably past + # Blacklist a synthetic row under the namespaced family jti, retained comfortably past # any live token in the family (refresh lifetime is the longest a token can survive). expire = datetime.now(timezone.utc) + timedelta(minutes=settings.authentication.refresh_token.expiration) - family_token = InternalToken(payload=TokenPayload(sub=user_id, exp=int(expire.timestamp()), jti=family_id)) + family_token = InternalToken( + payload=TokenPayload(sub=user_id, exp=int(expire.timestamp()), jti=self._family_blacklist_jti(family_id)) + ) await TokensService(self.session).revoke(family_token, TokenPurpose.REFRESH) diff --git a/src/apps/authentication/tests/test_auth.py b/src/apps/authentication/tests/test_auth.py index c388e75f841..806c83ef8eb 100644 --- a/src/apps/authentication/tests/test_auth.py +++ b/src/apps/authentication/tests/test_auth.py @@ -19,6 +19,7 @@ ) from apps.authentication.router import router as auth_router from apps.authentication.services import AuthenticationService +from apps.authentication.services.rotation import TokenRotationService from apps.authentication.tests.factories import UserLogoutRequestFactory from apps.shared.test import BaseTest from apps.shared.test.client import TestClient @@ -156,7 +157,8 @@ async def test_refresh_access_token__propagates_client_claim( response = await client.post(url=self.refresh_access_token_url, data={"refresh_token": refresh_token}) assert response.status_code == http.HTTPStatus.OK result = response.json()["result"] - assert result["refreshToken"] == refresh_token + # admin is a rotating client: refresh issues a new refresh token, claim preserved. + assert result["refreshToken"] != refresh_token access_payload = jwt.decode( result["accessToken"], settings.authentication.access_token.secret_key, @@ -202,6 +204,8 @@ async def test_refresh_token_key_transition__preserves_client_claim( token_settings_mock.transition_key = token_key token_settings_mock.transition_expire_date = transition_expire_date token_settings_mock.expiration = 540 + token_settings_mock.web_admin_expiration = 30 + token_settings_mock.rotation_grace_seconds = 60 _status_code, new_refresh_token = await self._request_refresh_token(client, refresh_token) assert _status_code == http.HTTPStatus.OK @@ -626,3 +630,127 @@ async def test_logout_revokes_paired_refresh_token_under_short_lifetimes( ) assert resp.status_code == http.HTTPStatus.UNAUTHORIZED assert resp.json()["result"][0]["message"] == AuthenticationError.message + + +class TestRefreshTokenRotation(BaseTest): + """Web/admin refresh tokens rotate with an idempotent grace window and reuse detection.""" + + get_token_url = auth_router.url_path_for("get_token") + refresh_access_token_url = auth_router.url_path_for("refresh_access_token") + + @staticmethod + def _refresh_payload(refresh_token: str) -> dict: + return jwt.decode( + refresh_token, + settings.authentication.refresh_token.secret_key, + algorithms=[settings.authentication.algorithm], + ) + + @staticmethod + def _access_payload(access_token: str) -> dict: + return jwt.decode( + access_token, + settings.authentication.access_token.secret_key, + algorithms=[settings.authentication.algorithm], + ) + + async def _login(self, client: TestClient, user: User, source: str = "admin") -> dict: + resp = await client.post( + self.get_token_url, + data={"email": user.email_encrypted, "password": TEST_PASSWORD}, + headers={"Mindlogger-Content-Source": source}, + ) + assert resp.status_code == http.HTTPStatus.OK + return resp.json()["result"]["token"] + + async def _refresh(self, client: TestClient, refresh_token: str, source: str = "admin"): + return await client.post( + self.refresh_access_token_url, + data={"refresh_token": refresh_token}, + headers={"Mindlogger-Content-Source": source}, + ) + + async def test_web_admin_refresh_rotates_and_preserves_family( + self, client: TestClient, user: User, mocker: MockerFixture + ): + mocker.patch("apps.authentication.api.auth.log") + token = await self._login(client, user) + r1 = token["refreshToken"] + family = self._refresh_payload(r1)["family"] + + resp = await self._refresh(client, r1) + assert resp.status_code == http.HTTPStatus.OK + result = resp.json()["result"] + + assert result["refreshToken"] != r1 + r2_payload = self._refresh_payload(result["refreshToken"]) + assert r2_payload["family"] == family + assert r2_payload["jti"] != self._refresh_payload(r1)["jti"] + access_payload = self._access_payload(result["accessToken"]) + assert access_payload["family"] == family + assert access_payload["client"] == "admin" + + async def test_web_admin_refresh_idempotent_within_grace( + self, client: TestClient, user: User, mocker: MockerFixture + ): + mocker.patch("apps.authentication.api.auth.log") + r1 = (await self._login(client, user))["refreshToken"] + + first = (await self._refresh(client, r1)).json()["result"] + second = (await self._refresh(client, r1)).json()["result"] + + # Same old token within the grace window -> identical replacement pair. + assert first["refreshToken"] == second["refreshToken"] + assert first["accessToken"] == second["accessToken"] + + # And the replacement itself works (rotates again). + third = await self._refresh(client, first["refreshToken"]) + assert third.status_code == http.HTTPStatus.OK + assert third.json()["result"]["refreshToken"] != first["refreshToken"] + + async def test_web_admin_reuse_after_grace_revokes_family( + self, client: TestClient, user: User, session: AsyncSession, mocker: MockerFixture + ): + mocker.patch("apps.authentication.api.auth.log") + r1 = (await self._login(client, user))["refreshToken"] + j1 = self._refresh_payload(r1)["jti"] + + r2 = (await self._refresh(client, r1)).json()["result"]["refreshToken"] + + # Simulate the grace window elapsing. + rotation = TokenRotationService(session) + await rotation.redis_client.delete(rotation._grace_key(j1)) + + # Replaying the old token now looks like theft -> whole family revoked. + reuse = await self._refresh(client, r1) + assert reuse.status_code == http.HTTPStatus.UNAUTHORIZED + + # The newest (legitimate) token is dead too. + after = await self._refresh(client, r2) + assert after.status_code == http.HTTPStatus.UNAUTHORIZED + + async def test_mobile_refresh_reuses_same_token(self, client: TestClient, user: User, mocker: MockerFixture): + mocker.patch("apps.authentication.api.auth.log") + resp = await client.post( + self.get_token_url, + data={"email": user.email_encrypted, "password": TEST_PASSWORD}, + headers={"Mindlogger-Content-Source": "mobile"}, + ) + r1 = resp.json()["result"]["token"]["refreshToken"] + + out = await self._refresh(client, r1, source="mobile") + assert out.status_code == http.HTTPStatus.OK + assert out.json()["result"]["refreshToken"] == r1 + + async def test_legacy_web_admin_token_adopts_family(self, client: TestClient, user: User, mocker: MockerFixture): + mocker.patch("apps.authentication.api.auth.log") + legacy = AuthenticationService.create_refresh_token( + {"sub": str(user.id), "jti": str(uuid.uuid4()), "client": "admin"} + ) + legacy_jti = self._refresh_payload(legacy)["jti"] + assert "family" not in self._refresh_payload(legacy) + + resp = await self._refresh(client, legacy) + assert resp.status_code == http.HTTPStatus.OK + rotated = resp.json()["result"]["refreshToken"] + assert self._refresh_payload(rotated)["family"] == legacy_jti From 4e7c24890b909298dddaa6daa424530cbff1f0ba Mon Sep 17 00:00:00 2001 From: sricharan varanasi <59170910+sricharan-varanasi@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:17:03 -0400 Subject: [PATCH 4/5] fix: logout revokes the whole token family for web/admin --- src/apps/authentication/api/auth.py | 12 ++++++++++++ src/apps/authentication/tests/test_auth.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/apps/authentication/api/auth.py b/src/apps/authentication/api/auth.py index e6fefceca14..82b144e3f4a 100644 --- a/src/apps/authentication/api/auth.py +++ b/src/apps/authentication/api/auth.py @@ -58,6 +58,16 @@ def client_token_claims(content_source: MindloggerContentSource | None) -> dict: return {JWTClaim.client: content_source} if content_source else {} +async def revoke_token_family_if_web_admin(session, token: InternalToken) -> None: + """On logout of a rotating (web/admin) token, revoke its whole family so a superseded + refresh token in the same chain cannot keep the session alive.""" + if token.payload.family and token.payload.client in ( + MindloggerContentSource.web, + MindloggerContentSource.admin, + ): + await TokenRotationService(session).revoke_family(token.payload.family, token.payload.sub) + + async def get_token( request: Request, user_login_schema: UserLoginRequest = Body(...), @@ -802,6 +812,7 @@ async def delete_access_token( try: async with atomic(session): await AuthenticationService(session).revoke_token(token, TokenPurpose.ACCESS) + await revoke_token_family_if_web_admin(session, token) async with atomic(session): if schema and schema.device_id: await UserDeviceService(session, user.id).remove_device(schema.device_id) @@ -833,6 +844,7 @@ async def delete_refresh_token( """Add token to the blacklist.""" async with atomic(session): await AuthenticationService(session).revoke_token(token, TokenPurpose.REFRESH) + await revoke_token_family_if_web_admin(session, token) if schema and schema.device_id: async with atomic(session): await UserDeviceService(session, token.payload.sub).remove_device(schema.device_id) diff --git a/src/apps/authentication/tests/test_auth.py b/src/apps/authentication/tests/test_auth.py index 806c83ef8eb..c5c512d86ab 100644 --- a/src/apps/authentication/tests/test_auth.py +++ b/src/apps/authentication/tests/test_auth.py @@ -754,3 +754,23 @@ async def test_legacy_web_admin_token_adopts_family(self, client: TestClient, us assert resp.status_code == http.HTTPStatus.OK rotated = resp.json()["result"]["refreshToken"] assert self._refresh_payload(rotated)["family"] == legacy_jti + + async def test_logout_with_stale_access_token_revokes_family( + self, client: TestClient, user: User, mocker: MockerFixture + ): + mocker.patch("apps.authentication.api.auth.log") + token = await self._login(client, user) + original_access = token["accessToken"] + + # Rotate the refresh token so the original access token's paired refresh is superseded. + r2 = (await self._refresh(client, token["refreshToken"])).json()["result"]["refreshToken"] + + # Logging out with the (now stale) original access token must kill the whole family. + logout = await client.post( + auth_router.url_path_for("delete_access_token"), + headers={"Authorization": f"Bearer {original_access}"}, + ) + assert logout.status_code == http.HTTPStatus.OK + + after = await self._refresh(client, r2) + assert after.status_code == http.HTTPStatus.UNAUTHORIZED From b21166c0d9447be0bbcf90bbb78279220ed5f98b Mon Sep 17 00:00:00 2001 From: sricharan varanasi <59170910+sricharan-varanasi@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:33:48 -0400 Subject: [PATCH 5/5] feat: distinct audit outcomes for refresh --- src/apps/authentication/api/auth.py | 5 ++++ src/apps/authentication/tests/test_auth.py | 28 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/apps/authentication/api/auth.py b/src/apps/authentication/api/auth.py index 82b144e3f4a..45e5031f5c1 100644 --- a/src/apps/authentication/api/auth.py +++ b/src/apps/authentication/api/auth.py @@ -665,6 +665,7 @@ async def refresh_access_token( """Refresh access token.""" user_id: uuid.UUID | None = None reuse_family: str | None = None + refresh_outcome = "reused" try: async with atomic(session): try: @@ -713,6 +714,7 @@ async def refresh_access_token( # Within the grace window: hand back the same replacement pair. access_token = replacement.access_token refresh_token = replacement.refresh_token + refresh_outcome = "grace_redeemed" elif await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)): # Old token replayed after its grace window -> treat as theft. Defer the # family revocation to its own committed transaction (raising here would roll @@ -744,6 +746,7 @@ async def refresh_access_token( token_data.jti, Token(access_token=access_token, refresh_token=refresh_token), ) + refresh_outcome = "rotated" else: # Mobile / unknown / legacy: reuse the same refresh token (unchanged behavior). revoked = await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)) @@ -778,6 +781,7 @@ async def refresh_access_token( if reuse_family is not None: # Commit the family revocation in its own transaction, then reject the request. + logger.warning(f"Refresh token reuse detected; revoking family user_id={user_id} family={reuse_family}") async with atomic(session): await TokenRotationService(session).revoke_family(reuse_family, user_id) raise AuthenticationError @@ -791,6 +795,7 @@ async def refresh_access_token( ) raise + logger.info(f"Token refresh succeeded user_id={user_id} outcome={refresh_outcome}") await log( AuditEvent( event_action=EventAction.USER_SESSION_REFRESH, diff --git a/src/apps/authentication/tests/test_auth.py b/src/apps/authentication/tests/test_auth.py index c5c512d86ab..7e2eaa0c610 100644 --- a/src/apps/authentication/tests/test_auth.py +++ b/src/apps/authentication/tests/test_auth.py @@ -774,3 +774,31 @@ async def test_logout_with_stale_access_token_revokes_family( after = await self._refresh(client, r2) assert after.status_code == http.HTTPStatus.UNAUTHORIZED + + async def test_refresh_logs_rotated_outcome(self, client: TestClient, user: User, mocker: MockerFixture): + mocker.patch("apps.authentication.api.auth.log") + logger_mock = mocker.patch("apps.authentication.api.auth.logger") + r1 = (await self._login(client, user))["refreshToken"] + + resp = await self._refresh(client, r1) + assert resp.status_code == http.HTTPStatus.OK + info_messages = " ".join(str(call) for call in logger_mock.info.call_args_list) + assert "outcome=rotated" in info_messages + + async def test_reuse_logs_warning( + self, client: TestClient, user: User, session: AsyncSession, mocker: MockerFixture + ): + mocker.patch("apps.authentication.api.auth.log") + logger_mock = mocker.patch("apps.authentication.api.auth.logger") + r1 = (await self._login(client, user))["refreshToken"] + j1 = self._refresh_payload(r1)["jti"] + await self._refresh(client, r1) + + rotation = TokenRotationService(session) + await rotation.redis_client.delete(rotation._grace_key(j1)) + + reuse = await self._refresh(client, r1) + assert reuse.status_code == http.HTTPStatus.UNAUTHORIZED + assert logger_mock.warning.called + warnings = " ".join(str(call) for call in logger_mock.warning.call_args_list) + assert "reuse detected" in warnings