Loading src/portal_service/adapters/database/repos/login_sessions.py +14 −1 Changes for src/portal_service/adapters/database/repos/login_sessions.py: 14 added lines, 1 removed line. Original line number Diff line number Diff line from datetime import datetime from typing import Any, cast from sqlalchemy import CursorResult, delete, select from sqlalchemy import CursorResult, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from portal_service.adapters.database.mappers import LoginSessionMapper Loading @@ -27,6 +27,19 @@ class SqlLoginSessionRepository(LoginSessionRepository): raise RuntimeError("Saved login session could not be reloaded") return saved async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: # A plain UPDATE, not merge: merge would INSERT the row again if a logout # deleted it while the refresh was talking to Keycloak. stmt = ( update(LoginSessionRow) .where(LoginSessionRow.id == session_id_hash) .values(tokens=encrypted_tokens, access_token_expires_at=access_token_expires_at) ) result = cast(CursorResult[Any], await self._session.execute(stmt)) return result.rowcount == 1 async def delete_by_id(self, session_id_hash: str) -> None: stmt = delete(LoginSessionRow).where(LoginSessionRow.id == session_id_hash) await self._session.execute(stmt) Loading src/portal_service/application/session_service.py +13 −8 Changes for src/portal_service/application/session_service.py: 13 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -117,16 +117,21 @@ class SessionService: refresh_token=issued.tokens.refresh_token or active.tokens.refresh_token, id_token=issued.tokens.id_token or active.tokens.id_token, ) row = await self._sessions.save( LoginSession( id=active.session.id, subject=active.session.subject, access_token_expires_at=now + timedelta(seconds=issued.expires_in), access_token_expires_at = now + timedelta(seconds=issued.expires_in) # Same key as before: it lives only in the cookie the browser still holds. encrypted_tokens=encrypt_tokens(tokens, active.credentials), encrypted = encrypt_tokens(tokens, active.credentials) if not await self._sessions.update_tokens( active.session.id, encrypted, access_token_expires_at ): # Logged out while we were refreshing: the session stays ended. raise InvalidSessionError("Session ended during refresh") # The session's own deadline is unchanged by a refresh. expires_at=active.session.expires_at, ) row = active.session.model_copy( update={ "encrypted_tokens": encrypted, "access_token_expires_at": access_token_expires_at, } ) return ActiveSession(session=row, tokens=tokens, credentials=active.credentials) Loading src/portal_service/domain/ports/database/login_sessions.py +9 −0 Changes for src/portal_service/domain/ports/database/login_sessions.py: 9 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -20,6 +20,15 @@ class LoginSessionRepository(ABC): async def save(self, login_session: LoginSession) -> LoginSession: pass @abstractmethod async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: """ Replaces the tokens of an existing session. Returns False, and changes nothing, if the session is gone: a refresh must never bring back a logged-out session. """ @abstractmethod async def delete_by_id(self, session_id_hash: str) -> None: pass Loading tests/fakes.py +14 −0 Changes for tests/fakes.py: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -37,6 +37,20 @@ class FakeLoginSessionRepository(LoginSessionRepository): self.rows[login_session.id] = login_session return login_session async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: row = self.rows.get(session_id_hash) if row is None: return False self.rows[session_id_hash] = row.model_copy( update={ "encrypted_tokens": encrypted_tokens, "access_token_expires_at": access_token_expires_at, } ) return True async def delete_by_id(self, session_id_hash: str) -> None: self.rows.pop(session_id_hash, None) Loading tests/integration/test_repositories.py +23 −0 Changes for tests/integration/test_repositories.py: 23 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -88,6 +88,29 @@ async def test_only_expired_sessions_are_purged(db_session: AsyncSession) -> Non assert await repo.get_by_id("b" * 64) is None async def test_updating_tokens_of_a_deleted_session_changes_nothing( db_session: AsyncSession, ) -> None: # The refresh-after-logout race: the UPDATE must not recreate the row. repo = SqlLoginSessionRepository(db_session) await repo.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"one")) await repo.delete_by_id("a" * 64) updated = await repo.update_tokens("a" * 64, b"refreshed", datetime.now(UTC)) assert updated is False assert await repo.get_by_id("a" * 64) is None async def test_updating_tokens_of_a_live_session_replaces_them(db_session: AsyncSession) -> None: repo = SqlLoginSessionRepository(db_session) await repo.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"one")) assert await repo.update_tokens("a" * 64, b"refreshed", datetime.now(UTC)) is True stored = await repo.get_by_id("a" * 64) assert stored is not None and stored.encrypted_tokens == b"refreshed" # ------------------------------------------------------------------ login attempts Loading Loading
src/portal_service/adapters/database/repos/login_sessions.py +14 −1 Changes for src/portal_service/adapters/database/repos/login_sessions.py: 14 added lines, 1 removed line. Original line number Diff line number Diff line from datetime import datetime from typing import Any, cast from sqlalchemy import CursorResult, delete, select from sqlalchemy import CursorResult, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from portal_service.adapters.database.mappers import LoginSessionMapper Loading @@ -27,6 +27,19 @@ class SqlLoginSessionRepository(LoginSessionRepository): raise RuntimeError("Saved login session could not be reloaded") return saved async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: # A plain UPDATE, not merge: merge would INSERT the row again if a logout # deleted it while the refresh was talking to Keycloak. stmt = ( update(LoginSessionRow) .where(LoginSessionRow.id == session_id_hash) .values(tokens=encrypted_tokens, access_token_expires_at=access_token_expires_at) ) result = cast(CursorResult[Any], await self._session.execute(stmt)) return result.rowcount == 1 async def delete_by_id(self, session_id_hash: str) -> None: stmt = delete(LoginSessionRow).where(LoginSessionRow.id == session_id_hash) await self._session.execute(stmt) Loading
src/portal_service/application/session_service.py +13 −8 Changes for src/portal_service/application/session_service.py: 13 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -117,16 +117,21 @@ class SessionService: refresh_token=issued.tokens.refresh_token or active.tokens.refresh_token, id_token=issued.tokens.id_token or active.tokens.id_token, ) row = await self._sessions.save( LoginSession( id=active.session.id, subject=active.session.subject, access_token_expires_at=now + timedelta(seconds=issued.expires_in), access_token_expires_at = now + timedelta(seconds=issued.expires_in) # Same key as before: it lives only in the cookie the browser still holds. encrypted_tokens=encrypt_tokens(tokens, active.credentials), encrypted = encrypt_tokens(tokens, active.credentials) if not await self._sessions.update_tokens( active.session.id, encrypted, access_token_expires_at ): # Logged out while we were refreshing: the session stays ended. raise InvalidSessionError("Session ended during refresh") # The session's own deadline is unchanged by a refresh. expires_at=active.session.expires_at, ) row = active.session.model_copy( update={ "encrypted_tokens": encrypted, "access_token_expires_at": access_token_expires_at, } ) return ActiveSession(session=row, tokens=tokens, credentials=active.credentials) Loading
src/portal_service/domain/ports/database/login_sessions.py +9 −0 Changes for src/portal_service/domain/ports/database/login_sessions.py: 9 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -20,6 +20,15 @@ class LoginSessionRepository(ABC): async def save(self, login_session: LoginSession) -> LoginSession: pass @abstractmethod async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: """ Replaces the tokens of an existing session. Returns False, and changes nothing, if the session is gone: a refresh must never bring back a logged-out session. """ @abstractmethod async def delete_by_id(self, session_id_hash: str) -> None: pass Loading
tests/fakes.py +14 −0 Changes for tests/fakes.py: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -37,6 +37,20 @@ class FakeLoginSessionRepository(LoginSessionRepository): self.rows[login_session.id] = login_session return login_session async def update_tokens( self, session_id_hash: str, encrypted_tokens: bytes, access_token_expires_at: datetime ) -> bool: row = self.rows.get(session_id_hash) if row is None: return False self.rows[session_id_hash] = row.model_copy( update={ "encrypted_tokens": encrypted_tokens, "access_token_expires_at": access_token_expires_at, } ) return True async def delete_by_id(self, session_id_hash: str) -> None: self.rows.pop(session_id_hash, None) Loading
tests/integration/test_repositories.py +23 −0 Changes for tests/integration/test_repositories.py: 23 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -88,6 +88,29 @@ async def test_only_expired_sessions_are_purged(db_session: AsyncSession) -> Non assert await repo.get_by_id("b" * 64) is None async def test_updating_tokens_of_a_deleted_session_changes_nothing( db_session: AsyncSession, ) -> None: # The refresh-after-logout race: the UPDATE must not recreate the row. repo = SqlLoginSessionRepository(db_session) await repo.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"one")) await repo.delete_by_id("a" * 64) updated = await repo.update_tokens("a" * 64, b"refreshed", datetime.now(UTC)) assert updated is False assert await repo.get_by_id("a" * 64) is None async def test_updating_tokens_of_a_live_session_replaces_them(db_session: AsyncSession) -> None: repo = SqlLoginSessionRepository(db_session) await repo.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"one")) assert await repo.update_tokens("a" * 64, b"refreshed", datetime.now(UTC)) is True stored = await repo.get_by_id("a" * 64) assert stored is not None and stored.encrypted_tokens == b"refreshed" # ------------------------------------------------------------------ login attempts Loading