Skip to content

Commit 8f6cc52

Browse files
feat: short-lived access & refresh tokens for web/admin clients
1 parent 5a122d6 commit 8f6cc52

3 files changed

Lines changed: 135 additions & 5 deletions

File tree

src/apps/authentication/services/security.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ def token_expiration_minutes(
5050
@staticmethod
5151
def create_access_token(data: dict) -> str:
5252
to_encode = data.copy()
53-
expires_delta = timedelta(minutes=settings.authentication.access_token.expiration)
54-
expire = datetime.now(timezone.utc) + expires_delta
53+
minutes = AuthenticationService.token_expiration_minutes(
54+
to_encode.get(JWTClaim.client),
55+
settings.authentication.access_token.expiration,
56+
settings.authentication.access_token.web_admin_expiration,
57+
)
58+
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
5559
to_encode.setdefault(JWTClaim.exp, expire)
5660
to_encode.setdefault(JWTClaim.jti, str(uuid.uuid4()))
5761
encoded_jwt = jwt.encode(
@@ -130,8 +134,12 @@ def decode_mfa_token(token: str) -> str:
130134
@staticmethod
131135
def create_refresh_token(data: dict) -> str:
132136
to_encode = data.copy()
133-
expires_delta = timedelta(minutes=settings.authentication.refresh_token.expiration)
134-
expire = datetime.now(timezone.utc) + expires_delta
137+
minutes = AuthenticationService.token_expiration_minutes(
138+
to_encode.get(JWTClaim.client),
139+
settings.authentication.refresh_token.expiration,
140+
settings.authentication.refresh_token.web_admin_expiration,
141+
)
142+
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
135143
to_encode.setdefault(JWTClaim.exp, expire)
136144
to_encode.setdefault(JWTClaim.jti, str(uuid.uuid4()))
137145
encoded_jwt = jwt.encode(

src/apps/authentication/tests/test_auth.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,89 @@ async def test_login_without_client_claim(self, client: TestClient, user: User,
505505
assert "client" not in access_payload
506506
assert "client" not in refresh_payload
507507
self._assert_lifetimes_unchanged(access_payload, refresh_payload, before, after)
508+
509+
510+
class TestShortLivedWebAdminTokens(BaseTest):
511+
"""Web/admin clients get shorter token lifetimes when configured; others are unchanged."""
512+
513+
get_token_url = auth_router.url_path_for("get_token")
514+
refresh_access_token_url = auth_router.url_path_for("refresh_access_token")
515+
516+
SHORT_ACCESS = 15
517+
SHORT_REFRESH = 120
518+
519+
@pytest.fixture
520+
def short_lifetimes(self, mocker: MockerFixture):
521+
mocker.patch.object(settings.authentication.access_token, "web_admin_expiration", self.SHORT_ACCESS)
522+
mocker.patch.object(settings.authentication.refresh_token, "web_admin_expiration", self.SHORT_REFRESH)
523+
524+
@staticmethod
525+
def _decode(token: str, secret: str) -> dict:
526+
return jwt.decode(token, secret, algorithms=[settings.authentication.algorithm])
527+
528+
@staticmethod
529+
def _assert_expires_in(exp: int, minutes: int, before: datetime.datetime, after: datetime.datetime):
530+
delta = datetime.timedelta(minutes=minutes)
531+
assert int((before + delta).timestamp()) <= exp <= int((after + delta).timestamp()) + 1
532+
533+
@pytest.mark.parametrize("content_source", ("web", "admin"))
534+
async def test_web_admin_get_short_lifetimes(
535+
self, client: TestClient, user: User, short_lifetimes, content_source: str
536+
):
537+
before = datetime.datetime.now(datetime.timezone.utc)
538+
resp = await client.post(
539+
self.get_token_url,
540+
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
541+
headers={"Mindlogger-Content-Source": content_source},
542+
)
543+
after = datetime.datetime.now(datetime.timezone.utc)
544+
assert resp.status_code == http.HTTPStatus.OK
545+
token = resp.json()["result"]["token"]
546+
access = self._decode(token["accessToken"], settings.authentication.access_token.secret_key)
547+
refresh = self._decode(token["refreshToken"], settings.authentication.refresh_token.secret_key)
548+
self._assert_expires_in(access["exp"], self.SHORT_ACCESS, before, after)
549+
self._assert_expires_in(refresh["exp"], self.SHORT_REFRESH, before, after)
550+
551+
@pytest.mark.parametrize(
552+
"headers",
553+
({"Mindlogger-Content-Source": "mobile"}, None),
554+
ids=("mobile", "no-header"),
555+
)
556+
async def test_mobile_and_unknown_keep_default_lifetimes(
557+
self, client: TestClient, user: User, short_lifetimes, headers: dict | None
558+
):
559+
before = datetime.datetime.now(datetime.timezone.utc)
560+
resp = await client.post(
561+
self.get_token_url,
562+
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
563+
headers=headers,
564+
)
565+
after = datetime.datetime.now(datetime.timezone.utc)
566+
assert resp.status_code == http.HTTPStatus.OK
567+
token = resp.json()["result"]["token"]
568+
access = self._decode(token["accessToken"], settings.authentication.access_token.secret_key)
569+
refresh = self._decode(token["refreshToken"], settings.authentication.refresh_token.secret_key)
570+
self._assert_expires_in(access["exp"], settings.authentication.access_token.expiration, before, after)
571+
self._assert_expires_in(refresh["exp"], settings.authentication.refresh_token.expiration, before, after)
572+
573+
async def test_refresh_preserves_short_access_lifetime(
574+
self, client: TestClient, user: User, short_lifetimes, mocker: MockerFixture
575+
):
576+
mocker.patch("apps.authentication.api.auth.log")
577+
login = await client.post(
578+
self.get_token_url,
579+
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
580+
headers={"Mindlogger-Content-Source": "admin"},
581+
)
582+
refresh_token = login.json()["result"]["token"]["refreshToken"]
583+
584+
before = datetime.datetime.now(datetime.timezone.utc)
585+
resp = await client.post(
586+
self.refresh_access_token_url,
587+
data={"refresh_token": refresh_token},
588+
headers={"Mindlogger-Content-Source": "admin"},
589+
)
590+
after = datetime.datetime.now(datetime.timezone.utc)
591+
assert resp.status_code == http.HTTPStatus.OK
592+
access = self._decode(resp.json()["result"]["accessToken"], settings.authentication.access_token.secret_key)
593+
self._assert_expires_in(access["exp"], self.SHORT_ACCESS, before, after)

src/apps/authentication/tests/test_mfa_flow.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import http
44
import json
5-
from datetime import datetime, timezone
5+
from datetime import datetime, timedelta, timezone
66
from unittest.mock import AsyncMock, patch
77

88
import jwt
@@ -309,6 +309,42 @@ async def test_verify_mfa_embeds_client_claim(
309309
assert access_payload["client"] == "admin"
310310
assert refresh_payload["client"] == "admin"
311311

312+
async def test_verify_mfa_applies_short_lifetime_for_web_admin(
313+
self, client: TestClient, user_with_mfa: User, session: AsyncSession, mocker: MockerFixture
314+
):
315+
"""Tokens issued via MFA verification honor the short web/admin lifetime."""
316+
short_access = 15
317+
mocker.patch.object(settings.authentication.access_token, "web_admin_expiration", short_access)
318+
319+
login_response = await client.post(
320+
url=self.get_token_url,
321+
data=dict(email=user_with_mfa.email_encrypted, password=TEST_PASSWORD),
322+
)
323+
mfa_token = login_response.json()["result"]["mfaToken"]
324+
325+
crud = UsersCRUD(session)
326+
fresh_user = await crud.get_by_id(user_with_mfa.id)
327+
assert fresh_user is not None
328+
assert fresh_user.mfa_secret is not None
329+
valid_code = totp_service.get_current_code(totp_service.decrypt_secret(fresh_user.mfa_secret))
330+
331+
mocker.patch("apps.authentication.api.auth.log")
332+
before = datetime.now(timezone.utc)
333+
verify_response = await client.post(
334+
url=self.verify_mfa_url,
335+
data=dict(mfaToken=mfa_token, totpCode=valid_code),
336+
headers={"Mindlogger-Content-Source": "admin"},
337+
)
338+
after = datetime.now(timezone.utc)
339+
assert verify_response.status_code == http.HTTPStatus.OK
340+
access_payload = jwt.decode(
341+
verify_response.json()["result"]["token"]["accessToken"],
342+
settings.authentication.access_token.secret_key,
343+
algorithms=[settings.authentication.algorithm],
344+
)
345+
delta = timedelta(minutes=short_access)
346+
assert int((before + delta).timestamp()) <= access_payload["exp"] <= int((after + delta).timestamp()) + 1
347+
312348
async def test_verify_mfa_with_invalid_code_fails(
313349
self, client: TestClient, user_with_mfa: User, mocker: MockerFixture
314350
):

0 commit comments

Comments
 (0)