Coverage for src/lilbee/server/auth.py: 100%
101 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Session token auth middleware with decorator-based read-only marking."""
3from __future__ import annotations
5import hmac
6import json
7import logging
8import secrets
9from collections.abc import Callable
10from pathlib import Path
11from typing import Any, TypeVar
13from litestar.exceptions import NotAuthorizedException
14from litestar.types import ASGIApp, Receive, Scope, Send
16from lilbee.core.config import cfg
17from lilbee.core.security import file_lock_or_warn, harden_private_file, write_private_text
19log = logging.getLogger(__name__)
21_TOKEN_BYTES = 32
23_BOOT_LOCK_TIMEOUT_S = 30
25# Character floor for a persisted token. secrets.token_urlsafe(n) base64url-encodes
26# n bytes, so the entropy count is not a length: reusing _TOKEN_BYTES here accepted
27# tokens roughly a quarter weaker than the ones this module mints.
28_MIN_TOKEN_CHARS = len(secrets.token_urlsafe(_TOKEN_BYTES))
30F = TypeVar("F", bound=Callable[..., Any])
33# Route handlers AuthMiddleware skips, registered at import by the decorator
34# below. A module-level set rather than an attribute on the function object,
35# which mypy cannot see and would put a # type: ignore on every check.
36_SELF_AUTHENTICATING_HANDLERS: set[Callable[..., Any]] = set()
39def auth_checked_in_handler(fn: F) -> F:
40 """Mark a route whose token check runs inside the handler, not in middleware.
42 Not an exemption: the route must still reject an unauthenticated caller
43 itself. Only ``/v1/*`` uses this, to answer a bad token with the OpenAI
44 error envelope instead of Litestar's 401 shape.
45 ``test_every_route_is_authenticated`` holds the line.
47 Must sit *below* the route decorator so it receives the raw function, which
48 is what ``AuthMiddleware`` looks up via ``handler.fn``; stacked the other
49 way the lookup misses. Enforced below.
50 """
51 if hasattr(fn, "fn"):
52 raise TypeError(
53 "@auth_checked_in_handler must be applied below the route decorator, "
54 "so it sees the function rather than the route handler."
55 )
56 _SELF_AUTHENTICATING_HANDLERS.add(fn)
57 return fn
60def authenticates_itself(fn: Callable[..., Any]) -> bool:
61 """True iff *fn* was decorated with :func:`auth_checked_in_handler`."""
62 return fn in _SELF_AUTHENTICATING_HANDLERS
65def server_json_path() -> Path:
66 """Return the path to the server session file."""
67 return cfg.data_dir / "server.json"
70class SessionManager:
71 """Manages the server session token lifecycle.
72 Replaces the old module-level ``_session_token`` global so that auth
73 state is explicit and injectable rather than hidden mutable state.
74 """
76 def __init__(self) -> None:
77 self.token: str | None = None
78 # False until load_or_generate() or disable() runs. validate() fails
79 # closed while unset so an app served without its lifespan (or after
80 # cleanup) never silently accepts unauthenticated mutating requests.
81 self._initialized: bool = False
83 def load_or_generate(self) -> str:
84 """Return the persisted token if shape-valid; generate a new one otherwise.
86 Read and write happen under a file lock so concurrent boots converge on
87 one token: without it both see no file, both mint, and the last write
88 rejects every client that read the other's file.
89 """
90 path = server_json_path()
91 with file_lock_or_warn(path, _BOOT_LOCK_TIMEOUT_S):
92 existing = self._read_persisted_token(path)
93 if existing is not None:
94 # The token is reused indefinitely and the file can arrive
95 # world-readable (backup, older release), so narrow on every load.
96 harden_private_file(path)
97 self.token = existing
98 self._initialized = True
99 return existing
100 self.token = secrets.token_urlsafe(_TOKEN_BYTES)
101 write_private_text(path, json.dumps({"token": self.token}))
102 self._initialized = True
103 return self.token
105 def disable(self) -> None:
106 """Explicitly turn auth off (test harness / embedded read-only use).
108 Distinct from the uninitialized state: validate() accepts any request
109 once disabled, but denies until either this or load_or_generate() runs.
110 """
111 self.token = None
112 self._initialized = True
114 @staticmethod
115 def _read_persisted_token(path: Path) -> str | None:
116 """Return a previously-persisted token if shape-valid, else None.
118 Total by design: every way the file can be unusable returns None so the
119 caller mints a fresh token. A corrupt server.json must never be the
120 reason the server refuses to boot, since nothing would point the user at
121 the file to delete.
122 """
123 try:
124 raw = path.read_text(encoding="utf-8")
125 except OSError:
126 return None
127 except UnicodeDecodeError:
128 # Not an OSError: a truncated write or a file clobbered by another
129 # tool leaves bytes that are not valid UTF-8.
130 return None
131 try:
132 data = json.loads(raw)
133 except json.JSONDecodeError:
134 return None
135 if not isinstance(data, dict):
136 return None
137 token = data.get("token")
138 if not isinstance(token, str) or len(token) < _MIN_TOKEN_CHARS:
139 return None
140 return token
142 def cleanup(self) -> None:
143 """Remove server.json on shutdown and reset to the uninitialized state."""
144 self.token = None
145 self._initialized = False
146 path = server_json_path()
147 try:
148 path.unlink(missing_ok=True)
149 except OSError:
150 # A still-open handle on Windows makes unlink raise; the token is
151 # already invalidated above and the file is rewritten on next boot.
152 log.debug("Could not remove %s at shutdown.", path, exc_info=True)
154 def validate(self, auth_header: str) -> bool:
155 """Check whether *auth_header* carries a valid bearer token.
157 Fails closed until initialized: a request reaching auth before the
158 lifespan ran (or after cleanup) is denied rather than allowed.
159 """
160 if not self._initialized:
161 raise NotAuthorizedException("Server authentication is not initialized")
162 if self.token is None:
163 return True # auth explicitly disabled via disable()
164 return hmac.compare_digest(auth_header, f"Bearer {self.token}")
167# Singleton instance: used by AuthMiddleware and the app lifespan.
168session_manager = SessionManager()
171class AuthMiddleware:
172 """Bearer token auth middleware for mutating endpoints."""
174 def __init__(self, app: ASGIApp) -> None:
175 self.app = app
177 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
178 if scope["type"] != "http":
179 await self.app(scope, receive, send)
180 return
182 method = scope.get("method", "")
183 if method == "OPTIONS":
184 await self.app(scope, receive, send)
185 return
187 handler = scope.get("route_handler")
188 if handler and authenticates_itself(handler.fn):
189 await self.app(scope, receive, send)
190 return
192 headers = dict(scope.get("headers", []))
193 auth_header = headers.get(b"authorization", b"").decode()
194 if session_manager.validate(auth_header):
195 await self.app(scope, receive, send)
196 return
197 raise NotAuthorizedException("Missing or invalid bearer token")