From 19ef55e24cc0798743b0e60f212f6065e024d166 Mon Sep 17 00:00:00 2001 From: henfircreo Date: Mon, 5 Oct 2026 20:50:03 +0200 Subject: [PATCH] fix(auth): answer a refresh burst with one successor token The reuse grace window rotated the session again on every grace refresh, so in a burst of three refreshes on one cookie the third matched no previous hash, got a 401, and its response cleared the cookie the other two had just set. The session stayed active and the browser lost it. - The successor refresh token is now derived from the spent one (jti is an HMAC of its hash, exp is the row's expiry), so a grace refresh is answered with the token the row already holds instead of rotating again. Every request in the burst gets the same token. - A 401 from the refresh logs the console out, so AuthGuard sends the person to sign in instead of leaving a page where every request fails. - The chat socket's /auth/me recovery tolerates an empty answer. --- CHANGELOG.md | 13 +++ backend/app/api/routes/v1/auth.py | 52 +++++++---- backend/app/core/security.py | 14 ++- backend/app/services/session.py | 71 +++++++++++++-- .../integration/test_session_revocation.py | 91 +++++++++++++------ backend/tests/test_session_refresh_grace.py | 72 ++++++++++++++- docs/configuration.de.md | 4 +- docs/configuration.es.md | 4 +- docs/configuration.md | 2 +- docs/configuration.pl.md | 4 +- docs/security.de.md | 4 +- docs/security.es.md | 4 +- docs/security.md | 2 +- docs/security.pl.md | 4 +- frontend/src/hooks/use-chat.test.tsx | 12 +++ frontend/src/hooks/use-chat.ts | 7 +- frontend/src/lib/api-client.test.ts | 39 ++++++++ frontend/src/lib/api-client.ts | 6 ++ 18 files changed, 336 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1897f28ab..288da7e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ Two things are versioned separately from this file and worth knowing about: ## [Unreleased] +### Fixed + +- A burst of refreshes on one cookie no longer signs the person out. The reuse + grace window rotated the session again on every grace refresh, so the third + request of a burst matched nothing, got a 401, and its response cleared the + cookie the other two had just set. Within `REFRESH_REUSE_GRACE_SECONDS` a spent + token is now answered with the successor the session already holds - the same + token for every request in the burst - so the cookie converges whichever + response lands last. +- A session that has ended sends the person to sign in. A refused refresh left the + console signed in, with every request answering 401 and the chat socket + reconnecting on a dead token, until a full reload. + ## [0.0.516] - 2026-10-01 ### Changed diff --git a/backend/app/api/routes/v1/auth.py b/backend/app/api/routes/v1/auth.py index dfd5483ad..17a2fd0ce 100644 --- a/backend/app/api/routes/v1/auth.py +++ b/backend/app/api/routes/v1/auth.py @@ -36,6 +36,7 @@ from app.schemas.token import MagicLinkToken, RefreshTokenRequest, Token from app.schemas.user import MeRead, UserCreate, UserRead from app.services.email.service import get_email_service +from app.services.session import refresh_expiry, successor_refresh_token logger = logging.getLogger(__name__) @@ -103,9 +104,11 @@ async def refresh_token( """Exchange a refresh token for a new access token.""" await enforce_auth_limit(request, surface="auth_refresh") - session = await session_service.validate_refresh_token( - body.refresh_token - ) or await session_service.claim_refresh_grace(body.refresh_token) + session = await session_service.validate_refresh_token(body.refresh_token) + within_grace = False + if not session: + session = await session_service.claim_refresh_grace(body.refresh_token) + within_grace = session is not None if not session: # Before the refusal, and only on the path where one is already certain: # a token that validated no live session may be a typo, an expired one, a @@ -133,20 +136,35 @@ async def refresh_token( if payload is None or payload.get("cv", 0) != user.credential_version: raise AuthenticationError(message="Invalid or expired refresh token") - new_refresh_token = create_refresh_token( - subject=str(user.id), credential_version=user.credential_version - ) - - # Rotate the refresh token in place, keeping the row's id: the new access - # token names the same `sid`, so a live socket or a second tab holding the - # old access token is not cut off by a routine refresh (#1437, #1501). The - # old refresh token's hash is replaced, which is what makes it unusable. - await session_service.rotate_session( - session, - new_refresh_token, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("User-Agent"), - ) + if within_grace: + # A token this row spent seconds ago - a lost response, or one request of + # a burst on the same cookie. Answered with the successor the row already + # holds, not a new rotation, so every request in the burst gets the same + # token and the cookie jar converges on it. + new_refresh_token = session_service.reissue_within_grace( + session, body.refresh_token, credential_version=user.credential_version + ) + else: + # Rotate the refresh token in place, keeping the row's id: the new access + # token names the same `sid`, so a live socket or a second tab holding the + # old access token is not cut off by a routine refresh (#1437, #1501). The + # old refresh token's hash is replaced, which is what makes it unusable. + # The successor is derived from the spent token, so a grace-window reissue + # can rebuild it byte for byte. + expires_at = refresh_expiry() + new_refresh_token = successor_refresh_token( + body.refresh_token, + subject=str(user.id), + credential_version=user.credential_version, + expires_at=expires_at, + ) + await session_service.rotate_session( + session, + new_refresh_token, + expires_at=expires_at, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("User-Agent"), + ) access_token = create_access_token(subject=str(user.id), sid=str(session.id)) return Token(access_token=access_token, refresh_token=new_refresh_token) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index cba330cea..6204d1f53 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -59,6 +59,8 @@ def create_refresh_token( expires_delta: timedelta | None = None, *, credential_version: int = 0, + jti: str | None = None, + expires_at: datetime | None = None, ) -> str: """Create a JWT refresh token. @@ -69,11 +71,19 @@ def create_refresh_token( next refresh's `scalar_one_or_none` lookup raises rather than resolving (#1501 review). + A rotation passes `jti` and `expires_at` instead, derived from the token it + spends, so the same spent token always mints the same successor - which is + what lets `SessionService.reissue_within_grace` hand every request in a burst + the one token the row now holds. The payload has no `iat`, so equal inputs + encode to equal bytes. + Carries the account's `credential_version` as `cv`: a password change bumps the user's version, and the refresh path refuses a token whose `cv` is behind it, so a token minted before the change cannot be rotated past it (#1517). """ - if expires_delta: + if expires_at is not None: + expire = expires_at + elif expires_delta: expire = datetime.now(UTC) + expires_delta else: expire = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) @@ -82,7 +92,7 @@ def create_refresh_token( "exp": expire, "sub": str(subject), "type": "refresh", - "jti": uuid4().hex, + "jti": jti or uuid4().hex, "cv": credential_version, } return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) diff --git a/backend/app/services/session.py b/backend/app/services/session.py index e6335c1f8..5882f9ee6 100644 --- a/backend/app/services/session.py +++ b/backend/app/services/session.py @@ -1,7 +1,9 @@ """Session service (PostgreSQL async).""" import hashlib +import hmac import logging +import secrets from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID @@ -11,7 +13,7 @@ from app.core.audit import record_audit from app.core.config import settings from app.core.exceptions import AuthenticationError, NotFoundError -from app.core.security import read_uuid_claim +from app.core.security import create_refresh_token, read_uuid_claim from app.db.models.session import Session from app.repositories import session_repo from app.schemas.session import SessionListResponse, SessionRead @@ -24,6 +26,29 @@ def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() +def successor_refresh_token( + spent_token: str, *, subject: str, credential_version: int, expires_at: datetime +) -> str: + """The refresh token a rotation of `spent_token` issues - the same bytes every time. + + The `jti` is an HMAC of the spent token's hash and `exp` is the row's own + expiry, so the successor can be rebuilt later from what the row stores, without + the row ever holding a credential. Unique along a chain because every spent + token is. + """ + jti = hmac.new( + settings.SECRET_KEY.encode(), hash_token(spent_token).encode(), hashlib.sha256 + ).hexdigest()[:32] + return create_refresh_token( + subject=subject, credential_version=credential_version, jti=jti, expires_at=expires_at + ) + + +def refresh_expiry() -> datetime: + """When a refresh token minted now stops refreshing.""" + return datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + + def _parse_user_agent(user_agent: str | None) -> tuple[str | None, str | None]: if not user_agent: return None, None @@ -93,6 +118,7 @@ async def rotate_session( session: Session, new_refresh_token: str, *, + expires_at: datetime | None = None, ip_address: str | None = None, user_agent: str | None = None, ) -> Session: @@ -108,8 +134,13 @@ async def rotate_session( recreating the row used to: the sessions list is what a person revokes an unfamiliar device from, so it has to show where the credential is being used now, not only where the login began (#1501 review). + + `expires_at` must be the `exp` the new token was minted with when it came + from `successor_refresh_token`, or a reissue within the grace window would + rebuild a different token than the row holds. """ - expires_at = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + if expires_at is None: + expires_at = refresh_expiry() device_name, device_type = _parse_user_agent(user_agent) return await session_repo.rotate( self.db, @@ -207,8 +238,8 @@ async def claim_refresh_grace(self, refresh_token: str) -> Session | None: closed mid-request - or when a second tab refreshed on the same cookie a moment after the first. Treating that as a replay ended the session and signed the person out several times a day. So inside the window the spent - token refreshes once more, and the route rotates the row again; the next - presentation of it finds a different previous hash and is refused. + token is answered again - by `reissue_within_grace`, with the successor the + row already holds, never with a new rotation. The window is the cost: a stolen refresh token replayed within seconds of the victim's own refresh is accepted rather than detected. Outside it, @@ -221,8 +252,8 @@ async def claim_refresh_grace(self, refresh_token: str) -> Session | None: grace = settings.REFRESH_REUSE_GRACE_SECONDS if grace <= 0: return None - # Locked like the ordinary lookup, so two grace refreshes on the same - # spent token serialize: the second finds the previous hash moved on. + # Locked like the ordinary lookup, so a grace answer never interleaves + # with a rotation of the same row. session = await session_repo.get_by_previous_refresh_token_hash( self.db, hash_token(refresh_token), for_update=True ) @@ -241,6 +272,34 @@ async def claim_refresh_grace(self, refresh_token: str) -> Session | None: logger.info("refresh_token_grace_reuse", extra={"session_id": str(session.id)}) return session + def reissue_within_grace( + self, session: Session, spent_token: str, *, credential_version: int + ) -> str: + """The successor a grace-window refresh answers with: the token the row holds now. + + Rotating again here, as the first version of the window did, moved the + previous hash on - so in a burst of three refreshes on one cookie the third + matched nothing, got a 401, and its response cleared the cookie the other + two had just set. The session stayed active and the browser lost it. + Answering every request in the burst with the *same* token lets the cookie + jar converge whichever response lands last, and a client whose response + was lost gets back exactly the token it missed. + + Raises: + AuthenticationError: The row has rotated past that successor since - a + later refresh on the new token - or the account's credential + version has moved, so the rebuilt token is not the one it holds. + """ + successor = successor_refresh_token( + spent_token, + subject=str(session.user_id), + credential_version=credential_version, + expires_at=session.expires_at, + ) + if not secrets.compare_digest(hash_token(successor), session.refresh_token_hash): + raise AuthenticationError(message="Invalid or expired refresh token") + return successor + async def detect_refresh_reuse( self, refresh_token: str, *, ip_address: str | None = None ) -> Session | None: diff --git a/backend/tests/integration/test_session_revocation.py b/backend/tests/integration/test_session_revocation.py index ba16e5e81..4acb40945 100644 --- a/backend/tests/integration/test_session_revocation.py +++ b/backend/tests/integration/test_session_revocation.py @@ -318,37 +318,36 @@ async def test_the_refresh_route_ends_the_chain_and_still_answers_401( class TestTheReuseGraceWindow: """A spent refresh token presented seconds after its rotation is a lost - response or a second tab, not a thief. Ending the session for it signed people - out several times a day, so inside `REFRESH_REUSE_GRACE_SECONDS` it refreshes - once more through the real route.""" + response or one request of a burst on the same cookie, not a thief. Ending the + session for it signed people out several times a day, so inside + `REFRESH_REUSE_GRACE_SECONDS` it is answered again, through the real route, + with the successor the row already holds.""" - async def _rotated(self, db, email: str) -> tuple[UUID, UUID, str, str]: + async def _rotated(self, db, api: AsyncClient, email: str) -> tuple[UUID, str, str]: + """A session whose first token has been spent by a real refresh.""" user = await _user(db, email) spent = create_refresh_token(subject=str(user.id), credential_version=0) - current = create_refresh_token(subject=str(user.id), credential_version=0) session = await session_repo.create( db, user_id=user.id, refresh_token_hash=hash_token(spent), expires_at=_in_a_day() ) - await SessionService(db).rotate_session(session, current) - return user.id, session.id, spent, current - - async def test_a_lost_response_does_not_sign_the_person_out(self, db, api: AsyncClient): - user_id, session_id, spent, _ = await self._rotated(db, "grace-http@example.com") + session_id = session.id + first = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + assert first.status_code == 200 db.expire_all() + return session_id, spent, first.json()["refresh_token"] - response = await api.post( - f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent} - ) + async def test_a_lost_response_gets_back_the_token_it_missed(self, db, api: AsyncClient): + session_id, spent, successor = await self._rotated(db, api, "grace-lost@example.com") - assert response.status_code == 200 + retry = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + + assert retry.status_code == 200 + assert retry.json()["refresh_token"] == successor db.expire_all() reread = await session_repo.get_by_id(db, session_id) assert reread is not None assert reread.is_active is True - # The token it answered with is the live one now. - service = SessionService(db) - assert await service.validate_refresh_token(response.json()["refresh_token"]) is not None - assert await session_repo.count_user_sessions(db, user_id, open_only=True) == 1 + assert reread.refresh_token_hash == hash_token(successor) recorded = ( await db.execute( select(func.count()) @@ -358,26 +357,62 @@ async def test_a_lost_response_does_not_sign_the_person_out(self, db, api: Async ).scalar_one() assert recorded == 0 - async def test_the_spent_token_refreshes_only_once(self, db, api: AsyncClient): - """The grace rotation moves the previous hash on, so the same spent token - a second time matches nothing: a plain 401, and nothing to revoke.""" - _, session_id, spent, _ = await self._rotated(db, "grace-once@example.com") + async def test_every_request_in_a_burst_gets_the_same_token(self, db, api: AsyncClient): + """The first version of the window rotated again on each grace refresh, so + the third request of a burst matched nothing, got a 401, and its response + cleared the cookie the others had just set. The session stayed active and + the browser lost it.""" + _, spent, successor = await self._rotated(db, api, "grace-burst@example.com") + + answers = [ + await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + for _ in range(3) + ] + + assert [answer.status_code for answer in answers] == [200, 200, 200] + assert {answer.json()["refresh_token"] for answer in answers} == {successor} db.expire_all() + assert await SessionService(db).validate_refresh_token(successor) is not None - first = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) - again = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + async def test_once_the_successor_has_rotated_the_spent_token_is_refused( + self, db, api: AsyncClient + ): + """A later refresh on the new token moves the chain on; the old one no + longer names the token the row holds. A plain 401, and nothing revoked.""" + session_id, spent, successor = await self._rotated(db, api, "grace-moved-on@example.com") + onward = await api.post( + f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": successor} + ) + assert onward.status_code == 200 + db.expire_all() - assert first.status_code == 200 - assert again.status_code == 401 + late = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + + assert late.status_code == 401 db.expire_all() reread = await session_repo.get_by_id(db, session_id) assert reread is not None assert reread.is_active is True - async def test_rotation_records_when_it_happened(self, db): - _, session_id, _, _ = await self._rotated(db, "grace-stamp@example.com") + async def test_a_password_change_closes_the_window(self, db, api: AsyncClient): + """The spent token carries the old credential version, so the route + refuses it before any successor is rebuilt (#1517).""" + session_id, spent, _ = await self._rotated(db, api, "grace-password@example.com") + reread = await session_repo.get_by_id(db, session_id) + assert reread is not None + user = await user_repo.get_by_id(db, reread.user_id) + assert user is not None + user.credential_version = 1 + await db.flush() db.expire_all() + late = await api.post(f"{settings.API_V1_STR}/auth/refresh", json={"refresh_token": spent}) + + assert late.status_code == 401 + + async def test_rotation_records_when_it_happened(self, db, api: AsyncClient): + session_id, _, _ = await self._rotated(db, api, "grace-stamp@example.com") + reread = await session_repo.get_by_id(db, session_id) assert reread is not None assert reread.rotated_at is not None diff --git a/backend/tests/test_session_refresh_grace.py b/backend/tests/test_session_refresh_grace.py index 84b2fd5e7..9f8594b4e 100644 --- a/backend/tests/test_session_refresh_grace.py +++ b/backend/tests/test_session_refresh_grace.py @@ -15,7 +15,9 @@ import pytest from app.core.config import settings -from app.services.session import SessionService +from app.core.exceptions import AuthenticationError +from app.core.security import verify_token +from app.services.session import SessionService, hash_token, successor_refresh_token pytestmark = [pytest.mark.anyio, pytest.mark.security] @@ -82,3 +84,71 @@ async def test_a_zero_window_turns_the_grace_off(self, monkeypatch: pytest.Monke with patch(_REPO, AsyncMock(return_value=_row())) as lookup: assert await SessionService(MagicMock()).claim_refresh_grace("spent") is None lookup.assert_not_awaited() + + +class TestTheSuccessorToken: + """Every request in a grace burst must be answered with the one token the row + holds, so the successor has to be rebuildable from what the row stores.""" + + def test_the_same_spent_token_mints_the_same_successor(self) -> None: + expires_at = datetime.now(UTC) + timedelta(days=7) + first = successor_refresh_token( + "spent", subject="u-1", credential_version=0, expires_at=expires_at + ) + again = successor_refresh_token( + "spent", subject="u-1", credential_version=0, expires_at=expires_at + ) + assert first == again + + def test_different_spent_tokens_mint_different_successors(self) -> None: + expires_at = datetime.now(UTC) + timedelta(days=7) + first = successor_refresh_token( + "a", subject="u-1", credential_version=0, expires_at=expires_at + ) + second = successor_refresh_token( + "b", subject="u-1", credential_version=0, expires_at=expires_at + ) + assert first != second + + def test_the_successor_carries_the_expiry_and_version_it_was_given(self) -> None: + expires_at = datetime.now(UTC) + timedelta(days=7) + token = successor_refresh_token( + "s", subject="u-1", credential_version=3, expires_at=expires_at + ) + payload = verify_token(token) + assert payload is not None + assert payload["cv"] == 3 + assert payload["exp"] == int(expires_at.timestamp()) + + +class TestReissueWithinGrace: + def _row(self, *, holds: str, expires_at: datetime) -> MagicMock: + return MagicMock(user_id="u-1", expires_at=expires_at, refresh_token_hash=hash_token(holds)) + + def test_answers_with_the_token_the_row_holds(self) -> None: + expires_at = datetime.now(UTC) + timedelta(days=7) + successor = successor_refresh_token( + "spent", subject="u-1", credential_version=0, expires_at=expires_at + ) + row = self._row(holds=successor, expires_at=expires_at) + + assert ( + SessionService(MagicMock()).reissue_within_grace(row, "spent", credential_version=0) + == successor + ) + + def test_refuses_once_the_row_has_rotated_past_it(self) -> None: + row = self._row(holds="a-later-token", expires_at=datetime.now(UTC) + timedelta(days=7)) + + with pytest.raises(AuthenticationError): + SessionService(MagicMock()).reissue_within_grace(row, "spent", credential_version=0) + + def test_refuses_after_the_credential_version_moved(self) -> None: + expires_at = datetime.now(UTC) + timedelta(days=7) + successor = successor_refresh_token( + "spent", subject="u-1", credential_version=0, expires_at=expires_at + ) + row = self._row(holds=successor, expires_at=expires_at) + + with pytest.raises(AuthenticationError): + SessionService(MagicMock()).reissue_within_grace(row, "spent", credential_version=1) diff --git a/docs/configuration.de.md b/docs/configuration.de.md index 44a1298bd..880642889 100644 --- a/docs/configuration.de.md +++ b/docs/configuration.de.md @@ -1,5 +1,5 @@ --- -source_sha: "0c537f0b37b2" +source_sha: "130cbafc773f" --- # Konfiguration { #configuration } @@ -107,7 +107,7 @@ terminiert; die Compose-Dateien starten uvicorn ohne eine eigene solche Grenze. | `SECRET_KEY` | (insecure default) | Signierschlüssel für JWT. **Muss** in der Produktion geändert werden. Erzeugen mit: `openssl rand -hex 32` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Lebensdauer des Access Tokens | | `REFRESH_TOKEN_EXPIRE_MINUTES` | `10080` | Lebensdauer des Refresh Tokens (7 Tage) | -| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Wie lange nach einer Rotation der verbrauchte Refresh Token noch einmal refreshen darf, damit eine verlorene Antwort oder ein zweiter Tab die Session nicht beendet; `0` schaltet es ab | +| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Wie lange nach einer Rotation der verbrauchte Refresh Token noch beantwortet wird, jedes Mal mit demselben Nachfolger, damit eine verlorene Antwort oder eine Serie von Refreshes die Session nicht beendet; `0` schaltet es ab | | `ALGORITHM` | `HS256` | Signaturalgorithmus für JWT | Prüfung für die Produktion: `SECRET_KEY` muss mindestens 32 Zeichen lang sein und diff --git a/docs/configuration.es.md b/docs/configuration.es.md index f349c4027..ed7e032c4 100644 --- a/docs/configuration.es.md +++ b/docs/configuration.es.md @@ -1,5 +1,5 @@ --- -source_sha: "0c537f0b37b2" +source_sha: "130cbafc773f" --- # Configuración { #configuration } @@ -107,7 +107,7 @@ sin un límite propio. | `SECRET_KEY` | (insecure default) | Clave de firma de los JWT. **Tiene que** cambiarse en producción. Genérala con: `openssl rand -hex 32` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Vida del access token | | `REFRESH_TOKEN_EXPIRE_MINUTES` | `10080` | Vida del refresh token (7 días) | -| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Cuánto tiempo tras una rotación el refresh token gastado puede refrescar una vez más, para que una respuesta perdida o una segunda pestaña no terminen la sesión; `0` lo desactiva | +| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Cuánto tiempo tras una rotación el refresh token gastado sigue recibiendo respuesta, siempre con el mismo sucesor, para que una respuesta perdida o una ráfaga de refrescos no terminen la sesión; `0` lo desactiva | | `ALGORITHM` | `HS256` | Algoritmo de firma de los JWT | Validación en producción: `SECRET_KEY` tiene que tener al menos 32 caracteres y no diff --git a/docs/configuration.md b/docs/configuration.md index ad20c06f8..1e0a022b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,7 +100,7 @@ with no such limit of its own. | `SECRET_KEY` | (insecure default) | JWT signing key. **Must** be changed in production. Generate with: `openssl rand -hex 32` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token lifetime | | `REFRESH_TOKEN_EXPIRE_MINUTES` | `10080` | Refresh token lifetime (7 days) | -| `REFRESH_REUSE_GRACE_SECONDS` | `60` | How long after a rotation the spent refresh token may still refresh once, so a lost response or a second tab does not end the session; `0` turns it off | +| `REFRESH_REUSE_GRACE_SECONDS` | `60` | How long after a rotation the spent refresh token is still answered, with the same successor token each time, so a lost response or a burst of refreshes does not end the session; `0` turns it off | | `ALGORITHM` | `HS256` | JWT signing algorithm | Production validation: `SECRET_KEY` must be at least 32 characters and cannot diff --git a/docs/configuration.pl.md b/docs/configuration.pl.md index 780c6f31d..49a27d803 100644 --- a/docs/configuration.pl.md +++ b/docs/configuration.pl.md @@ -1,5 +1,5 @@ --- -source_sha: "0c537f0b37b2" +source_sha: "130cbafc773f" --- # Konfiguracja { #configuration } @@ -105,7 +105,7 @@ limitu. | `SECRET_KEY` | (insecure default) | Klucz podpisujący JWT. **Musi** zostać zmieniony w produkcji. Wygeneruj: `openssl rand -hex 32` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Czas życia access tokena | | `REFRESH_TOKEN_EXPIRE_MINUTES` | `10080` | Czas życia refresh tokena (7 dni) | -| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Jak długo po rotacji zużyty refresh token może jeszcze raz odświeżyć, żeby utracona odpowiedź albo druga karta nie kończyły sesji; `0` to wyłącza | +| `REFRESH_REUSE_GRACE_SECONDS` | `60` | Jak długo po rotacji zużyty refresh token nadal dostaje odpowiedź - za każdym razem z tym samym następcą - żeby utracona odpowiedź albo seria odświeżeń nie kończyły sesji; `0` to wyłącza | | `ALGORITHM` | `HS256` | Algorytm podpisu JWT | Walidacja produkcyjna: `SECRET_KEY` musi mieć co najmniej 32 znaki i nie może diff --git a/docs/security.de.md b/docs/security.de.md index b81e7781f..844ad53cf 100644 --- a/docs/security.de.md +++ b/docs/security.de.md @@ -1,5 +1,5 @@ --- -source_sha: "832b2ea60503" +source_sha: "3b62656f6e5b" --- # Sicherheit { #security } @@ -206,7 +206,7 @@ SOC 2 CC6–CC8. | SAML, SCIM, eine erneute Prüfung des Verzeichnisses zwischen Anmeldungen | **Noch nicht** — die offene Session eines im Verzeichnis deaktivierten Kontos hält, bis ihr Refresh Token abläuft, sofern ein Administrator das Konto nicht deaktiviert. Siehe [Was dies noch nicht tut](directory.md#what-this-does-not-do-yet) | — | | Rate-Limiting beim Login | `enforce_auth_limit` (`app/api/deps.py`) | `test_auth_rate_limit.py` | | Eine geänderte E-Mail-Adresse wird nachgewiesen, bevor Post ihr folgt | `PATCH /users/me` legt die Adresse in `users.pending_email` ab und schickt einen einmaligen Link mit einer Stunde Gültigkeit dorthin; bis der Link zurückkommt, erhält das Konto alles weiter unter seiner bisherigen Adresse, und diese wird darüber informiert, dass eine Änderung verlangt wurde. Der Link trägt die Credential-Version des Kontos, sodass ein Ändern oder Zurücksetzen des Passworts — wozu genau dieser Hinweis auffordert — ihn entwertet, und eine von einer Administratorin reparierte Adresse löscht die Vormerkung. Eine erneute Anfrage nach der bereits vorgemerkten Adresse verschickt nichts, und die Zahl unterschiedlicher Adressen pro Konto und Stunde ist begrenzt. Anfrage und Bestätigung werden beide auditiert (`app/services/user.py`, `POST /auth/email-change/confirm`) | `test_email_change.py` | -| Ein wiedergespielter Refresh-Token beendet seine Kette und wird protokolliert | Die Rotation behält den ersetzten Hash; ein Refresh, der dazu passt, ist der Reuse-Fall aus RFC 6819 §5.2.2.3 und schließt diese Session mit einem Audit-Eintrag (`SessionService.detect_refresh_reuse`). Ein Token, der innerhalb von `REFRESH_REUSE_GRACE_SECONDS` (standardmäßig 60) nach seiner Rotation vorgelegt wird, ist eine verlorene Antwort oder ein zweiter Tab und kein Replay, und er refresht stattdessen noch einmal (`SessionService.claim_refresh_grace`); dieses Fenster ist der bewusst in Kauf genommene Preis | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | +| Ein wiedergespielter Refresh-Token beendet seine Kette und wird protokolliert | Die Rotation behält den ersetzten Hash; ein Refresh, der dazu passt, ist der Reuse-Fall aus RFC 6819 §5.2.2.3 und schließt diese Session mit einem Audit-Eintrag (`SessionService.detect_refresh_reuse`). Ein Token, der innerhalb von `REFRESH_REUSE_GRACE_SECONDS` (standardmäßig 60) nach seiner Rotation vorgelegt wird, ist eine verlorene Antwort oder eine Anfrage aus einer Serie mit demselben Cookie und kein Replay, und er bekommt den Nachfolger, den die Zeile bereits hält, aus dem verbrauchten Token neu gebildet, sodass jede Anfrage der Serie denselben Token erhält (`SessionService.claim_refresh_grace`, `reissue_within_grace`); dieses Fenster ist der bewusst in Kauf genommene Preis | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | ### Audit-Kontrollen · HIPAA §164.312(b) · SOC 2 CC7 { #audit-controls-hipaa-164312b-soc-2-cc7 } diff --git a/docs/security.es.md b/docs/security.es.md index 9751bdaab..c0c2ef74e 100644 --- a/docs/security.es.md +++ b/docs/security.es.md @@ -1,5 +1,5 @@ --- -source_sha: "832b2ea60503" +source_sha: "3b62656f6e5b" --- # Seguridad { #security } @@ -200,7 +200,7 @@ Encuadrado frente a las salvaguardas técnicas de HIPAA §164.312 y SOC 2 CC6– | SAML, SCIM, una nueva comprobación del directorio entre inicios de sesión | **Todavía no** — la sesión abierta de una cuenta desactivada en el directorio dura hasta que caduca su refresh token, salvo que un administrador la desactive. Véase [Lo que esto todavía no hace](directory.md#what-this-does-not-do-yet) | — | | Límite de peticiones en el login | `enforce_auth_limit` (`app/api/deps.py`) | `test_auth_rate_limit.py` | | Una dirección de correo cambiada se demuestra antes de que el correo la siga | `PATCH /users/me` deja la dirección en `users.pending_email` y le envía un enlace de un solo uso válido una hora; hasta que ese enlace vuelve, la cuenta sigue recibiéndolo todo en su dirección actual, y esa dirección recibe aviso de que se ha pedido un cambio. El enlace lleva la versión de credenciales de la cuenta, así que cambiar o restablecer la contraseña — lo que ese aviso pide hacer — lo revoca, y que quien administra repare la dirección borra el cambio pendiente. Volver a pedir la dirección ya pendiente no envía nada, y el número de direcciones distintas por cuenta y hora está limitado. Tanto la petición como la confirmación quedan auditadas (`app/services/user.py`, `POST /auth/email-change/confirm`) | `test_email_change.py` | -| Un refresh token reproducido termina su cadena y queda registrado | La rotación conserva el hash que sustituyó; un refresh que coincida con él es el caso de reutilización de la RFC 6819 §5.2.2.3 y cierra esa sesión con una entrada de auditoría (`SessionService.detect_refresh_reuse`). Un token presentado dentro de `REFRESH_REUSE_GRACE_SECONDS` (60 por defecto) desde su rotación es una respuesta perdida o una segunda pestaña, no una reproducción, y refresca una vez más (`SessionService.claim_refresh_grace`); esa ventana es el coste aceptado | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | +| Un refresh token reproducido termina su cadena y queda registrado | La rotación conserva el hash que sustituyó; un refresh que coincida con él es el caso de reutilización de la RFC 6819 §5.2.2.3 y cierra esa sesión con una entrada de auditoría (`SessionService.detect_refresh_reuse`). Un token presentado dentro de `REFRESH_REUSE_GRACE_SECONDS` (60 por defecto) desde su rotación es una respuesta perdida o una petición de una ráfaga con la misma cookie, no una reproducción, y recibe el sucesor que la fila ya guarda, reconstruido a partir del token gastado, de modo que cada petición de la ráfaga obtiene el mismo token (`SessionService.claim_refresh_grace`, `reissue_within_grace`); esa ventana es el coste aceptado | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | ### Controles de auditoría · HIPAA §164.312(b) · SOC 2 CC7 { #audit-controls-hipaa-164312b-soc-2-cc7 } diff --git a/docs/security.md b/docs/security.md index 954d78c4d..4035af3ab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -187,7 +187,7 @@ true. Framed against HIPAA §164.312 technical safeguards and SOC 2 CC6–CC8. | Directory groups decide memberships | Directory group mappings, applied at every LDAP, Kerberos or OIDC sign-in. The sync rewrites only memberships it made, never demotes or removes an owner, never joins a personal organization, and cannot map a role its author could not assign; an Entra ID group overage is refused (`app/services/directory/sync.py`, `app/services/directory/mappings.py`, `groups_claim` in `app/core/oauth.py`) | `tests/integration/test_directory_groups.py`, `test_oidc_groups.py` | | Grants to groups | A grant has exactly one subject, a person or a group (`ck_resource_grant_one_subject`); a group grant reaches whoever is in the group when access is checked, and only through groups of the same organization (`app/repositories/resource_grant.py`) | `tests/integration/test_directory_groups.py`, `test_resource_grant_repo.py` | | SAML, SCIM, a directory re-check between sign-ins | **Not yet** — a disabled directory account's open session lasts until its refresh token expires, unless an administrator deactivates it. See [What this does not do yet](directory.md#what-this-does-not-do-yet) | — | -| A replayed refresh token ends its chain and is recorded | Rotation keeps the hash it replaced; a refresh matching it is the reuse case in RFC 6819 §5.2.2.3 and closes that session with an audit entry (`SessionService.detect_refresh_reuse`). A token presented within `REFRESH_REUSE_GRACE_SECONDS` (60 by default) of its rotation is a lost response or a second tab rather than a replay, and refreshes once more instead (`SessionService.claim_refresh_grace`); that window is the accepted cost | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | +| A replayed refresh token ends its chain and is recorded | Rotation keeps the hash it replaced; a refresh matching it is the reuse case in RFC 6819 §5.2.2.3 and closes that session with an audit entry (`SessionService.detect_refresh_reuse`). A token presented within `REFRESH_REUSE_GRACE_SECONDS` (60 by default) of its rotation is a lost response or one request of a burst on the same cookie rather than a replay, and is answered with the successor the row already holds, rebuilt from the spent token, so every request in the burst gets the same token (`SessionService.claim_refresh_grace`, `reissue_within_grace`); that window is the accepted cost | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | ### Audit controls · HIPAA §164.312(b) · SOC 2 CC7 diff --git a/docs/security.pl.md b/docs/security.pl.md index 893731e07..46d5c1ec0 100644 --- a/docs/security.pl.md +++ b/docs/security.pl.md @@ -1,5 +1,5 @@ --- -source_sha: "832b2ea60503" +source_sha: "3b62656f6e5b" --- # Bezpieczeństwo { #security } @@ -193,7 +193,7 @@ w mocy. Ujęte względem zabezpieczeń technicznych HIPAA §164.312 i SOC 2 CC6 | SAML, SCIM, ponowne sprawdzanie katalogu między logowaniami | **Jeszcze nie** — otwarta sesja wyłączonego konta katalogowego trwa, dopóki nie wygaśnie jej refresh token, chyba że administrator dezaktywuje konto. Zobacz [Czego to jeszcze nie robi](directory.md#what-this-does-not-do-yet) | — | | Limitowanie prób logowania | `enforce_auth_limit` (`app/api/deps.py`) | `test_auth_rate_limit.py` | | Zmieniony adres e-mail jest dowodzony, zanim poczta za nim pójdzie | `PATCH /users/me` odkłada adres w `users.pending_email` i wysyła na niego jednorazowy, godzinny link; konto do powrotu tego linku odbiera wszystko pod dotychczasowym adresem, a ten dotychczasowy dostaje informację, że o zmianę poproszono. Link niesie wersję poświadczeń konta, więc zmiana albo reset hasła — to, do czego wzywa tamta informacja — unieważnia go, a naprawa adresu przez administratora czyści odłożoną zmianę. Ponowna prośba o już odłożony adres nie wysyła nic, a liczba różnych adresów na konto jest ograniczona w ciągu godziny. I żądanie, i potwierdzenie trafiają do audytu (`app/services/user.py`, `POST /auth/email-change/confirm`) | `test_email_change.py` | -| Odtworzony refresh token kończy swój łańcuch i zostaje zapisany | Rotacja zachowuje zastąpiony hash; refresh, który do niego pasuje, to przypadek ponownego użycia z RFC 6819 §5.2.2.3 - zamyka tę sesję i zostawia wpis w audycie (`SessionService.detect_refresh_reuse`). Token przedstawiony w ciągu `REFRESH_REUSE_GRACE_SECONDS` (domyślnie 60) od swojej rotacji to utracona odpowiedź albo druga karta, a nie odtworzenie, więc odświeża jeszcze raz (`SessionService.claim_refresh_grace`); to okno jest świadomie przyjętym kosztem | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | +| Odtworzony refresh token kończy swój łańcuch i zostaje zapisany | Rotacja zachowuje zastąpiony hash; refresh, który do niego pasuje, to przypadek ponownego użycia z RFC 6819 §5.2.2.3 - zamyka tę sesję i zostawia wpis w audycie (`SessionService.detect_refresh_reuse`). Token przedstawiony w ciągu `REFRESH_REUSE_GRACE_SECONDS` (domyślnie 60) od swojej rotacji to utracona odpowiedź albo jedno z kilku równoczesnych żądań z tym samym cookie, a nie odtworzenie, więc dostaje następcę, którego wiersz już trzyma, odtworzonego ze zużytego tokena - każde żądanie z takiej serii dostaje ten sam token (`SessionService.claim_refresh_grace`, `reissue_within_grace`); to okno jest świadomie przyjętym kosztem | `test_session_revocation.py::TestReusingASpentRefreshToken`, `TestTheReuseGraceWindow` | ### Kontrole audytowe · HIPAA §164.312(b) · SOC 2 CC7 { #audit-controls-hipaa-164312b-soc-2-cc7 } diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx index 1b816493b..b7767addd 100644 --- a/frontend/src/hooks/use-chat.test.tsx +++ b/frontend/src/hooks/use-chat.test.tsx @@ -2525,6 +2525,18 @@ describe("useChat - the socket it opens", () => { expect(useAuthStore.getState().accessToken).toBe("t-1"); }); + it("keeps the token when the refresh answers with nothing at all", async () => { + renderHook(() => useChat(), { wrapper }); + get.mockResolvedValue(null); + + await act(async () => { + socket.onClose?.(); + await Promise.resolve(); + }); + + expect(useAuthStore.getState().accessToken).toBe("t-1"); + }); + it("closes the socket when the chat goes away", () => { const { unmount } = renderHook(() => useChat(), { wrapper }); diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts index 2582308d7..fc6acf14e 100644 --- a/frontend/src/hooks/use-chat.ts +++ b/frontend/src/hooks/use-chat.ts @@ -823,8 +823,13 @@ export function useChat(options: UseChatOptions = {}) { refreshingRef.current = true; void (async () => { try { + // Through `apiClient`, not a bare fetch: `/auth/me` refreshes on the + // server when the access cookie has expired, and only `apiClient` sends + // it under the cross-tab auth lock. An expired access token closes this + // socket at the same moment the page's own requests start refreshing, + // and the unserialized refresh here was one of that burst. const data = await apiClient.get<{ access_token?: string }>("/auth/me"); - if (data.access_token) useAuthStore.getState().setAccessToken(data.access_token); + if (data?.access_token) useAuthStore.getState().setAccessToken(data.access_token); } catch { // ignore - backoff reconnect will retry } finally { diff --git a/frontend/src/lib/api-client.test.ts b/frontend/src/lib/api-client.test.ts index 3876f394c..de20dff11 100644 --- a/frontend/src/lib/api-client.test.ts +++ b/frontend/src/lib/api-client.test.ts @@ -222,6 +222,45 @@ describe("recovering from an expired token", () => { }); }); + it("signs the store out when the refresh is refused, so the guard sends them to sign in", async () => { + // Left signed in, the page stayed up with every request answering 401 and + // nothing redirecting until a full reload. + useAuthStore.setState({ isAuthenticated: true }); + fetchMock + .mockResolvedValueOnce(refused(401, {})) + .mockResolvedValueOnce(refused(401, { code: "SESSION_EXPIRED" })); + + await expect(apiClient.get("/agents")).rejects.toMatchObject({ status: 401 }); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it("keeps the store signed in when the refresh failed for a reason that is not the session", async () => { + useAuthStore.setState({ isAuthenticated: true }); + fetchMock + .mockResolvedValueOnce(refused(401, {})) + .mockResolvedValueOnce(refused(502, { code: "INTERNAL_SERVER_ERROR" })) + .mockResolvedValueOnce(refused(401, {})) + .mockResolvedValueOnce(refused(429, {})); + + await expect(apiClient.get("/a")).rejects.toMatchObject({ status: 401 }); + await expect(apiClient.get("/b")).rejects.toMatchObject({ status: 401 }); + + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + + it("leaves an ended impersonation to its own exit rather than signing out", async () => { + useAuthStore.setState({ isAuthenticated: true, impersonationRevoked: false }); + fetchMock + .mockResolvedValueOnce(refused(401, {})) + .mockResolvedValueOnce(refused(401, { code: "IMPERSONATION_ENDED" })); + + await expect(apiClient.get("/agents")).rejects.toMatchObject({ status: 401 }); + + expect(useAuthStore.getState().impersonationRevoked).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + it("survives a refresh whose body is not JSON, because the cookie still rotated", async () => { fetchMock .mockResolvedValueOnce(refused(401, {})) diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 7c77c1f54..bee317df6 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -77,6 +77,12 @@ function refreshAccessToken(): Promise { if (!res.ok) { if (await refusedAsEndedImpersonation(res)) { useAuthStore.getState().setImpersonationRevoked(true); + } else if (res.status === 401) { + // The session is over. Only the store tells `AuthGuard` so: left + // signed in, the page stays up with every request refused and + // nothing sends the person to sign in again until a full reload. + // A rate limit or a 5xx is not a verdict on the session. + useAuthStore.getState().logout(); } return false; }