Commit e86f19f6 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement login callback route and session management

parent 364d5002
Loading
Loading
Loading
Loading
+95 −6
Changes for src/portal_service/api/auth/routes.py: 95 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@ Login routes.

  GET /api/auth/login     — start a login: remember the one-time values, send the
                            browser to Keycloak.
  GET /api/auth/callback  — finish it: check the callback belongs to that login,
                            trade the code for tokens, and open a session.

The browser never receives a token; it gets cookies that name server-side rows.
"""
@@ -10,22 +12,39 @@ The browser never receives a token; it gets cookies that name server-side rows.
from typing import Annotated

import structlog
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse

from portal_service.api.dependencies import SettingsDep, get_login_service
from portal_service.api.auth.verification import _decode_id_token
from portal_service.api.dependencies import (
    SettingsDep,
    get_identity_provider,
    get_login_service,
    get_session_service,
)
from portal_service.application.login_service import LoginService
from portal_service.application.session_service import SessionService
from portal_service.config import Settings
from portal_service.domain.errors import (
    AuthenticationError,
    IdentityProviderUnavailableError,
    InvalidTokenError,
)
from portal_service.domain.ports.identity_provider import IdentityProvider

logger: structlog.BoundLogger = structlog.get_logger(__name__)

auth_router = APIRouter(prefix="/api/auth", tags=["auth"])

LoginServiceDep = Annotated[LoginService, Depends(get_login_service)]
SessionServiceDep = Annotated[SessionService, Depends(get_session_service)]
IdentityProviderDep = Annotated[IdentityProvider, Depends(get_identity_provider)]

_LOGIN_FAILED = "Login failed, please try again"


@auth_router.get("/login")
async def login(
    service: Annotated[LoginService, Depends(get_login_service)],
    settings: SettingsDep,
) -> RedirectResponse:
async def login(service: LoginServiceDep, settings: SettingsDep) -> RedirectResponse:
    started = await service.start()
    logger.info("login started")

@@ -48,3 +67,73 @@ async def login(
        path="/api/auth",
    )
    return response


@auth_router.get("/callback")
async def callback(
    request: Request,
    settings: SettingsDep,
    login_service: LoginServiceDep,
    session_service: SessionServiceDep,
    identity_provider: IdentityProviderDep,
    code: str | None = None,
    state: str | None = None,
    error: str | None = None,
) -> RedirectResponse:
    if error is not None:
        # The user cancelled, or Keycloak refused before any code was issued.
        logger.warning("keycloak did not issue a code", oauth_error=error)
        raise HTTPException(status.HTTP_400_BAD_REQUEST, _LOGIN_FAILED)
    if code is None or state is None:
        logger.warning("callback missing code or state")
        raise HTTPException(status.HTTP_400_BAD_REQUEST, _LOGIN_FAILED)

    attempt_id = request.cookies.get(settings.session.attempt_cookie_name)
    if not attempt_id:
        logger.warning("callback without a login cookie")
        raise HTTPException(status.HTTP_400_BAD_REQUEST, _LOGIN_FAILED)

    try:
        # Taking the attempt deletes it, so a replayed callback finds nothing.
        attempt = await login_service.take_attempt(attempt_id)
        login_service.check_state(attempt, state)

        issued = await identity_provider.exchange_code(
            code=code, code_verifier=attempt.code_verifier
        )
        if issued.tokens.id_token is None:
            raise InvalidTokenError("Keycloak returned no ID token")
        claims = _decode_id_token(issued.tokens.id_token, expected_nonce=attempt.nonce)

        created = await session_service.create(subject=claims.sub, issued=issued)
    except AuthenticationError as exc:
        logger.warning("login could not be completed", error=str(exc))
        raise HTTPException(status.HTTP_400_BAD_REQUEST, _LOGIN_FAILED) from exc
    except IdentityProviderUnavailableError as exc:
        logger.error("identity provider unreachable during login", error=str(exc))
        raise HTTPException(
            status.HTTP_503_SERVICE_UNAVAILABLE, "Authentication service unavailable"
        ) from exc

    logger.info("login completed", subject=claims.sub)
    return _session_response(settings, created.cookie_value)


def _session_response(settings: Settings, session_cookie: str) -> RedirectResponse:
    response = RedirectResponse(
        settings.oidc.post_login_url if settings.oidc and settings.oidc.post_login_url else "/",
        status_code=status.HTTP_303_SEE_OTHER,
    )
    response.set_cookie(
        key=settings.session.cookie_name,
        value=session_cookie,
        max_age=settings.session.max_age,
        httponly=True,
        secure=settings.session.https_only,
        samesite="lax",
        # Sent to every portal route, since any of them may act for the user.
        path="/",
    )
    # The login is over: the attempt cookie has nothing left to name.
    response.delete_cookie(settings.session.attempt_cookie_name, path="/api/auth")
    return response
+11 −0
Changes for src/portal_service/api/dependencies.py: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@ from portal_service.adapters.database.repos import (
from portal_service.adapters.keycloak.oidc_client import KeycloakOIDCClient
from portal_service.app_state import AppState
from portal_service.application.login_service import LoginService
from portal_service.application.session_service import SessionService
from portal_service.config import OIDCSettings, Settings, get_settings
from portal_service.domain.ports.database.login_attempts import LoginAttemptRepository
from portal_service.domain.ports.database.login_sessions import LoginSessionRepository
@@ -87,3 +88,13 @@ def get_login_service(
        identity_provider=identity_provider,
        attempt_ttl=timedelta(seconds=settings.session.attempt_max_age),
    )


def get_session_service(
    sessions: Annotated[LoginSessionRepository, Depends(get_login_session_repo)],
    settings: SettingsDep,
) -> SessionService:
    return SessionService(
        sessions=sessions,
        session_ttl=timedelta(seconds=settings.session.max_age),
    )
+20 −0
Changes for src/portal_service/application/login_service.py: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ the browser. The route deals with cookies and redirects; this deals with the flo

from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from hmac import compare_digest

from portal_service.application.pkce import (
    code_challenge,
@@ -15,6 +16,7 @@ from portal_service.application.pkce import (
    new_state,
)
from portal_service.application.session_crypto import hash_id, new_id
from portal_service.domain.errors import LoginAttemptError
from portal_service.domain.models import LoginAttempt
from portal_service.domain.ports.database.login_attempts import LoginAttemptRepository
from portal_service.domain.ports.identity_provider import IdentityProvider
@@ -64,3 +66,21 @@ class LoginService:
            ),
            attempt_id=attempt_id,
        )

    async def take_attempt(self, attempt_id: str) -> LoginAttempt:
        """
        Reads the attempt the login cookie names, and removes it: a callback can be
        used once. An unknown or expired attempt is not a callback we started.
        """
        attempt = await self._attempts.take(hash_id(attempt_id))
        if attempt is None:
            raise LoginAttemptError("No login is in progress")
        if attempt.expires_at <= datetime.now(UTC):
            raise LoginAttemptError("The login took too long")
        return attempt

    @staticmethod
    def check_state(attempt: LoginAttempt, state: str) -> None:
        """Rejects a callback this browser did not ask for (CSRF)."""
        if not compare_digest(attempt.state, state):
            raise LoginAttemptError("This callback belongs to a different login")
+44 −0
Changes for src/portal_service/application/session_service.py: 44 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""
Creating a login session.

Turns the tokens Keycloak issued into a row nobody can read without the cookie,
and returns the cookie value the browser should get.
"""

