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)