Skip to content

Commit b061d7d

Browse files
fix: keep refresh-token revocation exp correct under per-client lifetimes
1 parent 8f6cc52 commit b061d7d

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/apps/authentication/services/security.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,27 @@ def _get_refresh_token_by_access(self, token: InternalToken) -> InternalToken |
176176
if not token.payload.rjti:
177177
return None
178178

179+
# Reconstruct the paired refresh token's exp from the access token's exp. The
180+
# deltas must match the per-client lifetimes the tokens were actually minted with
181+
# (via the `client` claim), otherwise a short web/admin token would under-estimate
182+
# the refresh exp and its blacklist entry could be purged before the refresh token
183+
# truly expires — letting a revoked token be used again.
184+
client = token.payload.client
179185
access_exp = datetime.fromtimestamp(token.payload.exp, timezone.utc)
180-
refresh_expires_delta = timedelta(minutes=settings.authentication.refresh_token.expiration)
181-
access_expires_delta = timedelta(minutes=settings.authentication.access_token.expiration)
186+
refresh_expires_delta = timedelta(
187+
minutes=self.token_expiration_minutes(
188+
client,
189+
settings.authentication.refresh_token.expiration,
190+
settings.authentication.refresh_token.web_admin_expiration,
191+
)
192+
)
193+
access_expires_delta = timedelta(
194+
minutes=self.token_expiration_minutes(
195+
client,
196+
settings.authentication.access_token.expiration,
197+
settings.authentication.access_token.web_admin_expiration,
198+
)
199+
)
182200
expire = access_exp - access_expires_delta + refresh_expires_delta
183201
refresh_token = InternalToken(
184202
payload=TokenPayload(

src/apps/authentication/tests/test_auth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,28 @@ async def test_refresh_preserves_short_access_lifetime(
591591
assert resp.status_code == http.HTTPStatus.OK
592592
access = self._decode(resp.json()["result"]["accessToken"], settings.authentication.access_token.secret_key)
593593
self._assert_expires_in(access["exp"], self.SHORT_ACCESS, before, after)
594+
595+
async def test_logout_revokes_paired_refresh_token_under_short_lifetimes(
596+
self, client: TestClient, user: User, short_lifetimes, mocker: MockerFixture
597+
):
598+
mocker.patch("apps.authentication.api.auth.log")
599+
login = await client.post(
600+
self.get_token_url,
601+
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
602+
headers={"Mindlogger-Content-Source": "admin"},
603+
)
604+
token = login.json()["result"]["token"]
605+
606+
logout = await client.post(
607+
auth_router.url_path_for("delete_access_token"),
608+
headers={"Authorization": f"Bearer {token['accessToken']}"},
609+
)
610+
assert logout.status_code == http.HTTPStatus.OK
611+
612+
# Revoking the access token must also revoke its paired refresh token.
613+
resp = await client.post(
614+
self.refresh_access_token_url,
615+
data={"refresh_token": token["refreshToken"]},
616+
)
617+
assert resp.status_code == http.HTTPStatus.UNAUTHORIZED
618+
assert resp.json()["result"][0]["message"] == AuthenticationError.message

src/apps/authentication/tests/unit/test_auth_service.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uuid
2+
from datetime import datetime, timedelta, timezone
23

34
import pytest
45
from pytest import FixtureRequest
@@ -7,13 +8,15 @@
78

89
from apps.authentication.domain.login import UserLoginRequest
910
from apps.authentication.domain.token import InternalToken
10-
from apps.authentication.domain.token.internal import TokenPurpose
11+
from apps.authentication.domain.token.internal import TokenPayload, TokenPurpose
1112
from apps.authentication.errors import BadCredentials, InvalidCredentials
1213
from apps.authentication.services import AuthenticationService
1314
from apps.authentication.services.core import TokensService
1415
from apps.users.cruds.user import UsersCRUD
1516
from apps.users.domain import User
1617
from apps.users.errors import UserIsDeletedError, UserNotFound
18+
from config import settings
19+
from infrastructure.http.domain import MindloggerContentSource
1720

1821
TEST_PASSWORD = "Test12345!"
1922
RJTI = str(uuid.uuid4())
@@ -154,3 +157,47 @@ async def test_token_revoke__ttl_less_than_one(
154157
await token_blacklist_service.revoke(access_token_internal, TokenPurpose.ACCESS)
155158
is_revoked = await token_blacklist_service.is_revoked(access_token_internal)
156159
assert not is_revoked
160+
161+
162+
class TestRefreshTokenExpDerivation:
163+
"""`_get_refresh_token_by_access` must derive the paired refresh exp using the
164+
same per-client lifetimes the tokens were minted with (keyed on the `client` claim)."""
165+
166+
@staticmethod
167+
def _access_token(access_exp: datetime, client: MindloggerContentSource | None) -> InternalToken:
168+
return InternalToken(
169+
payload=TokenPayload(
170+
sub=uuid.uuid4(),
171+
exp=int(access_exp.timestamp()),
172+
jti=str(uuid.uuid4()),
173+
rjti=RJTI,
174+
client=client,
175+
)
176+
)
177+
178+
def test_web_admin_uses_short_deltas(self, auth_service: AuthenticationService, mocker: MockerFixture):
179+
mocker.patch.object(settings.authentication.access_token, "web_admin_expiration", 15)
180+
mocker.patch.object(settings.authentication.refresh_token, "web_admin_expiration", 120)
181+
access_exp = datetime.now(timezone.utc) + timedelta(minutes=15)
182+
derived = auth_service._get_refresh_token_by_access(
183+
self._access_token(access_exp, MindloggerContentSource.admin)
184+
)
185+
assert derived is not None
186+
expected = int((access_exp - timedelta(minutes=15) + timedelta(minutes=120)).timestamp())
187+
assert derived.payload.exp == expected
188+
assert derived.payload.jti == RJTI
189+
190+
def test_legacy_client_none_uses_defaults(self, auth_service: AuthenticationService, mocker: MockerFixture):
191+
mocker.patch.object(settings.authentication.access_token, "web_admin_expiration", 15)
192+
mocker.patch.object(settings.authentication.refresh_token, "web_admin_expiration", 120)
193+
access_exp = datetime.now(timezone.utc) + timedelta(minutes=settings.authentication.access_token.expiration)
194+
derived = auth_service._get_refresh_token_by_access(self._access_token(access_exp, None))
195+
assert derived is not None
196+
expected = int(
197+
(
198+
access_exp
199+
- timedelta(minutes=settings.authentication.access_token.expiration)
200+
+ timedelta(minutes=settings.authentication.refresh_token.expiration)
201+
).timestamp()
202+
)
203+
assert derived.payload.exp == expected

0 commit comments

Comments
 (0)