-
Notifications
You must be signed in to change notification settings - Fork 3
10 - API (feat): Use Litestar Stores for Sessions #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ab75018
31be4ae
351eaa0
bfb60ee
107a031
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,6 @@ certs/ | |
| *.crt | ||
| *.key | ||
| *.pem | ||
|
|
||
| # Litestar file-backed stores (server-side sessions, response cache) | ||
| /api/stores/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """OAuth2/OIDC client configuration. | ||
|
|
||
| Owned by the auth slice; the composition root builds it via | ||
| `create_oauth_client` and holds it on `AppState`. | ||
| """ | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ..shared.settings import Settings | ||
|
|
||
| # Session cookie name, shared by `main.py`'s session config and the logout | ||
| # handlers in `auth/routers.py` so the two cannot drift. | ||
| SESSION_COOKIE_KEY = "session" | ||
|
|
||
|
|
||
| @dataclass | ||
| class OAuthClient: | ||
| """OAuth2/OIDC client configuration with lazily loaded server metadata.""" | ||
|
|
||
| client_id: str | ||
| client_secret: str | ||
| scope: str | ||
| server_metadata_url: str | ||
| server_metadata: dict = field(default_factory=dict) | ||
|
|
||
| async def load_server_metadata(self) -> None: | ||
| import httpx | ||
|
|
||
| async with httpx.AsyncClient() as http: | ||
| resp = await http.get(self.server_metadata_url) | ||
| resp.raise_for_status() | ||
| self.server_metadata = resp.json() | ||
|
|
||
|
|
||
| def create_oauth_client(settings: "Settings") -> OAuthClient | None: | ||
| if settings.auth is None: | ||
| return None | ||
|
|
||
| return OAuthClient( | ||
| client_id=settings.auth.client_id, | ||
| client_secret=settings.auth.client_secret.get_secret_value(), | ||
| scope="openid email groups", | ||
| server_metadata_url=str(settings.auth.server_metadata_url), | ||
| ) |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,19 @@ | ||
| from anyio import Path as APath | ||
| from litestar import Router, get | ||
| from litestar import Request, Router, get | ||
| from litestar.di import Provide | ||
|
|
||
| from ..metadata.models import ProposalMeta | ||
| from ..metadata.routers import get_proposal_meta | ||
| from . import models | ||
|
|
||
|
|
||
| @get("/content") | ||
| def _proposal_cache_key(request: Request) -> str: | ||
| """Key response-cache entries per proposal so entries stay isolated.""" | ||
| proposal_number = request.query_params.get("proposal_number", "") | ||
| return f"{request.url.path}:{proposal_number}" | ||
|
|
||
|
|
||
| @get("/content", cache=5, cache_key_builder=_proposal_cache_key) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to confirm: is this behind auth? I just wonder on the case when the cache is already built by an authorized user who just accessed it, and then subsequenltly accessed by unauthorized user. From reading the code, the endpoint will return the content regardless. |
||
| async def get_content(proposal: ProposalMeta) -> models.ContextFile | None: | ||
| if proposal.damnit_path is None: | ||
| return None | ||
|
|
@@ -16,7 +22,7 @@ async def get_content(proposal: ProposalMeta) -> models.ContextFile | None: | |
| ) | ||
|
|
||
|
|
||
| @get("/last_modified") | ||
| @get("/last_modified", cache=5, cache_key_builder=_proposal_cache_key) | ||
| async def get_modified(proposal: ProposalMeta) -> models.ModifiedTime | None: | ||
| if proposal.damnit_path is None: | ||
| return None | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,42 +14,39 @@ | |
|
|
||
|
|
||
| def create_app(): | ||
| import hashlib | ||
|
|
||
| from litestar import Request, Response | ||
| from litestar.middleware.session.client_side import CookieBackendConfig | ||
| from litestar.middleware.session.server_side import ServerSideSessionConfig | ||
| from litestar.openapi import OpenAPIConfig | ||
| from litestar.response import Redirect | ||
| from litestar.stores.file import FileStore | ||
| from litestar.stores.memory import MemoryStore | ||
| from litestar.stores.registry import StoreRegistry | ||
|
|
||
| from . import _logging, auth, get_logger | ||
| from .auth.oauth import SESSION_COOKIE_KEY, create_oauth_client | ||
| from .graphql.dependencies import get_subscription_cursors | ||
| from .runs.dependencies import get_repositories | ||
| from .shared.errors import DamnitWebError | ||
| from .shared.gql import get_gql_controller | ||
| from .shared.settings import settings | ||
| from .state import ( | ||
| SESSION_COOKIE_KEY, | ||
| AppState, | ||
| create_db_engine, | ||
| create_db_sessionmaker, | ||
| create_mymdc_client, | ||
| create_oauth_client, | ||
| create_repositories, | ||
| create_subscription_cursors, | ||
| create_token_store, | ||
| provide_app_state, | ||
| ) | ||
|
|
||
| logger = get_logger("lifespan") | ||
|
|
||
| # ββ Session middleware ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # Derive a 32-byte AES key from the session secret via SHA-256. | ||
| session_secret = settings.session_secret | ||
| assert session_secret is not None # enforced by Settings validator # noqa: S101 | ||
| session_config = CookieBackendConfig( | ||
| secret=hashlib.sha256(session_secret.get_secret_value().encode()).digest(), | ||
| key=SESSION_COOKIE_KEY, | ||
| ) | ||
| # ββ Stores + server-side sessions βββββββββββββββββββββββββββββββββββββββββ | ||
| # Sessions are server-side: the cookie carries only an opaque session id; | ||
| # session data lives in a Litestar store. The same registry backs every | ||
| # named store (sessions, response cache); the backend is mode-dependent | ||
| # (in-memory locally, file-backed otherwise) and selected below. | ||
| session_config = ServerSideSessionConfig(key=SESSION_COOKIE_KEY) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we can add |
||
|
|
||
| # ββ Exception handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| def dw_error_handler(request: Request, exc: DamnitWebError) -> Response: | ||
|
|
@@ -100,7 +97,6 @@ async def lifespan(app: Litestar): | |
| db_sessionmaker=create_db_sessionmaker(engine), | ||
| mymdc_client=create_mymdc_client(settings), | ||
| oauth_client=oauth_client, | ||
| token_store=create_token_store(), | ||
| repositories=create_repositories(), | ||
| subscription_cursors=create_subscription_cursors(), | ||
| ) | ||
|
|
@@ -110,10 +106,22 @@ async def lifespan(app: Litestar): | |
| finally: | ||
| await engine.dispose() | ||
|
|
||
| # ββ Auth controller (mode-dependent, ADR-008 composition) βββββββββββββββ | ||
| auth_controller = ( | ||
| auth.NoAuthOAuthController if settings.is_local else auth.OAuthController | ||
| ) | ||
| def _file_store(name: str) -> FileStore: | ||
| # FileStore does not create its directory on write; the session read | ||
| # path (and forged test writes) need it to exist up front. | ||
| path = settings.store_path / name | ||
| path.mkdir(parents=True, exist_ok=True) | ||
| return FileStore(path) | ||
|
|
||
| # ββ Mode-dependent composition: controller and stores βββββββββββββββββββ | ||
| # In-memory stores are process-local: local mode is single-worker, and the | ||
| # file-backed stores serve the deployed (potentially multi-worker) case. | ||
| if settings.is_local: | ||
| auth_controller = auth.NoAuthOAuthController | ||
| stores = StoreRegistry(default_factory=lambda name: MemoryStore()) | ||
| else: | ||
| auth_controller = auth.OAuthController | ||
| stores = StoreRegistry(default_factory=_file_store) | ||
|
|
||
| # ββ GraphQL controller ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| gql_controller = get_gql_controller() | ||
|
|
@@ -132,9 +140,6 @@ async def lifespan(app: Litestar): | |
| "oauth_config": Provide( | ||
| auth.dependencies.get_oauth_client, sync_to_thread=False | ||
| ), | ||
| "token_store": Provide( | ||
| auth.dependencies.get_token_store, sync_to_thread=False | ||
| ), | ||
| "session": Provide(get_session), | ||
| "mymdc": Provide(get_mymdc_client, sync_to_thread=False), | ||
| "user": Provide(get_user), | ||
|
|
@@ -144,6 +149,7 @@ async def lifespan(app: Litestar): | |
| ), | ||
| "repositories": Provide(get_repositories, sync_to_thread=False), | ||
| }, | ||
| stores=stores, | ||
| middleware=[ | ||
| session_config.middleware, | ||
| _logging.RequestLoggingMiddleware, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooooh this is a bit scary. Do we have encryption on the file store or something similar?