from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta

from portal_service.application.session_crypto import SessionCredentials, encrypt_tokens
from portal_service.domain.models import IssuedTokens, LoginSession
from portal_service.domain.ports.database.login_sessions import LoginSessionRepository


@dataclass(frozen=True)
class CreatedSession:
    """The cookie the browser gets, and when the session dies regardless."""

    cookie_value: str = field(repr=False)
    expires_at: datetime


class SessionService:
    def __init__(self, sessions: LoginSessionRepository, session_ttl: timedelta) -> None:
        self._sessions = sessions
        self._session_ttl = session_ttl

    async def create(self, subject: str, issued: IssuedTokens) -> CreatedSession:
        credentials = SessionCredentials.generate()
        now = datetime.now(UTC)
        expires_at = now + self._session_ttl

        await self._sessions.save(
            LoginSession(
                # The row is named by the hash; only the cookie carries the id itself.
                id=credentials.session_id_hash,
                subject=subject,
                access_token_expires_at=now + timedelta(seconds=issued.expires_in),
                encrypted_tokens=encrypt_tokens(issued.tokens, credentials),
                expires_at=expires_at,
            )
        )
        return CreatedSession(cookie_value=credentials.to_cookie(), expires_at=expires_at)
+5 −0
Changes for src/portal_service/domain/errors.py: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,11 @@ class InvalidSessionError(AuthenticationError):
    pass


# The callback does not match a login we started: no attempt, expired, or wrong state.
class LoginAttemptError(AuthenticationError):
    pass


# Keycloak refused a login code or refresh token: expired, already used, or not ours.
class InvalidGrantError(AuthenticationError):
    pass
Loading