Commit 165d5aa0 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement login session management with repository and mapper

parent 6d2a9489
Loading
Loading
Loading
Loading
+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,
        )
+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",
]
+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
+15 −0
Changes for src/portal_service/domain/models.py: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@
Core domain models — pure Python, no framework dependencies.
"""

from datetime import datetime
from enum import StrEnum
from typing import Any

@@ -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
+0 −0

Empty file added.

Loading