Skip to content

src/genro_asgi/middleware/session.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """Session middleware — session lifecycle driven by the request cookie.16 17 Reads the session token from the request ``Cookie`` header (via the shared18 ``headers_dict`` scope cache, not a request object), reconnects an existing19 session or creates a new ANONYMOUS one through ``server.session_store``20 (``store.create()`` with no avatar — capturing an identity into a session is21 an explicit act of the login surface, core 1d), attaches it to22 ``scope["session"]``, and — ONLY when the session was created here — wraps23 ``send`` to add its ``Set-Cookie`` header (HttpOnly, ``Max-Age`` = the session24 TTL times ``COOKIE_LIFETIME_FACTOR``). The cookie is deliberately much longer25 than the session: the server-side TTL is SLIDING (every request refreshes26 ``last_access``) while ``Max-Age`` is fixed from issue time, so a same-length27 cookie would log an active user out on schedule. The wide fixed cookie is the28 legacy-proven answer (``GnrWebConnection.write_cookie``: timeout × 24, never29 re-issued per request) — the server stays the only arbiter of expiry and no30 response but the first carries a ``Set-Cookie``. Login never changes the31 session id: a handler attaches the avatar to32 the existing session in place (``request.session.attach_avatar``), so the33 cookie the client already holds stays valid and no login-time cookie exists —34 handlers stay pure and never set cookies themselves. Armed by ``SessionMixin``; order 400 (OUTSIDE35 ``AuthMiddleware`` at 450, so the session is on the scope before the §5.536 fallback runs), default OFF. The chain only carries ``http`` scopes, so no37 scope filtering happens here.38 """39 40 from __future__ import annotations41 42 from collections.abc import MutableMapping43 from typing import TYPE_CHECKING, Any44 45 from .base import BaseMiddleware, cookie_value46 47 if TYPE_CHECKING:48     from ..types import ASGIApp, Receive, Scope, Send49 50 __all__ = ["SessionMiddleware", "COOKIE_LIFETIME_FACTOR"]51 52 COOKIE_LIFETIME_FACTOR = 24   # cookie Max-Age = session TTL × this (see module doc)53 54 55 class SessionMiddleware(BaseMiddleware):56     """Per-server session middleware: cookie in, session on the scope, cookie out."""57 58     middleware_order = 40059     middleware_default = False60 61     def __init__(62         self,63         app: ASGIApp,64         server: Any,65         cookie_name: str = "session_id",66         secure: bool = False,67         samesite: str = "lax",68         **options: Any,69     ) -> None:70         """Store the cookie configuration; ``server`` supplies ``session_store``."""71         super().__init__(app, server, **options)72         self._cookie_name = cookie_name73         self._secure = secure74         self._samesite = samesite75 76     def _cookie_value(self, scope: Scope) -> str | None:77         """The session cookie value carried by the request, or ``None``."""78         return cookie_value(scope, self._cookie_name)79 80     def get_session(self, scope: Scope) -> Any | None:81         """The session the store holds for this scope's cookie, or ``None``.82 83         Args:84             scope: any scope carrying cookies — an HTTP request, or the85                 handshake of a websocket, which the chain never sees.86 87         Returns:88             The stored session, or ``None`` when no cookie arrived or the89             store has nothing for it.90 91         A pure reading: nothing is created here. The anonymous session of a92         first visit is born in ``__call__``, which is where a cookie can be93         issued for it — a websocket handshake has no such moment to offer.94         """95         incoming = self._cookie_value(scope)96         return self.server.session_store.get(incoming) if incoming else None97 98     def _set_cookie(self, session: Any) -> tuple[bytes, bytes]:99         """Build the ``Set-Cookie`` header tuple for a session to (re)issue to the client.100 101         ``Max-Age`` is the TTL times ``COOKIE_LIFETIME_FACTOR``: the cookie102         must outlive the sliding server-side expiry (module doc).103         """104         parts = [105             f"{self._cookie_name}={session.id}",106             f"Max-Age={session.meta['ttl'] * COOKIE_LIFETIME_FACTOR}",107             "Path=/",108             "HttpOnly",109             f"SameSite={self._samesite.capitalize()}",110         ]111         if self._secure:112             parts.append("Secure")113         return (b"set-cookie", "; ".join(parts).encode("latin-1"))114 115     def _write_back(self, session: Any) -> None:116         """Persist the session at request end, but ONLY when it is dirty.117 118         A read-only request never marks the session dirty (``get`` only refreshes119         ``last_access``), so it stays zero-I/O; a data mutation or a login raised120         the flag, and here it is saved and cleared.121         """122         if session.dirty:123             self.server.session_store.save(session)124             session.clear_dirty()125 126     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:127         """Attach the session to the scope; issue the cookie for a NEW session only.128 129         Login never changes the session id (a handler attaches the avatar to130         the existing session in place), so the only moment a cookie must be131         issued is when the store had no session for the incoming token (none132         arrived, expired, or unknown) and a fresh anonymous one was created133         here. In either path the session is written back at request end when134         dirty (``_write_back``).135         """136         store = self.server.session_store137         session = self.get_session(scope)138         if session is not None:139             scope["session"] = session140             await self.app(scope, receive, send)141             self._write_back(session)142             return143         session = store.create()144         scope["session"] = session145 146         async def send_with_cookie(message: MutableMapping[str, Any]) -> None:147             if message["type"] == "http.response.start":148                 headers = list(message.get("headers", []))149                 headers.append(self._set_cookie(session))150                 message = {**message, "headers": headers}151             await send(message)152 153         await self.app(scope, receive, send_with_cookie)154         self._write_back(session)