Loading src/portal_service/api/auth/routes.py +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 Loading @@ -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. """ Loading @@ -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") Loading @@ -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 src/portal_service/api/dependencies.py +11 −0 Changes for src/portal_service/api/dependencies.py: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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), ) src/portal_service/application/login_service.py +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 Loading @@ -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, Loading @@ -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 Loading Loading @@ -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") src/portal_service/application/session_service.py 0 → 100644 +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) src/portal_service/domain/errors.py +5 −0 Changes for src/portal_service/domain/errors.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
src/portal_service/api/auth/routes.py +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 Loading @@ -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. """ Loading @@ -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") Loading @@ -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
src/portal_service/api/dependencies.py +11 −0 Changes for src/portal_service/api/dependencies.py: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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), )
src/portal_service/application/login_service.py +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 Loading @@ -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, Loading @@ -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 Loading Loading @@ -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")
src/portal_service/application/session_service.py 0 → 100644 +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)
src/portal_service/domain/errors.py +5 −0 Changes for src/portal_service/domain/errors.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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