Loading src/portal_service/adapters/database/mappers.py 0 → 100644 +26 −0 Changes for src/portal_service/adapters/database/mappers.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line from portal_service.adapters.database.sql import LoginSessionRow from portal_service.domain.models import LoginSession class LoginSessionMapper: @staticmethod def to_domain(row: LoginSessionRow) -> LoginSession: return LoginSession( id=row.id, subject=row.subject, access_token_expires_at=row.access_token_expires_at, encrypted_tokens=row.tokens, expires_at=row.expires_at, created_at=row.created_at, ) @staticmethod def to_row(domain: LoginSession) -> LoginSessionRow: # created_at is left out: the database sets it on insert and merge keeps it. return LoginSessionRow( id=domain.id, subject=domain.subject, access_token_expires_at=domain.access_token_expires_at, tokens=domain.encrypted_tokens, expires_at=domain.expires_at, ) src/portal_service/adapters/database/repos/__init__.py 0 → 100644 +5 −0 Changes for src/portal_service/adapters/database/repos/__init__.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line from portal_service.adapters.database.repos.login_sessions import SqlLoginSessionRepository __all__ = [ "SqlLoginSessionRepository", ] src/portal_service/adapters/database/repos/login_sessions.py 0 → 100644 +37 −0 Changes for src/portal_service/adapters/database/repos/login_sessions.py: 37 added lines, 0 removed lines. 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.ext.asyncio import AsyncSession from portal_service.adapters.database.mappers import LoginSessionMapper from portal_service.adapters.database.sql import LoginSessionRow from portal_service.domain.models import LoginSession from portal_service.ports.database.login_sessions import LoginSessionRepository class SqlLoginSessionRepository(LoginSessionRepository): def __init__(self, session: AsyncSession) -> None: self._session = session async def get_by_id(self, session_id_hash: str) -> LoginSession | None: stmt = select(LoginSessionRow).where(LoginSessionRow.id == session_id_hash) row = await self._session.scalar(stmt) return LoginSessionMapper.to_domain(row) if row is not None else None async def save(self, login_session: LoginSession) -> LoginSession: merged = await self._session.merge(LoginSessionMapper.to_row(login_session)) await self._session.flush() saved = await self.get_by_id(merged.id) if saved is None: raise RuntimeError("Saved login session could not be reloaded") return saved 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) async def delete_expired(self, now: datetime) -> int: stmt = delete(LoginSessionRow).where(LoginSessionRow.expires_at <= now) result = cast(CursorResult[Any], await self._session.execute(stmt)) return result.rowcount src/portal_service/domain/models.py +15 −0 Changes for src/portal_service/domain/models.py: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ Core domain models — pure Python, no framework dependencies. """ from datetime import datetime from enum import StrEnum from typing import Any Loading Loading @@ -60,3 +61,17 @@ class SessionTokens(BaseModel): refresh_token: str | None = Field(default=None, repr=False) # Sent back to Keycloak as id_token_hint when logging out. id_token: str | None = Field(default=None, repr=False) class LoginSession(BaseModel): """ A stored login session. `id` is the SHA-256 of the session id in the cookie, and the tokens stay encrypted with the key that only the cookie holds. """ id: str subject: str access_token_expires_at: datetime encrypted_tokens: bytes = Field(repr=False) expires_at: datetime created_at: datetime | None = None src/portal_service/ports/__init__.py 0 → 100644 +0 −0 Empty file added. Loading
src/portal_service/adapters/database/mappers.py 0 → 100644 +26 −0 Changes for src/portal_service/adapters/database/mappers.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line from portal_service.adapters.database.sql import LoginSessionRow from portal_service.domain.models import LoginSession class LoginSessionMapper: @staticmethod def to_domain(row: LoginSessionRow) -> LoginSession: return LoginSession( id=row.id, subject=row.subject, access_token_expires_at=row.access_token_expires_at, encrypted_tokens=row.tokens, expires_at=row.expires_at, created_at=row.created_at, ) @staticmethod def to_row(domain: LoginSession) -> LoginSessionRow: # created_at is left out: the database sets it on insert and merge keeps it. return LoginSessionRow( id=domain.id, subject=domain.subject, access_token_expires_at=domain.access_token_expires_at, tokens=domain.encrypted_tokens, expires_at=domain.expires_at, )
src/portal_service/adapters/database/repos/__init__.py 0 → 100644 +5 −0 Changes for src/portal_service/adapters/database/repos/__init__.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line from portal_service.adapters.database.repos.login_sessions import SqlLoginSessionRepository __all__ = [ "SqlLoginSessionRepository", ]
src/portal_service/adapters/database/repos/login_sessions.py 0 → 100644 +37 −0 Changes for src/portal_service/adapters/database/repos/login_sessions.py: 37 added lines, 0 removed lines. 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.ext.asyncio import AsyncSession from portal_service.adapters.database.mappers import LoginSessionMapper from portal_service.adapters.database.sql import LoginSessionRow from portal_service.domain.models import LoginSession from portal_service.ports.database.login_sessions import LoginSessionRepository class SqlLoginSessionRepository(LoginSessionRepository): def __init__(self, session: AsyncSession) -> None: self._session = session async def get_by_id(self, session_id_hash: str) -> LoginSession | None: stmt = select(LoginSessionRow).where(LoginSessionRow.id == session_id_hash) row = await self._session.scalar(stmt) return LoginSessionMapper.to_domain(row) if row is not None else None async def save(self, login_session: LoginSession) -> LoginSession: merged = await self._session.merge(LoginSessionMapper.to_row(login_session)) await self._session.flush() saved = await self.get_by_id(merged.id) if saved is None: raise RuntimeError("Saved login session could not be reloaded") return saved 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) async def delete_expired(self, now: datetime) -> int: stmt = delete(LoginSessionRow).where(LoginSessionRow.expires_at <= now) result = cast(CursorResult[Any], await self._session.execute(stmt)) return result.rowcount
src/portal_service/domain/models.py +15 −0 Changes for src/portal_service/domain/models.py: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ Core domain models — pure Python, no framework dependencies. """ from datetime import datetime from enum import StrEnum from typing import Any Loading Loading @@ -60,3 +61,17 @@ class SessionTokens(BaseModel): refresh_token: str | None = Field(default=None, repr=False) # Sent back to Keycloak as id_token_hint when logging out. id_token: str | None = Field(default=None, repr=False) class LoginSession(BaseModel): """ A stored login session. `id` is the SHA-256 of the session id in the cookie, and the tokens stay encrypted with the key that only the cookie holds. """ id: str subject: str access_token_expires_at: datetime encrypted_tokens: bytes = Field(repr=False) expires_at: datetime created_at: datetime | None = None