Skip to content

src/genro_asgi/applications/server_app.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 """ServerApplication: the automatic ``_server`` system app (D4).16 17 ``ServerApplication`` is the server's own application — the system surface18 every server exposes under ``/_server`` without configuring it (D4:19 "automatic, not configured"). ``AsgiServer`` mounts one at the end of its20 ``__init__`` (``_register_server_app``), so a hand-built21 ``AsgiServer(applications=[...])`` gets it exactly like a configured one; no22 configuration path special-cases it. The demux finds it through the ordinary23 mount table — there is no dedicated demux logic.24 25 It extends ``OpenApiApplication`` (REST + OpenAPI; the MCP face on26 ``_server`` is out of this wave), so ``/_server/_meta/`` carries the usual27 schema/docs/index endpoints, and adds:28 29 - ``index`` — the ``/_server/`` descriptor: title and the attached section30   names (JSON — no HTML in code);31 - ``sections`` / ``attach_section(section, name)`` — the registry of system32   sections: ``attach_section`` links a ``RoutingClass`` under ``name``33   (endpoints at ``/_server/<name>/...``) and records it so introspection34   surfaces (the index today, monitors later) can enumerate them;35 - the PASSWORD login surface (core 1d wave 1): ``login`` (JSON POST →36   ``UserStore.verify`` → ``Avatar`` → ``request.session.attach_avatar``),37   ``login_page`` (HTML GET, the descriptor-driven ``resources/login.html``38   read at USE time), ``logout`` and the public ``login_methods`` — dual-mode39   by TWO routes, never in-handler ``Accept`` sniffing. The methods live in an40   ``AuthSection`` attached under ``auth`` (``ensure_auth_section`` /41   ``register_auth_method``); ``PasswordMethod`` is registered at construction.42   ``login`` enforces the store-backed lockout (REVIEW #9): the per-identity43   failure counter (``failed_attempts``/``last_failed_at``) rides the UserStore44   record with exponential backoff; the policy comes from the config's45   ``authentication.login`` element (``login_policy``, defaults 5 attempts /46   30s base).47 48 Handlers stay PURE: they return values and never touch cookies or an ambient49 request/response (the old ``self.server.request`` idiom must never be50 reintroduced). Login attaches the avatar to the existing session in place —51 the id never changes, so no login-time cookie exists. A handler that needs the52 live request DECLARES an UNANNOTATED ``_request`` parameter: ``bind_kwargs``53 injects the per-dispatch ``Request`` for that name — the same declarative54 convention ``body_data`` follows — and the handler reaches the server through55 ``_request.server``. Leaving it unannotated keeps it out of the pydantic model56 (and thus the public OpenAPI schema); the pydantic wrapper, seeing no type hint,57 passes it straight through instead of routing it into validation. The ``_``58 prefix is the injected-name convention ``bind_kwargs`` matches in the neutral59 ``fields`` block. ``pydantic`` and ``openapi`` are fixed server structure (armed60 on every router by ``PluginMixin``), so the handler signatures are always61 captured and per-entry OpenAPI controls (``openapi_method``) always take effect.62 63 The future internal server (a D8 orchestration concern) is a SUBCLASS that64 overrides what it needs — not a profile flag on this class: no code exists for65 a consumer that does not exist yet.66 67 Identity: ``code`` and ``mount`` are both declared ``"_server"`` as class68 attributes — the system mount is a D4 invariant, and three cross-file69 references hardcode ``/_server/...`` (``PasswordMethod``'s ``action``,70 ``LOGIN_PAGE_URL``, ``login.html``'s fetch), so moving this app elsewhere71 404s them.72 73 Kwargs peeled by the cooperative ``__init__`` (D16): ``login`` and ``oidc`` are74 the login-surface values of the configuration's ``authentication`` section (the75 ``server_app=`` server kwarg, forwarded by ``_register_server_app``): the lockout76 policy dict and the per-``code`` OIDC provider dicts, stored as77 ``login_policy``/``oidc_providers`` (consumed by the lockout check and the78 ``OidcMethod`` registration). The rest flows down the chain. A hand-built79 ``AsgiServer(applications=[...])`` passes nothing, so the defaults (empty dicts)80 keep today's bare app.81 """82 83 from __future__ import annotations84 85 import time86 from typing import TYPE_CHECKING, Any, ClassVar87 88 from genro_routes import RoutingClass, route89 90 from ..auth import AuthMethod, OidcMethod, PasswordMethod91 from ..session import Avatar92 from .openapi import RESOURCES_DIR, OpenApiApplication93 from .server_sections import (94     AuthSection,95     MonitorSection,96     TasksSection,97     TokensSection,98     UsersSection,99 )100 101 if TYPE_CHECKING:102 103     pass104 105 __all__ = ["ServerApplication"]106 107 LOCKOUT_MAX_ATTEMPTS = 5108 LOCKOUT_BACKOFF_SECONDS = 30.0109 110 111 class ServerApplication(OpenApiApplication):112     """System endpoints of a server, auto-mounted under ``/_server`` (D4).113 114     Carries the public server's system surface: the password login surface and115     the sections attached through ``attach_section``, listed by the ``index``116     descriptor. The future internal server (a D8 orchestration concern) will be117     a SUBCLASS overriding what it needs — not a profile flag on this class.118     """119 120     openapi_info: ClassVar[dict[str, Any]] = {121         "title": "genro-asgi server endpoints",122         "version": "1.0.0",123     }124     code = "_server"125     mount = "_server"126 127     def __init__(self, **kwargs: Any) -> None:128         self._login_policy: dict[str, Any] = kwargs.pop("login", {})129         self._oidc_providers: dict[str, dict[str, Any]] = kwargs.pop("oidc", {})130         self._sections: dict[str, RoutingClass] = {}131         self._auth_section: AuthSection | None = None132         super().__init__(**kwargs)133         self.register_auth_method(PasswordMethod(self, "password"))134         for code, provider in self.oidc_providers.items():135             method_id = self._oidc_method_id(code)136             self.register_auth_method(OidcMethod(self, method_id, code, provider))137         self.attach_section(UsersSection(self), name="users")138         self.attach_section(TokensSection(self), name="tokens")139         self.attach_section(TasksSection(self), name="tasks")140         self.attach_section(MonitorSection(self), name="monitor")141 142     @staticmethod143     def _oidc_method_id(code: str) -> str:144         """The mount name of the OIDC method for ``code`` under ``_server/auth``.145 146         The colon form is legal on the router and through the server demux (a147         boot-time verification): if a future router rejected it, the fallback is148         the single-line change ``f"oidc_{code}"``.149         """150         return f"oidc:{code}"151 152     @property153     def login_policy(self) -> dict[str, Any]:154         """The lockout policy from ``authentication.login`` (may be empty)."""155         return self._login_policy156 157     @property158     def oidc_providers(self) -> dict[str, dict[str, Any]]:159         """OIDC provider configs from the ``oidc()`` elements, keyed by code."""160         return self._oidc_providers161 162     @property163     def sections(self) -> dict[str, RoutingClass]:164         """Attached system sections keyed by their mount segment (may be empty)."""165         return self._sections166 167     @property168     def auth_section(self) -> AuthSection | None:169         """The ``auth`` section carrying the login methods, or ``None``."""170         return self._auth_section171 172     def attach_section(self, section: RoutingClass, name: str) -> None:173         """Attach ``section`` under ``name`` and record it in ``sections``.174 175         Links the section's router into this app (endpoints at176         ``/_server/<name>/...``) and keeps it enumerable for the177         introspection surfaces (the ``index`` descriptor today).178         """179         self.route.add_branches({"name": name, "instance": section})180         self.sections[name] = section181 182     def ensure_auth_section(self) -> AuthSection:183         """The ``auth`` section, attached under ``auth`` on first use."""184         if self._auth_section is None:185             section = AuthSection(self)186             self.attach_section(section, name="auth")187             self._auth_section = section188         return self._auth_section189 190     def register_auth_method(self, method: AuthMethod) -> None:191         """Register a login method in the ``auth`` section (created on demand)."""192         self.ensure_auth_section().register(method)193 194     def _lock_seconds_remaining(self, record: dict[str, Any]) -> float:195         """Seconds left in ``record``'s lockout window, ``0.0`` when not locked.196 197         The window opens after ``max_attempts`` consecutive failures and lasts198         ``backoff * 2**(failed_attempts - max_attempts)`` seconds from the last199         failure — exponential backoff, tuned by the config's ``login()`` policy200         (``login_policy``; defaults 5 attempts / 30s base).201         """202         policy = self.login_policy203         failed = record.get("failed_attempts", 0)204         max_attempts = policy.get("max_attempts", LOCKOUT_MAX_ATTEMPTS)205         if failed < max_attempts:206             return 0.0207         backoff = policy.get("backoff", LOCKOUT_BACKOFF_SECONDS)208         window = backoff * 2 ** (failed - max_attempts)209         return max(0.0, record.get("last_failed_at", 0.0) + window - time.time())210 211     @route()212     def index(self) -> dict[str, Any]:213         """The ``/_server/`` descriptor: title and section names."""214         return {215             "title": self.api_info.get("title", type(self).__name__),216             "sections": sorted(self.sections),217         }218 219     @route(media_type="application/json", openapi_method="post")220     def login(self, identity: str = "", password: str = "", _request=None) -> dict[str, Any]:221         """Authenticate against the server's UserStore and attach the identity.222 223         The JSON convergence point of every ``form`` method: verifies the224         credentials (``UserStore.verify`` — the record key is ``identity``),225         builds the ``Avatar`` and attaches it to the request's session in place226         (``_request.session.attach_avatar``) — the session id never changes at227         login, so the client's cookie stays valid and no ``Set-Cookie`` is228         involved. The server's ``user_store`` is wired in the next wave (Macro229         5b): until then a server without one answers the error shape.230 231         The ``next`` return path is NOT a login parameter: the challenge232         redirects to ``login_page?next=...`` and the page script owns the233         post-success redirect — ``login`` itself never sees it and posts carry234         only the credentials.235 236         Enforces the server-side lockout (REVIEW #9): the failure counter237         lives ON the user's store record (``failed_attempts`` /238         ``last_failed_at``), so it survives restarts and is shared across239         processes on a shared store. After ``max_attempts`` consecutive240         failures the identity is refused until the exponential-backoff window241         (``_lock_seconds_remaining``) has passed; refused attempts never touch242         the counter — an attacker hammering a locked identity cannot extend a243         legitimate user's lock — and a success resets it. Known-identity244         failures surface the server-computed ``remaining_attempts``; unknown245         identities have no record, hence no counter and no such field. Per-IP246         rate limiting is a future middleware concern, not this handler's.247 248         The method is POST by declaration (``openapi_method="post"``): with249         ``_request`` hidden from the schema (see below) the remaining fields250         are all scalar, so the guesser would otherwise pick GET.251 252         Args:253             identity: The record key to verify (NOT the old ``username``).254             password: The password to verify.255             _request: The live ``Request``, injected by ``bind_kwargs``. Left256                 unannotated so it stays out of the pydantic model — and thus out257                 of the public OpenAPI request body — while the ``_`` prefix is258                 the injected-name convention ``bind_kwargs`` matches.259 260         Returns:261             ``{session_id, identity, tags}`` on success; ``{"error": ...}`` on262             missing/invalid credentials, active lockout, or when no user store263             is wired — with ``remaining_attempts`` when the identity has a264             record.265 266         Note:267             Route: POST /_server/login268         """269         if not identity or not password:270             return {"error": "Identity and password are required"}271         user_store = getattr(_request.server, "user_store", None)272         if user_store is None:273             return {"error": "Login is not available"}274         record = user_store.get(identity)275         if record is not None and self._lock_seconds_remaining(record) > 0:276             return {"error": "Too many failed attempts"}277         verified = user_store.verify(identity, password)278         if verified is None:279             if record is None:280                 return {"error": "Invalid credentials"}281             record["failed_attempts"] = record.get("failed_attempts", 0) + 1282             record["last_failed_at"] = time.time()283             user_store.save(record)284             max_attempts = self.login_policy.get("max_attempts", LOCKOUT_MAX_ATTEMPTS)285             remaining = max(0, max_attempts - record["failed_attempts"])286             return {"error": "Invalid credentials", "remaining_attempts": remaining}287         if verified.get("failed_attempts"):288             verified["failed_attempts"] = 0289             verified["last_failed_at"] = 0.0290             user_store.save(verified)291         avatar = Avatar(verified["identity"], verified["tags"])292         session = _request.session293         session.attach_avatar(avatar)294         return {"session_id": session.id, "identity": avatar.identity, "tags": avatar.tags}295 296     @route(media_type="text/html")297     def login_page(self, next: str = "") -> str:298         """Serve the descriptor-driven HTML login page (GET, dual-mode twin of ``login``).299 300         The page builds itself from ``login_methods`` and posts credentials to301         the method's ``action`` (``/_server/login``). Read at USE time so a302         template swap needs no re-import. ``next`` is accepted so the challenge303         redirect's query binds; the page script consumes it client-side.304 305         Note:306             Route: GET /_server/login_page307         """308         return (RESOURCES_DIR / "login.html").read_text()309 310     @route(media_type="application/json")311     def logout(self, session_id: str = "") -> dict[str, Any]:312         """Destroy a session.313 314         Deletes the session from the store. No error if the session is unknown.315 316         Args:317             session_id: Session token to invalidate.318 319         Returns:320             ``{"status": "ok"}`` (always succeeds).321 322         Note:323             Route: POST /_server/logout324         """325         if session_id:326             self.server.session_store.delete(session_id)327         return {"status": "ok"}328 329     @route(media_type="application/json")330     def login_methods(self) -> dict[str, Any]:331         """Public descriptors of the active auth methods (NO ``auth_rule``).332 333         The login page builds itself from this: register a method, its334         descriptor (and therefore its button/form) appears. Deliberately public335         — a caller must see the methods before it can authenticate. Empty list336         when no login surface is active.337 338         Returns:339             ``{"methods": [descriptor, ...]}`` in registration order.340 341         Note:342             Route: GET /_server/login_methods343         """344         section = self.auth_section345         return {"methods": section.descriptors() if section is not None else []}