Source code for genro_asgi.auth.auth_method

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Auth methods — self-describing login mechanisms mounted under ``_server/auth``.

An auth method is one way a user proves who they are: a password form today,
an OIDC redirect to an external provider in the next wave. Each method is a
``RoutingClass``; the ``AuthSection`` attaches it under
``_server/auth/<method_id>`` ONLY when it owns routes (OIDC will own
``start`` + ``callback``). The password method owns none — it adapts the
existing ``/_server/login`` — so it is never attached: it lives only in the
section's registry (Invariant 10: zero-route nodes never enter the routing
tree). The method is also self-describing: ``descriptor()`` is the small dict
the login page renders it from (an id, a ``kind``, a label, the entry URL for
redirect methods).

The contract::

    AuthMethod (RoutingClass):
        method_id: str          # "password", "oidc:google", ...
        kind: str               # "form" | "redirect" | "ceremony"
        descriptor() -> dict    # what the login page renders it from

``kind`` tells the page how to draw the method: ``form`` renders the
identity/password form (the password method), ``redirect`` renders one button
navigating to the method's entry URL (OIDC), ``ceremony`` is a client-driven
exchange (passkey, a later wave). Every method converges on the SAME success
outcome: the avatar attached to the request's session through
``request.session.attach_avatar`` — the session id never changes at login, so
no cookie is involved and no method or handler ever sets one.

``safe_next_path`` is the shared open-redirect guard for the login ``next``
parameter: the login page mirrors it client-side today; the challenge
redirect (ErrorMiddleware, next phase) will apply it server-side, so one
rule governs every path.
"""

from __future__ import annotations

from typing import Any

from genro_routes import RoutingClass

__all__ = ["AuthMethod", "PasswordMethod", "safe_next_path"]


[docs] def safe_next_path(value: str | None, default: str = "/") -> str: """Return a login ``next`` target that is safe to redirect to, or ``default``. The login flow carries a ``next`` parameter through the challenge and back; an attacker who can shape it into an off-site URL turns the login page into an open redirect. The single guard keeps only a same-origin relative path: it must start with a single ``/`` and must not be a scheme-relative URL (``//host``) nor carry a backslash a browser would normalize to ``/``. Anything else (an absolute URL, ``javascript:``, an empty value, a bare path without the leading slash) collapses to ``default``. """ if not value: return default if not value.startswith("/"): return default if value.startswith("//"): return default if "\\" in value: return default return value
[docs] class AuthMethod(RoutingClass): """Base contract for a login method attached under ``_server/auth/<id>``. A concrete method sets ``kind`` and (usually) ``method_id``, overrides ``descriptor()`` to add what its ``kind`` needs (a label, an entry URL), and adds its own routes with ``@route`` when it has any (OIDC does; password does not — a route-less method is recorded in the AuthSection registry but never attached to the routing tree, Invariant 10). The ServerApplication that owns the login surface is reached through ``self.application`` (dual relationship), the AsgiServer through ``self.application.server``. """ #: The method kind: "form" | "redirect" | "ceremony". A concrete method sets it. kind: str = ""
[docs] def __init__(self, application: Any, method_id: str) -> None: """Bind the method to its login surface and fix its id. Args: application: The ServerApplication the login surface belongs to (dual relationship). The AsgiServer is ``application.server``. method_id: The method's identity and its mount name under ``_server/auth`` (e.g. ``"password"``, ``"oidc:google"``). """ self.application = application self._method_id = method_id
@property def method_id(self) -> str: """The method's identity, also its mount name under ``_server/auth``.""" return self._method_id @property def server(self) -> Any: """The AsgiServer, reached through the parent ServerApplication.""" return self.application.server
[docs] def descriptor(self) -> dict[str, Any]: """The dict the login page renders this method from. The base carries the two facts every method has — its ``id`` and its ``kind``; a concrete method adds what its kind needs (a ``label``, a ``url`` for redirect methods) by extending this dict. """ return {"id": self.method_id, "kind": self.kind}
[docs] class PasswordMethod(AuthMethod): """The password method: a thin adapter over ``/_server/login``. Owns no routes and is therefore never attached to the routing tree (Invariant 10) — it lives only in the AuthSection registry. The credential check and session promotion live on the ServerApplication's ``/_server/login`` route (store-backed via the UserStore). This method only declares itself to the login page as a ``form`` so the page renders the identity/password form and posts it there. """ kind = "form"
[docs] def descriptor(self) -> dict[str, Any]: """Descriptor for the password form: adds where the page posts. The page posts credentials to the existing ``/_server/login`` JSON route (``action``); ``label`` names the form for the page. """ return {**super().descriptor(), "label": "Sign in", "action": "/_server/login"}
if __name__ == "__main__": from types import SimpleNamespace application = SimpleNamespace(server="SERVER") method = PasswordMethod(application, "password") assert method.method_id == "password" assert method.kind == "form" assert method.server == "SERVER" assert method.descriptor() == { "id": "password", "kind": "form", "label": "Sign in", "action": "/_server/login", } assert safe_next_path("/app/page") == "/app/page" assert safe_next_path("//evil.example") == "/" assert safe_next_path("https://evil.example") == "/" assert safe_next_path("\\evil") == "/" assert safe_next_path(None) == "/" assert safe_next_path("", default="/home") == "/home"