From 5206416bad502df4e6163fbe333befd46cab18a6 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:22:27 +0000 Subject: [PATCH] extract auth into enlace_auth (BREAKING) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth subsystem (auth router, sessions, CSRF, OAuth, per-app access rules, the per-user store, and all related diagnostics) moves to a new sibling package, i2mint/enlace_auth. Hosts opt in by passing the auth plugin to build_backend or by setting ENLACE_PLUGINS=enlace_auth:plugin. Why split: - enlace's mission is composition + discovery + routing. Auth has grown to ~1300 LOC across two subpackages plus diagnostics, env-var checks, and CLI commands. Hosts that don't want auth shouldn't carry it. - The admin dashboard work coming next would have piled another ~150 LOC of router + UI plumbing into the same tree. Better to give auth+admin its own package now. Public API breaks: - AuthConfig, OAuthProviderConfig, StoreBackendConfig, AccessLevel removed from enlace.base. Now in enlace_auth.config. - PlatformConfig.auth: dict[str, Any] (was AuthConfig). - PlatformConfig.stores: dict[str, dict] (was dict[str, StoreBackendConfig]). - AppConfig.access: free-form str (was AccessLevel Literal). Values parse identically; enlace just stops interpreting them. - build_backend(config) no longer wires auth. Use plugins=[enlace_auth.plugin] or set ENLACE_PLUGINS=enlace_auth:plugin. - enlace.compose.EnlaceConfigError no longer raised for bad signing keys — enlace_auth raises EnlaceAuthConfigError now. - CLI auth-* commands removed from `enlace`. Re-added in enlace_auth as `enlace-auth init|generate-signing-key|hash-password|list-sessions| revoke-session`. - enlace.doctor checks for signing-key / shared-passwords / oauth / csrf moved to enlace_auth.diagnostics. run_doctor gained extra_static_checks and extra_http_checks for plugins. What stays in enlace: - AppConfig fields access, allowed_users, shared_password_env (no-op fields enlace_auth reads). - enlace.diagnose's app-source-pattern checks (sub-app auth middleware, unsafe store keys, etc.) — those are app-side, not platform-side. Tests: enlace 78/78, enlace_auth 98/98 green. Version: 0.1.0 (breaking). --- enlace/__init__.py | 16 +- enlace/__main__.py | 137 ---------- enlace/auth/__init__.py | 31 --- enlace/auth/cookies.py | 46 ---- enlace/auth/middleware.py | 380 -------------------------- enlace/auth/oauth.py | 168 ------------ enlace/auth/passwords.py | 40 --- enlace/auth/routes.py | 177 ------------ enlace/auth/sessions.py | 55 ---- enlace/base.py | 79 +++--- enlace/compose.py | 259 +++++------------- enlace/doctor.py | 173 ++---------- enlace/stores/__init__.py | 28 -- enlace/stores/backends.py | 117 -------- enlace/stores/middleware.py | 124 --------- enlace/stores/prefixed.py | 73 ----- enlace/stores/validation.py | 59 ---- enlace/tests/test_auth_failfast.py | 102 ------- enlace/tests/test_auth_middleware.py | 204 -------------- enlace/tests/test_base_auth_config.py | 70 ----- enlace/tests/test_csrf.py | 110 -------- enlace/tests/test_doctor.py | 210 -------------- enlace/tests/test_oauth.py | 96 ------- enlace/tests/test_passwords.py | 29 -- enlace/tests/test_prefixed_store.py | 84 ------ enlace/tests/test_sessions.py | 46 ---- enlace/tests/test_store_middleware.py | 123 --------- pyproject.toml | 16 +- tests/test_auth_e2e.py | 211 -------------- 29 files changed, 127 insertions(+), 3136 deletions(-) delete mode 100644 enlace/auth/__init__.py delete mode 100644 enlace/auth/cookies.py delete mode 100644 enlace/auth/middleware.py delete mode 100644 enlace/auth/oauth.py delete mode 100644 enlace/auth/passwords.py delete mode 100644 enlace/auth/routes.py delete mode 100644 enlace/auth/sessions.py delete mode 100644 enlace/stores/__init__.py delete mode 100644 enlace/stores/backends.py delete mode 100644 enlace/stores/middleware.py delete mode 100644 enlace/stores/prefixed.py delete mode 100644 enlace/stores/validation.py delete mode 100644 enlace/tests/test_auth_failfast.py delete mode 100644 enlace/tests/test_auth_middleware.py delete mode 100644 enlace/tests/test_base_auth_config.py delete mode 100644 enlace/tests/test_csrf.py delete mode 100644 enlace/tests/test_doctor.py delete mode 100644 enlace/tests/test_oauth.py delete mode 100644 enlace/tests/test_passwords.py delete mode 100644 enlace/tests/test_prefixed_store.py delete mode 100644 enlace/tests/test_sessions.py delete mode 100644 enlace/tests/test_store_middleware.py delete mode 100644 tests/test_auth_e2e.py diff --git a/enlace/__init__.py b/enlace/__init__.py index 6af74b6..3fef229 100644 --- a/enlace/__init__.py +++ b/enlace/__init__.py @@ -4,32 +4,32 @@ mounts it, serves it, and optionally gates it behind auth -- with zero boilerplate. """ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _version from pathlib import Path from enlace.base import ( AppConfig, - AuthConfig, ConventionsConfig, - OAuthProviderConfig, PlatformConfig, - StoreBackendConfig, ) -from enlace.compose import EnlaceConfigError, build_backend, create_app +from enlace.compose import EnlaceConfigError, Plugin, build_backend, create_app from enlace.diagnose import DiagnosticReport, Issue, diagnose_app from enlace.discover import ConventionDiscoverer, discover_apps from enlace.serve import serve -__version__ = "0.0.1" +try: + __version__ = _version("enlace") +except PackageNotFoundError: # editable install with no metadata, etc. + __version__ = "0.0.0+local" __all__ = [ "AppConfig", - "AuthConfig", "ConventionsConfig", "DiagnosticReport", "Issue", - "OAuthProviderConfig", "PlatformConfig", - "StoreBackendConfig", + "Plugin", "ConventionDiscoverer", "EnlaceConfigError", "build_backend", diff --git a/enlace/__main__.py b/enlace/__main__.py index f24a65c..a4ea308 100644 --- a/enlace/__main__.py +++ b/enlace/__main__.py @@ -10,9 +10,7 @@ import json as json_module import os -import secrets import sys -from getpass import getpass from pathlib import Path from typing import Optional @@ -178,7 +176,6 @@ def check( config = _build_config(apps_dir, apps_dirs, app_dirs) errors = config.check_conflicts() - errors.extend(_check_auth_env(config)) warnings: list[str] = [] if json: @@ -368,135 +365,6 @@ def doctor( sys.exit(1) -def _check_auth_env(config: PlatformConfig) -> list[str]: - """Return a list of errors for missing auth-related env vars.""" - errors: list[str] = [] - auth = config.auth - if not auth.enabled: - return errors - - if not os.environ.get(auth.signing_key_env): - errors.append( - f"Missing env var {auth.signing_key_env} (required when auth.enabled). " - "Run `enlace auth-generate-signing-key` to create one." - ) - - for app in config.apps: - if app.access == "protected:shared": - if not app.shared_password_env: - errors.append( - f"App '{app.name}' uses access='protected:shared' but has " - "no shared_password_env set in its config." - ) - elif not os.environ.get(app.shared_password_env): - errors.append( - f"App '{app.name}': env var {app.shared_password_env} is unset. " - "Hash a password with `enlace auth-hash-password` and export it." - ) - - for name, provider in auth.oauth.items(): - if not os.environ.get(provider.client_id_env): - errors.append( - f"OAuth provider '{name}': env var {provider.client_id_env} is unset." - ) - if not os.environ.get(provider.client_secret_env): - errors.append( - f"OAuth provider '{name}': env var " - f"{provider.client_secret_env} is unset." - ) - - return errors - - -def auth_init(): - """Print a starter ``[auth]`` block for platform.toml.""" - print( - "# Copy into your platform.toml and edit as needed.\n" - "[auth]\n" - "enabled = true\n" - 'session_cookie_name = "enlace_session"\n' - "session_max_age_seconds = 86400\n" - 'signing_key_env = "ENLACE_SIGNING_KEY"\n' - "secure_cookies = true\n" - "\n" - "[auth.stores]\n" - 'backend = "file"\n' - 'path = "~/.enlace/platform_store"\n' - "\n" - "[stores.user_data]\n" - 'backend = "file"\n' - 'path = "~/.enlace/user_data"\n' - ) - - -def auth_generate_signing_key(): - """Print a URL-safe 32-byte signing key suitable for ENLACE_SIGNING_KEY.""" - print(secrets.token_urlsafe(32)) - - -def auth_hash_password(): - """Prompt for a password and print its argon2id hash.""" - try: - from enlace.auth import hash_password - except ImportError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(2) - pw = getpass("Password: ") - confirm = getpass("Confirm: ") - if pw != confirm: - print("Passwords did not match.", file=sys.stderr) - sys.exit(1) - print(hash_password(pw)) - - -def _load_session_store(): - """Build a SessionStore pointing at the configured platform store.""" - from enlace.auth import SessionStore - from enlace.stores import make_file_store_factory - - config = PlatformConfig.from_toml() - factory = make_file_store_factory(config.auth.stores.path) - return SessionStore(factory("sessions")) - - -def auth_list_sessions(*, json: bool = False): - """List active sessions from the platform store. - - Args: - json: Output as JSON. - """ - sessions = _load_session_store().list_all() - if json: - print( - json_module.dumps( - [{"session_id": sid, **info} for sid, info in sessions], indent=2 - ) - ) - return - if not sessions: - print("No active sessions.") - return - for sid, info in sessions: - user = info.get("user_id") or "?" - email = info.get("email") or "-" - created = info.get("created_at") or 0 - print(f"{sid} user={user} email={email} created_at={created:.0f}") - - -def auth_revoke_session(session_id: str): - """Delete a session by id. - - Args: - session_id: The session id to revoke. - """ - ok = _load_session_store().delete(session_id) - if ok: - print(f"Revoked {session_id}") - else: - print(f"No session named {session_id}", file=sys.stderr) - sys.exit(1) - - def main(): argh.dispatch_commands( [ @@ -506,11 +374,6 @@ def main(): list_apps, diagnose, doctor, - auth_init, - auth_generate_signing_key, - auth_hash_password, - auth_list_sessions, - auth_revoke_session, ] ) diff --git a/enlace/auth/__init__.py b/enlace/auth/__init__.py deleted file mode 100644 index d6adc97..0000000 --- a/enlace/auth/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Authentication subsystem for enlace. - -Apps never import from here. The contract exposed to mounted apps is just -``request.state.user_id`` and (optionally) ``request.state.user_email``. - -Public helpers: - -- ``PlatformAuthMiddleware`` — pure-ASGI auth middleware. -- ``CSRFMiddleware`` — signed double-submit CSRF. -- ``SessionStore`` — MutableMapping-backed session storage. -- ``hash_password`` / ``verify_password`` — argon2id helpers. -- ``make_auth_router`` — FastAPI router for ``/auth/*`` endpoints. -""" - -from enlace.auth.cookies import sign_cookie, verify_cookie -from enlace.auth.middleware import AccessRule, CSRFMiddleware, PlatformAuthMiddleware -from enlace.auth.passwords import hash_password, verify_password -from enlace.auth.routes import make_auth_router -from enlace.auth.sessions import SessionStore - -__all__ = [ - "AccessRule", - "CSRFMiddleware", - "PlatformAuthMiddleware", - "SessionStore", - "hash_password", - "make_auth_router", - "sign_cookie", - "verify_cookie", - "verify_password", -] diff --git a/enlace/auth/cookies.py b/enlace/auth/cookies.py deleted file mode 100644 index 4b3a996..0000000 --- a/enlace/auth/cookies.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Signed cookie helpers built on itsdangerous. - -We wrap ``URLSafeTimedSerializer`` so signing_key rotation and ``max_age`` checks -are centralised; callers never touch itsdangerous directly. -""" - -from __future__ import annotations - -from typing import Optional - - -def _serializer(signing_key: str, salt: str = "enlace-cookie"): - try: - from itsdangerous import URLSafeTimedSerializer # type: ignore - except ImportError as e: - raise ImportError( - "itsdangerous is required for signed cookies. " - "Install it via `pip install enlace[auth]`." - ) from e - return URLSafeTimedSerializer(signing_key, salt=salt) - - -def sign_cookie(value: str, signing_key: str, *, salt: str = "enlace-cookie") -> str: - """Return a signed, URL-safe token carrying ``value``.""" - return _serializer(signing_key, salt=salt).dumps(value) - - -def verify_cookie( - token: str, - signing_key: str, - *, - max_age: Optional[int] = None, - salt: str = "enlace-cookie", -) -> Optional[str]: - """Return the original value iff the token is valid and unexpired.""" - try: - from itsdangerous import BadSignature, SignatureExpired # type: ignore - except ImportError: - return None - ser = _serializer(signing_key, salt=salt) - try: - return ser.loads(token, max_age=max_age) - except (BadSignature, SignatureExpired): - return None - except Exception: - return None diff --git a/enlace/auth/middleware.py b/enlace/auth/middleware.py deleted file mode 100644 index fdb0f3c..0000000 --- a/enlace/auth/middleware.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Platform auth middleware (pure ASGI). - -Runs before any mounted sub-app. Responsibilities: - -1. Normalize the request path and reject known traversal bypasses. -2. Strip client-provided identity headers so apps can't be fooled by spoofed - ``X-User-ID`` / ``X-Forwarded-User`` / similar. -3. Resolve the access level for the request by longest-prefix match on the - route prefix of each mounted app. Deny-by-default: an unmatched path is - treated as ``protected:user``. -4. For ``public`` / ``local``, pass through with ``user_id=None``. -5. For ``protected:shared``, look for a per-app signed cookie; on failure - return 401. -6. For ``protected:user``, look for the platform session cookie, load the - session from ``SessionStore``, set ``user_id`` / ``user_email``; on - failure return 401. - -Design notes: -- Pure ASGI three-callable pattern. Never ``BaseHTTPMiddleware`` (see - CLAUDE.md). -- Exempts ``/auth/*`` from auth checks so login/register pages stay reachable. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Iterable, Optional -from urllib.parse import unquote - -from enlace.auth.cookies import verify_cookie -from enlace.auth.sessions import SessionStore - -# Headers clients could try to spoof identity with. Always stripped inbound. -_IDENTITY_HEADERS = ( - b"x-user-id", - b"x-user-email", - b"x-forwarded-user", - b"x-forwarded-email", - b"x-remote-user", - b"x-remote-email", - b"x-auth-user", -) - -# Traversal markers that should never appear in a normalized path. We reject -# both raw and percent-encoded forms without touching the rest of the path. -_TRAVERSAL_MARKERS = ("..", "\\", "\x00") -_ENCODED_TRAVERSAL = ("%2e%2e", "%2f%2f", "%5c", "%00") - - -@dataclass(frozen=True) -class AccessRule: - """Auth policy for a single mount prefix.""" - - prefix: str # e.g. "/api/my_app" or "/my_app" - level: str # "public" | "protected:shared" | "protected:user" | "local" - app_id: str - shared_password_hash: Optional[str] = None - # Optional user whitelist (tuple for hashability). When non-empty, only - # sessions whose email is in this set can access the app. Evaluated after - # the standard session check, so it's a second gate on top of level. - allowed_users: tuple[str, ...] = () - - -def _normalize_path(raw_path: str) -> Optional[str]: - """Return a normalized path or None if the path contains a traversal marker. - - "Normalized" here means collapsed ``//`` and decoded once. We don't try to - canonicalize `.` vs `./` or case; the goal is to reject known attacks, not - to rewrite every input. - """ - if not raw_path: - return "/" - lowered = raw_path.lower() - for m in _ENCODED_TRAVERSAL: - if m in lowered: - return None - decoded = unquote(raw_path) - for m in _TRAVERSAL_MARKERS: - if m in decoded: - return None - while "//" in decoded: - decoded = decoded.replace("//", "/") - return decoded - - -def _strip_identity_headers( - headers: list[tuple[bytes, bytes]], -) -> list[tuple[bytes, bytes]]: - return [(k, v) for (k, v) in headers if k.lower() not in _IDENTITY_HEADERS] - - -def _parse_cookies(header_value: bytes) -> dict[str, str]: - """Light RFC-6265 cookie parser that only returns the first occurrence per name.""" - out: dict[str, str] = {} - for part in header_value.decode("latin-1").split(";"): - part = part.strip() - if not part or "=" not in part: - continue - name, _, value = part.partition("=") - name = name.strip() - if name not in out: - out[name] = value.strip() - return out - - -def _get_cookies(scope) -> dict[str, str]: - for k, v in scope.get("headers", []): - if k.lower() == b"cookie": - return _parse_cookies(v) - return {} - - -def _longest_prefix(path: str, rules: Iterable[AccessRule]) -> Optional[AccessRule]: - best: Optional[AccessRule] = None - best_len = -1 - for r in rules: - if path == r.prefix or path.startswith(r.prefix.rstrip("/") + "/"): - if len(r.prefix) > best_len: - best = r - best_len = len(r.prefix) - return best - - -async def _send_json_response(send, status: int, body: dict): - import json as _json - - data = _json.dumps(body).encode("utf-8") - await send( - { - "type": "http.response.start", - "status": status, - "headers": [(b"content-type", b"application/json")], - } - ) - await send({"type": "http.response.body", "body": data}) - - -async def _reject_websocket(send): - await send({"type": "websocket.close", "code": 1008}) - - -class PlatformAuthMiddleware: - """Pure-ASGI auth middleware. See module docstring for behavior.""" - - def __init__( - self, - app, - *, - access_rules: Iterable[AccessRule], - session_store: SessionStore, - signing_key: str, - cookie_name: str = "enlace_session", - max_age: int = 86400, - auth_path_prefix: str = "/auth", - ): - self.app = app - self._rules = list(access_rules) - self._sessions = session_store - self._signing_key = signing_key - self._cookie = cookie_name - self._max_age = max_age - self._auth_prefix = auth_path_prefix - - async def __call__(self, scope, receive, send): - if scope["type"] not in ("http", "websocket"): - await self.app(scope, receive, send) - return - - raw_path = scope.get("path", "/") - normalized = _normalize_path(raw_path) - if normalized is None: - if scope["type"] == "websocket": - await _reject_websocket(send) - else: - await _send_json_response(send, 400, {"detail": "Bad request path"}) - return - - scope["headers"] = _strip_identity_headers(scope.get("headers", [])) - state = scope.setdefault("state", {}) - state.setdefault("user_id", None) - state.setdefault("user_email", None) - - # /auth/* endpoints bypass auth so login / register / logout stay reachable. - if ( - normalized.startswith(self._auth_prefix + "/") - or normalized == self._auth_prefix - ): - await self.app(scope, receive, send) - return - - # Resolve auth rule for this path. - rule = _longest_prefix(normalized, self._rules) - level = rule.level if rule is not None else "protected:user" - if rule is not None: - state["app_id"] = rule.app_id - - cookies = _get_cookies(scope) - - if level in ("public", "local"): - # Public paths still opportunistically populate user_id if the - # session cookie is valid, so downstream handlers (e.g. /_apps) - # can filter by access level for authenticated users. - token = cookies.get(self._cookie) - if token: - session_id = verify_cookie( - token, self._signing_key, max_age=self._max_age, salt="session" - ) - session = self._sessions.get(session_id) if session_id else None - if session is not None: - state["user_id"] = session.get("user_id") - state["user_email"] = session.get("email") - await self.app(scope, receive, send) - return - - if level == "protected:shared": - app_id = rule.app_id if rule is not None else "" - name = f"shared_auth_{app_id}" - token = cookies.get(name) - if ( - not token - or verify_cookie( - token, - self._signing_key, - max_age=self._max_age, - salt=f"shared:{app_id}", - ) - is None - ): - return await self._deny(scope, send, "shared") - state["user_id"] = "shared" - await self.app(scope, receive, send) - return - - if level == "protected:user": - token = cookies.get(self._cookie) - session_id = None - if token: - session_id = verify_cookie( - token, self._signing_key, max_age=self._max_age, salt="session" - ) - session = self._sessions.get(session_id) if session_id else None - if session is None: - return await self._deny(scope, send, "user") - state["user_id"] = session.get("user_id") - state["user_email"] = session.get("email") - # Optional per-app user whitelist. - if rule is not None and rule.allowed_users: - who = state.get("user_email") or state.get("user_id") - if who not in rule.allowed_users: - return await self._deny(scope, send, "forbidden") - await self.app(scope, receive, send) - return - - # Unknown level — deny by default. - await self._deny(scope, send, "unknown") - - async def _deny(self, scope, send, kind: str): - if scope["type"] == "websocket": - await _reject_websocket(send) - return - await _send_json_response( - send, 401, {"detail": "Not authenticated", "auth": kind} - ) - - -# --------------------------------------------------------------------------- -# CSRF middleware -# --------------------------------------------------------------------------- - - -_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - - -class CSRFMiddleware: - """Signed double-submit CSRF for state-changing requests. - - On safe-method requests, sets an ``enlace_csrf`` cookie if one isn't - present. On state-changing requests, requires the cookie and an - ``X-CSRF-Token`` header to match after signature verification. - - Exempt paths skip the check entirely. Defaults exempt sub-app APIs - under ``/api/`` because the ``enlace_session`` cookie is ``SameSite=Lax``: - a cross-site POST from an attacker site arrives without credentials and - is rejected by PlatformAuthMiddleware regardless of CSRF. This keeps - pre-enlace apps working out of the box without each having to implement - the ``/auth/csrf`` double-submit flow. The auth endpoints themselves - (``/auth/login``, ``/auth/register``, ``/auth/logout``) stay protected. - """ - - def __init__( - self, - app, - *, - signing_key: str, - cookie_name: str = "enlace_csrf", - header_name: str = "X-CSRF-Token", - exempt_prefixes: Iterable[str] = ( - "/auth/callback", - "/auth/login/", - "/api/", - ), - ): - self.app = app - self._signing_key = signing_key - self._cookie = cookie_name - self._header = header_name.lower().encode("latin-1") - self._exempt = tuple(exempt_prefixes) - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - - method = scope.get("method", "GET").upper() - path = scope.get("path", "/") - - # Exempt paths skip the check entirely. - is_exempt = any(path.startswith(p) for p in self._exempt) - - cookies = _get_cookies(scope) - existing = cookies.get(self._cookie) - existing_value = ( - verify_cookie(existing, self._signing_key, salt="csrf") - if existing - else None - ) - - if method in _SAFE_METHODS or is_exempt: - # Ensure a token is present on safe requests. - if existing_value is None: - await self._send_with_csrf_cookie(scope, receive, send) - return - await self.app(scope, receive, send) - return - - # State-changing request — enforce double-submit. - header_value = None - for k, v in scope.get("headers", []): - if k.lower() == self._header: - header_value = v.decode("latin-1") - break - - if ( - existing_value is None - or header_value is None - or header_value != existing_value - ): - await _send_json_response(send, 403, {"detail": "CSRF check failed"}) - return - - await self.app(scope, receive, send) - - async def _send_with_csrf_cookie(self, scope, receive, send): - """Wrap the downstream response to inject a Set-Cookie for CSRF.""" - import secrets - - from enlace.auth.cookies import sign_cookie - - new_value = secrets.token_urlsafe(32) - signed = sign_cookie(new_value, self._signing_key, salt="csrf") - cookie_header = (f"{self._cookie}={signed}; Path=/; SameSite=Lax").encode( - "latin-1" - ) - - # Expose the minted unsigned value to downstream handlers (e.g. - # /auth/csrf) so they can return it in the body without minting a - # second token — otherwise two Set-Cookie headers race and the - # body-vs-cookie values disagree. - state = scope.setdefault("state", {}) - state["csrf_token"] = new_value - - async def wrapped_send(message): - if message["type"] == "http.response.start": - message = dict(message) - headers = list(message.get("headers", [])) - headers.append((b"set-cookie", cookie_header)) - message["headers"] = headers - await send(message) - - await self.app(scope, receive, wrapped_send) diff --git a/enlace/auth/oauth.py b/enlace/auth/oauth.py deleted file mode 100644 index 3a33c82..0000000 --- a/enlace/auth/oauth.py +++ /dev/null @@ -1,168 +0,0 @@ -"""OAuth2/OIDC login via Authlib. - -Lazy import — ``authlib`` lives behind the ``enlace[oauth]`` extra. Providers -are configured in ``platform.toml`` under ``[auth.oauth.{name}]`` with -``client_id_env`` / ``client_secret_env`` pointing at env vars (secrets never -in TOML). On callback we create a local session — the upstream tokens are -discarded because we use OAuth for identity only, not API access. - -Built-in provider presets for Google and GitHub auto-fill the well-known -endpoints; other providers need explicit URLs in the config. -""" - -from __future__ import annotations - -import os -import time -from typing import Any, Optional - -from fastapi import APIRouter, HTTPException, Request, Response - -from enlace.auth.cookies import sign_cookie -from enlace.auth.sessions import SessionStore -from enlace.base import OAuthProviderConfig - -_PROVIDER_PRESETS = { - "google": { - "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration", - "scopes": ["openid", "profile", "email"], - }, - "github": { - "authorize_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "userinfo_url": "https://api.github.com/user", - "scopes": ["read:user", "user:email"], - }, -} - - -def _import_authlib(): - try: - from authlib.integrations.starlette_client import OAuth # type: ignore - except ImportError as e: - raise ImportError( - "authlib is required for OAuth. Install via `pip install enlace[oauth]`." - ) from e - return OAuth - - -def _build_oauth_registry(providers: dict[str, OAuthProviderConfig]): - OAuth = _import_authlib() - oauth = OAuth() - for name, cfg in providers.items(): - preset = _PROVIDER_PRESETS.get(name, {}) - client_id = os.environ.get(cfg.client_id_env) - client_secret = os.environ.get(cfg.client_secret_env) - if not client_id or not client_secret: - # Skip providers whose env vars aren't set; `enlace check` surfaces this. - continue - kwargs: dict[str, Any] = { - "name": name, - "client_id": client_id, - "client_secret": client_secret, - "client_kwargs": { - "scope": " ".join(cfg.scopes or preset.get("scopes", [])), - }, - } - smu = cfg.server_metadata_url or preset.get("server_metadata_url") - if smu: - kwargs["server_metadata_url"] = smu - else: - if cfg.authorize_url or preset.get("authorize_url"): - kwargs["authorize_url"] = cfg.authorize_url or preset.get( - "authorize_url" - ) - if cfg.token_url or preset.get("token_url"): - kwargs["access_token_url"] = cfg.token_url or preset.get("token_url") - if cfg.userinfo_url or preset.get("userinfo_url"): - kwargs["userinfo_endpoint"] = cfg.userinfo_url or preset.get( - "userinfo_url" - ) - oauth.register(**kwargs) - return oauth - - -def make_oauth_router( - *, - providers: dict[str, OAuthProviderConfig], - session_store: SessionStore, - user_store, # MutableMapping[email -> {...}] - signing_key: str, - cookie_name: str = "enlace_session", - session_max_age: int = 86400, - secure_cookies: bool = True, -) -> Optional[APIRouter]: - """Build an OAuth router or return None if no providers are configured.""" - if not providers: - return None - - oauth = _build_oauth_registry(providers) - router = APIRouter(prefix="/auth") - - def _set_session_cookie(response: Response, session_id: str): - signed = sign_cookie(session_id, signing_key, salt="session") - attrs = [ - f"{cookie_name}={signed}", - "Path=/", - "HttpOnly", - f"Max-Age={session_max_age}", - "SameSite=Lax", - ] - if secure_cookies: - attrs.append("Secure") - response.headers.append("set-cookie", "; ".join(attrs)) - - @router.get("/login/{provider}") - async def login(provider: str, request: Request): - client = getattr(oauth, provider, None) - if client is None: - raise HTTPException( - status_code=404, detail=f"Unknown provider '{provider}'" - ) - redirect_uri = str(request.url_for("oauth_callback", provider=provider)) - return await client.authorize_redirect(request, redirect_uri) - - @router.get("/callback/{provider}", name="oauth_callback") - async def callback(provider: str, request: Request): - client = getattr(oauth, provider, None) - if client is None: - raise HTTPException( - status_code=404, detail=f"Unknown provider '{provider}'" - ) - try: - token = await client.authorize_access_token(request) - except Exception as e: - raise HTTPException(status_code=401, detail=f"OAuth failed: {e}") from e - - email = None - userinfo = token.get("userinfo") if isinstance(token, dict) else None - if userinfo and isinstance(userinfo, dict): - email = userinfo.get("email") - - if not email and hasattr(client, "userinfo"): - try: - info = await client.userinfo(token=token) - if isinstance(info, dict): - email = info.get("email") - except Exception: - pass - - if not email: - raise HTTPException(status_code=401, detail="No email from OAuth provider") - - email = email.lower() - if email not in user_store: - user_store[email] = { - "password_hash": None, - "created_at": time.time(), - "oauth_provider": provider, - } - session_id = session_store.create(user_id=email, email=email) - resp = Response( - content=f'{{"ok":true,"email":"{email}"}}', - media_type="application/json", - ) - _set_session_cookie(resp, session_id) - return resp - - return router diff --git a/enlace/auth/passwords.py b/enlace/auth/passwords.py deleted file mode 100644 index 2ff27a2..0000000 --- a/enlace/auth/passwords.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Password hashing via argon2id. - -argon2-cffi ships `PasswordHasher` which handles salting, tuning, and -constant-time verification. We surface only two helpers to keep callers away -from parameter tuning. -""" - -from __future__ import annotations - - -def _hasher(): - try: - from argon2 import PasswordHasher # type: ignore - except ImportError as e: - raise ImportError( - "argon2-cffi is required for password hashing. " - "Install it via `pip install enlace[auth]`." - ) from e - return PasswordHasher() - - -def hash_password(password: str) -> str: - """Return an argon2id hash string for ``password``.""" - return _hasher().hash(password) - - -def verify_password(hashed: str, password: str) -> bool: - """Return True iff ``password`` matches the stored ``hashed`` value.""" - try: - from argon2.exceptions import VerifyMismatchError # type: ignore - except ImportError: - VerifyMismatchError = Exception # type: ignore[assignment,misc] - ph = _hasher() - try: - ph.verify(hashed, password) - return True - except VerifyMismatchError: - return False - except Exception: - return False diff --git a/enlace/auth/routes.py b/enlace/auth/routes.py deleted file mode 100644 index cf2e21a..0000000 --- a/enlace/auth/routes.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Auth HTTP routes: register, login, logout, shared-login, csrf. - -OAuth routes live in ``enlace.auth.oauth`` and are attached separately so the -Authlib dependency stays lazy. -""" - -from __future__ import annotations - -import time -from typing import Any, Callable, Optional - -from fastapi import APIRouter, HTTPException, Request, Response -from pydantic import BaseModel, EmailStr - -from enlace.auth.cookies import sign_cookie, verify_cookie -from enlace.auth.passwords import hash_password, verify_password -from enlace.auth.sessions import SessionStore - - -class _LoginBody(BaseModel): - email: EmailStr - password: str - - -class _RegisterBody(BaseModel): - email: EmailStr - password: str - - -class _SharedLoginBody(BaseModel): - app: str - password: str - - -def make_auth_router( - *, - session_store: SessionStore, - user_store, # MutableMapping[email -> {password_hash, created_at}] - signing_key: str, - cookie_name: str = "enlace_session", - session_max_age: int = 86400, - secure_cookies: bool = True, - shared_password_for: Callable[[str], Optional[str]] = lambda _: None, -) -> APIRouter: - """Build a FastAPI router exposing ``/auth/*`` endpoints.""" - router = APIRouter(prefix="/auth") - - def _set_session_cookie( - response: Response, value: str, *, max_age: int, salt: str, name: str - ): - signed = sign_cookie(value, signing_key, salt=salt) - attrs = [ - f"{name}={signed}", - "Path=/", - "HttpOnly", - f"Max-Age={max_age}", - "SameSite=Lax", - ] - if secure_cookies: - attrs.append("Secure") - response.headers.append("set-cookie", "; ".join(attrs)) - - def _clear_cookie(response: Response, name: str): - response.headers.append( - "set-cookie", - f"{name}=; Path=/; Max-Age=0; SameSite=Lax" - + ("; Secure" if secure_cookies else ""), - ) - - @router.post("/register") - async def register(body: _RegisterBody, response: Response) -> dict[str, Any]: - email = body.email.lower() - if email in user_store: - raise HTTPException(status_code=409, detail="Email already registered") - user_store[email] = { - "password_hash": hash_password(body.password), - "created_at": time.time(), - } - session_id = session_store.create(user_id=email, email=email) - _set_session_cookie( - response, - session_id, - max_age=session_max_age, - salt="session", - name=cookie_name, - ) - return {"ok": True, "email": email} - - @router.post("/login") - async def login(body: _LoginBody, response: Response) -> dict[str, Any]: - email = body.email.lower() - try: - record = user_store[email] - except KeyError: - raise HTTPException(status_code=401, detail="Invalid credentials") - if not isinstance(record, dict) or "password_hash" not in record: - raise HTTPException(status_code=401, detail="Invalid credentials") - if not verify_password(record["password_hash"], body.password): - raise HTTPException(status_code=401, detail="Invalid credentials") - session_id = session_store.create(user_id=email, email=email) - _set_session_cookie( - response, - session_id, - max_age=session_max_age, - salt="session", - name=cookie_name, - ) - return {"ok": True, "email": email} - - @router.post("/logout") - async def logout(request: Request, response: Response) -> dict[str, Any]: - token = request.cookies.get(cookie_name) - if token: - session_id = verify_cookie(token, signing_key, salt="session") - if session_id: - session_store.delete(session_id) - _clear_cookie(response, cookie_name) - return {"ok": True} - - @router.post("/shared-login") - async def shared_login( - body: _SharedLoginBody, response: Response - ) -> dict[str, Any]: - stored_hash = shared_password_for(body.app) - if not stored_hash: - raise HTTPException(status_code=404, detail=f"Unknown app '{body.app}'") - if not verify_password(stored_hash, body.password): - raise HTTPException(status_code=401, detail="Invalid password") - token = sign_cookie("1", signing_key, salt=f"shared:{body.app}") - cookie_name_shared = f"shared_auth_{body.app}" - attrs = [ - f"{cookie_name_shared}={token}", - "Path=/", - "HttpOnly", - f"Max-Age={session_max_age}", - "SameSite=Lax", - ] - if secure_cookies: - attrs.append("Secure") - response.headers.append("set-cookie", "; ".join(attrs)) - return {"ok": True, "app": body.app} - - @router.get("/whoami") - async def whoami(request: Request) -> dict[str, Any]: - return { - "user_id": getattr(request.state, "user_id", None), - "email": getattr(request.state, "user_email", None), - } - - @router.get("/csrf") - async def csrf(request: Request) -> dict[str, Any]: - """Return the unsigned CSRF token. - - Three cases, in priority order: - 1. CSRFMiddleware just minted a token for this request (no inbound - cookie). It exposes the unsigned value via ``request.state.csrf_token`` - and sets the signed cookie in the response itself — we just echo. - 2. The request already carried a valid signed cookie — unseal it and - return the unsigned value. No new cookie is set. - 3. No cookie and no minted token (shouldn't happen in practice, but - defensive): fall through to a 500-like empty string. Prefer to let - the next request set the cookie via the middleware. - """ - minted = getattr(request.state, "csrf_token", None) - if minted: - return {"csrf": minted} - existing = request.cookies.get("enlace_csrf") - if existing: - token = verify_cookie(existing, signing_key, salt="csrf") - if token: - return {"csrf": token} - # Degenerate: no cookie, no minted token. Let the client retry. - raise HTTPException( - status_code=503, detail="CSRF token unavailable; retry this request" - ) - - return router diff --git a/enlace/auth/sessions.py b/enlace/auth/sessions.py deleted file mode 100644 index 40f1fc9..0000000 --- a/enlace/auth/sessions.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Session storage backed by a MutableMapping. - -A session is ``{"user_id": str, "email": str | None, "created_at": float}``. -Session IDs are 32-byte urlsafe tokens. Revocation is a simple delete. -""" - -from __future__ import annotations - -import secrets -import time -from collections.abc import MutableMapping -from typing import Any, Optional - - -class SessionStore: - """Thin adapter around a MutableMapping that speaks session semantics.""" - - def __init__(self, store: MutableMapping): - self._store = store - - def create(self, user_id: str, email: Optional[str] = None) -> str: - session_id = secrets.token_urlsafe(32) - self._store[session_id] = { - "user_id": user_id, - "email": email, - "created_at": time.time(), - } - return session_id - - def get(self, session_id: str) -> Optional[dict[str, Any]]: - try: - value = self._store[session_id] - except KeyError: - return None - if not isinstance(value, dict): - return None - return value - - def delete(self, session_id: str) -> bool: - try: - del self._store[session_id] - return True - except KeyError: - return False - - def list_all(self) -> list[tuple[str, dict[str, Any]]]: - out: list[tuple[str, dict[str, Any]]] = [] - for sid in list(self._store): - try: - value = self._store[sid] - except KeyError: - continue - if isinstance(value, dict): - out.append((sid, value)) - return out diff --git a/enlace/base.py b/enlace/base.py index e639c50..22cf81c 100644 --- a/enlace/base.py +++ b/enlace/base.py @@ -1,48 +1,30 @@ -"""Core data structures for enlace platform configuration.""" +"""Core data structures for enlace platform configuration. + +enlace does not enforce access — that's ``enlace_auth``'s job. But enlace +*does* read ``AppConfig.access`` and ``AppConfig.allowed_users`` for one +narrow purpose: filtering the ``/_apps`` listing so authenticated users +don't see entries they couldn't open anyway. That makes the access string +vocabulary part of enlace's contract — the values +``"public" | "local" | "protected:shared" | "protected:user"`` are the +ones ``compose._can_access`` understands; anything else is treated as +deny-by-default. + +The fields are otherwise opaque to enlace: enforcement, session lookup, +allowlist matching at request time, and CSRF all live in the +``enlace_auth`` plugin (passed in via ``build_backend(..., plugins=[...])``). + +Likewise, ``[auth.*]`` and ``[stores.*]`` tables in ``platform.toml`` are +preserved as untyped dicts on ``PlatformConfig`` so plugins can deserialize +them with their own models. +""" import os import sys from pathlib import Path -from typing import Literal, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel, Field, model_validator -# Access levels for apps. "local" is kept as a legacy alias for "public" so -# pre-auth configs keep parsing; middleware treats it as public. -AccessLevel = Literal["public", "protected:shared", "protected:user", "local"] - - -class StoreBackendConfig(BaseModel): - """Backend configuration for a MutableMapping-backed store.""" - - backend: str = "file" - path: str = "~/.enlace/platform_store" - - -class OAuthProviderConfig(BaseModel): - """Configuration for a single OAuth2/OIDC provider.""" - - client_id_env: str - client_secret_env: str - scopes: list[str] = Field(default_factory=list) - authorize_url: Optional[str] = None - token_url: Optional[str] = None - userinfo_url: Optional[str] = None - server_metadata_url: Optional[str] = None - - -class AuthConfig(BaseModel): - """Platform-wide authentication configuration.""" - - enabled: bool = False - session_cookie_name: str = "enlace_session" - session_max_age_seconds: int = 86400 - signing_key_env: str = "ENLACE_SIGNING_KEY" - secure_cookies: bool = True - stores: StoreBackendConfig = Field(default_factory=StoreBackendConfig) - oauth: dict[str, OAuthProviderConfig] = Field(default_factory=dict) - - if sys.version_info >= (3, 11): import tomllib else: @@ -79,7 +61,10 @@ class AppConfig(BaseModel): app_attr: str = "app" frontend_dir: Optional[Path] = None source_dir: Optional[Path] = None - access: AccessLevel = "local" + # Auth policy field. Consumed by enlace_auth (if installed) — enlace + # itself does not interpret this. Free-form string to avoid coupling + # enlace's data model to auth's vocabulary; enlace_auth normalizes it. + access: str = "local" shared_password_env: Optional[str] = None allowed_users: list[str] = Field( default_factory=list, @@ -182,8 +167,11 @@ class PlatformConfig(BaseModel): socket_dir: Path = Field(default=Path("/tmp/enlace")) conventions: ConventionsConfig = Field(default_factory=ConventionsConfig) apps: list[AppConfig] = Field(default_factory=list) - auth: AuthConfig = Field(default_factory=AuthConfig) - stores: dict[str, StoreBackendConfig] = Field(default_factory=dict) + # Auth + stores configuration is preserved as untyped dicts so plugins + # (e.g. enlace_auth) can deserialize them with their own pydantic models + # without enlace having to know the schema. + auth: dict[str, Any] = Field(default_factory=dict) + stores: dict[str, dict[str, Any]] = Field(default_factory=dict) @model_validator(mode="after") def _normalize_dirs(self): @@ -226,16 +214,11 @@ def from_toml(cls, path: Path = Path("platform.toml")) -> "PlatformConfig": if conventions_data: platform_data["conventions"] = conventions_data + # Auth and stores tables are forwarded verbatim as dicts; plugins + # such as enlace_auth deserialize them with their own models. auth_data = data.get("auth") if auth_data is not None: - auth_stores = auth_data.pop("stores", None) - auth_oauth = auth_data.pop("oauth", None) - if auth_stores is not None: - auth_data["stores"] = auth_stores - if auth_oauth is not None: - auth_data["oauth"] = auth_oauth platform_data["auth"] = auth_data - stores_data = data.get("stores") if stores_data is not None: platform_data["stores"] = stores_data diff --git a/enlace/compose.py b/enlace/compose.py index 074ff40..77336e2 100644 --- a/enlace/compose.py +++ b/enlace/compose.py @@ -3,6 +3,13 @@ Builds a single FastAPI application by mounting discovered sub-apps and applying cross-cutting middleware. Handles lifespan cascading to mounted sub-apps (Starlette does not do this natively). + +Plugins: + ``build_backend`` accepts a ``plugins`` argument — a sequence of callables + ``(parent: FastAPI, config: PlatformConfig) -> None`` invoked once after + sub-apps are mounted. ``enlace_auth.plugin`` is the canonical example: + when installed, it adds auth, sessions, the admin dashboard, and per-user + stores. enlace itself is auth-agnostic. """ import contextlib @@ -13,7 +20,7 @@ import sys from contextlib import asynccontextmanager from pathlib import Path -from typing import Optional +from typing import Callable, Optional, Sequence from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -27,11 +34,7 @@ _logger = logging.getLogger("enlace") -# Minimum accepted signing-key length. `secrets.token_urlsafe(32)` yields 43 -# chars; anything much shorter is a stub or placeholder and should be rejected. -_MIN_SIGNING_KEY_LEN = 32 - -_UNSAFE_OPT_OUT_ENV = "ENLACE_ALLOW_UNSIGNED" +Plugin = Callable[[FastAPI, PlatformConfig], None] class EnlaceConfigError(RuntimeError): @@ -42,7 +45,9 @@ class EnlaceConfigError(RuntimeError): """ -def build_backend(config: PlatformConfig) -> FastAPI: +def build_backend( + config: PlatformConfig, *, plugins: Sequence[Plugin] = () +) -> FastAPI: """Compose all app backends into a single ASGI application. For each discovered app: @@ -113,8 +118,11 @@ async def cascade_lifespan(app: FastAPI): allow_headers=["*"], ) - # Auth + store wiring (pure ASGI middleware only — no BaseHTTPMiddleware). - _wire_auth_and_stores(parent, config) + # Compose-time plugins (pure ASGI middleware only — no BaseHTTPMiddleware + # in plugins either, please). enlace_auth is the canonical plugin: it + # mounts /auth/*, /_admin/*, store routes, and the auth+csrf middleware. + for plug in plugins: + plug(parent, config) # JSON listing is always on (cheap, useful for frontends even when the # HTML index_page is disabled). @@ -314,194 +322,6 @@ async def __call__(self, scope, receive, send): await self.app(scope, receive, send) -def _require_signing_key(env_var: str) -> Optional[str]: - """Resolve the auth signing key, enforcing fail-fast by default. - - Returns the key when usable. Returns ``None`` when the key is missing or - malformed AND ``ENLACE_ALLOW_UNSIGNED=1`` is set — the caller should then - skip auth wiring (logging a loud warning is already done here). Raises - ``EnlaceConfigError`` when the key is missing/malformed and the opt-out is - NOT set. - - A deployment that silently runs without a signing key when auth is enabled - is strictly broken: ``/auth/*`` routes aren't mounted, so the SPA catch-all - returns HTML for ``fetch('/auth/csrf')`` and every protected mount becomes - unreachable. See i2mint/enlace#11. - """ - raw = os.environ.get(env_var) or "" - stripped = raw.strip() - problem: Optional[str] = None - if not stripped: - problem = f"env var {env_var} is unset or empty" - elif len(stripped) < _MIN_SIGNING_KEY_LEN: - problem = ( - f"env var {env_var} is too short " - f"({len(stripped)} chars; need >= {_MIN_SIGNING_KEY_LEN})" - ) - - if problem is None: - return stripped - - if os.environ.get(_UNSAFE_OPT_OUT_ENV) == "1": - _logger.error( - "enlace auth is ENABLED but %s — booting with /auth/* DISABLED " - "because %s=1. This gateway cannot authenticate users. Unset the " - "opt-out and set %s to restore auth.", - problem, - _UNSAFE_OPT_OUT_ENV, - env_var, - ) - return None - - raise EnlaceConfigError( - f"enlace auth is enabled but {problem}. Generate one with " - f"`enlace auth-generate-signing-key` and export it as {env_var}. " - f"To boot without auth (diagnostics only), set " - f"{_UNSAFE_OPT_OUT_ENV}=1. See `enlace check` / `enlace doctor` " - f"for details." - ) - - -def _wire_auth_and_stores(parent: FastAPI, config: PlatformConfig) -> None: - """Wire auth + store middleware and mount /auth/* and /api/{app}/store routes. - - No-op when ``config.auth.enabled`` is False, so pre-auth deployments - behave exactly as before. - """ - auth_cfg = config.auth - if not auth_cfg.enabled: - return - - signing_key = _require_signing_key(auth_cfg.signing_key_env) - if signing_key is None: - # Opt-out path: ENLACE_ALLOW_UNSIGNED=1 kept us from raising, but the - # gateway must still boot without auth so operators can diagnose. - return - - from enlace.auth import ( - CSRFMiddleware, - PlatformAuthMiddleware, - SessionStore, - make_auth_router, - ) - from enlace.auth.middleware import AccessRule - from enlace.stores import StoreInjectionMiddleware, make_file_store_factory - from enlace.stores.middleware import make_store_router - - platform_factory = make_file_store_factory(auth_cfg.stores.path) - session_backend = platform_factory("sessions") - user_backend = platform_factory("users") - session_store = SessionStore(session_backend) - - user_data_cfg = config.stores.get("user_data") - user_data_backend: Optional[object] = None - if user_data_cfg is not None: - user_data_factory = make_file_store_factory(user_data_cfg.path) - user_data_backend = user_data_factory("user_data") - - # Build access rules and shared-password lookup. - # Two rules per app: the API prefix (/api/{name}) AND the frontend prefix - # (/{name}) — otherwise browser requests to the frontend fall through to - # the middleware's deny-by-default clause (issue #7). - shared_hashes: dict[str, str] = {} - access_rules: list[AccessRule] = [] - protected_user_apps: set[str] = set() - for app in config.apps: - h: Optional[str] = None - if app.access == "protected:shared" and app.shared_password_env: - h = os.environ.get(app.shared_password_env) - if h: - shared_hashes[app.name] = h - if app.access == "protected:user": - protected_user_apps.add(app.name) - allowed = tuple(app.allowed_users) - access_rules.append( - AccessRule( - prefix=app.route_prefix, - level=app.access, - app_id=app.name, - shared_password_hash=h, - allowed_users=allowed, - ) - ) - frontend_prefix = f"/{app.name}" - if frontend_prefix != app.route_prefix: - access_rules.append( - AccessRule( - prefix=frontend_prefix, - level=app.access, - app_id=app.name, - shared_password_hash=h, - allowed_users=allowed, - ) - ) - # Root (/) and shared static assets (/shared.css etc) — public. The platform - # landing page must be reachable to anyone; per-app gating already covers - # everything beneath a more specific prefix via longest-prefix match. - access_rules.append(AccessRule(prefix="/", level="public", app_id="_root")) - - auth_router = make_auth_router( - session_store=session_store, - user_store=user_backend, - signing_key=signing_key, - cookie_name=auth_cfg.session_cookie_name, - session_max_age=auth_cfg.session_max_age_seconds, - secure_cookies=auth_cfg.secure_cookies, - shared_password_for=shared_hashes.get, - ) - parent.include_router(auth_router) - - # Optional OAuth router (lazy import of Authlib). - if auth_cfg.oauth: - try: - from enlace.auth.oauth import make_oauth_router - - oauth_router = make_oauth_router( - providers=auth_cfg.oauth, - session_store=session_store, - user_store=user_backend, - signing_key=signing_key, - cookie_name=auth_cfg.session_cookie_name, - session_max_age=auth_cfg.session_max_age_seconds, - secure_cookies=auth_cfg.secure_cookies, - ) - if oauth_router is not None: - parent.include_router(oauth_router) - except ImportError: - # authlib not installed but [auth.oauth.*] was configured. This - # silently breaks /auth/login/{provider}. Keep the platform - # functional (base auth still works) but make the degradation - # loud so ops don't chase a 404 in the dark. - providers = ", ".join(sorted(auth_cfg.oauth)) or "(none)" - _logger.error( - "enlace: [auth.oauth.*] is configured (%s) but authlib is " - "not installed. OAuth endpoints will be MISSING. " - "Install with `pip install enlace[oauth]` to fix.", - providers, - ) - - # Per-user store API. - store_router = make_store_router( - base_store_getter=lambda: user_data_backend, - protected_apps=protected_user_apps, - ) - parent.include_router(store_router) - - # Register middleware in the order requests traverse them: - # outermost = first added last. FastAPI/Starlette runs middleware in - # reverse insertion order, so the last `add_middleware` call is the - # outermost wrapper. We want: auth (outermost) -> store -> csrf -> app. - parent.add_middleware(CSRFMiddleware, signing_key=signing_key) - parent.add_middleware(StoreInjectionMiddleware, base_store=user_data_backend) - parent.add_middleware( - PlatformAuthMiddleware, - access_rules=access_rules, - session_store=session_store, - signing_key=signing_key, - cookie_name=auth_cfg.session_cookie_name, - max_age=auth_cfg.session_max_age_seconds, - ) - def _make_proxy_for(app_config: AppConfig) -> Optional[object]: """Create a reverse proxy ASGI app for a process or external backend. @@ -594,10 +414,53 @@ def create_app() -> FastAPI: Loads platform config, discovers apps, checks conflicts, and builds the composed backend. + + Plugins are loaded from the ``ENLACE_PLUGINS`` env var: a comma-separated + list of ``module:attribute`` pairs, e.g.:: + + ENLACE_PLUGINS=enlace_auth:plugin + + Each resolved object must be a callable + ``(parent: FastAPI, config: PlatformConfig) -> None``. """ config = discover_apps() config = _apply_port_env(config) - return build_backend(config) + plugins = _load_plugins_from_env() + return build_backend(config, plugins=plugins) + + +def _load_plugins_from_env() -> list[Plugin]: + """Parse ENLACE_PLUGINS=mod:attr,mod2:attr2 and resolve to callables.""" + raw = os.environ.get("ENLACE_PLUGINS", "").strip() + if not raw: + return [] + out: list[Plugin] = [] + for spec in (s.strip() for s in raw.split(",")): + if not spec: + continue + if ":" not in spec: + raise EnlaceConfigError( + f"ENLACE_PLUGINS entry {spec!r} is not in 'module:attribute' form" + ) + mod_name, attr = spec.split(":", 1) + try: + mod = importlib.import_module(mod_name) + except ImportError as e: + raise EnlaceConfigError( + f"ENLACE_PLUGINS: cannot import {mod_name!r}: {e}" + ) from e + try: + obj = getattr(mod, attr) + except AttributeError as e: + raise EnlaceConfigError( + f"ENLACE_PLUGINS: {mod_name!r} has no attribute {attr!r}" + ) from e + if not callable(obj): + raise EnlaceConfigError( + f"ENLACE_PLUGINS: {spec!r} resolved to non-callable {obj!r}" + ) + out.append(obj) + return out def _apply_port_env(config: PlatformConfig) -> PlatformConfig: diff --git a/enlace/doctor.py b/enlace/doctor.py index 36815d2..4ba910d 100644 --- a/enlace/doctor.py +++ b/enlace/doctor.py @@ -3,8 +3,9 @@ Complements ``enlace check`` (static config validation) by probing a live gateway over HTTP. Catches silent-degradation failures that static analysis can't — the incident that motivated this (i2mint/enlace#11) was a gateway -booting cleanly with ``/auth/*`` un-mounted because ``ENLACE_SIGNING_KEY`` -was missing at startup. +booting cleanly with auth un-mounted because the signing key was missing at +startup; auth-specific checks for that scenario live in +``enlace_auth.diagnostics``. Design: - Pure stdlib ``urllib`` for HTTP. No new deps; this must work in minimal @@ -13,14 +14,15 @@ of them and returns a ``Report`` so callers can emit pretty text OR JSON. - ``detail`` is a short human-readable string. Structured payloads go in ``extra`` (dict) so ``--json`` consumers don't re-parse prose. +- Plugins can supply extra static or HTTP probes via ``extra_static_checks`` + and ``extra_http_checks`` on ``run_doctor``. """ from __future__ import annotations import json -import os from dataclasses import asdict, dataclass, field -from typing import Iterable, Optional +from typing import Callable, Iterable, Optional from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -99,83 +101,6 @@ def format_text(self) -> str: # --------------------------------------------------------------------------- -def _check_signing_key(config: PlatformConfig) -> Check: - auth = config.auth - if not auth.enabled: - return Check("signing_key", SKIP, "auth.enabled=false") - raw = os.environ.get(auth.signing_key_env) or "" - stripped = raw.strip() - if not stripped: - return Check( - "signing_key", - FAIL, - f"env var {auth.signing_key_env} is unset or empty", - ) - if len(stripped) < 32: - return Check( - "signing_key", - FAIL, - f"env var {auth.signing_key_env} too short ({len(stripped)} chars)", - ) - return Check( - "signing_key", - PASS, - f"{auth.signing_key_env} set ({len(stripped)} chars)", - ) - - -def _check_shared_passwords(config: PlatformConfig) -> list[Check]: - if not config.auth.enabled: - return [] - out: list[Check] = [] - for app in config.apps: - if app.access != "protected:shared": - continue - if not app.shared_password_env: - out.append( - Check( - f"shared_pw:{app.name}", - FAIL, - "access=protected:shared but no shared_password_env set", - ) - ) - continue - if not os.environ.get(app.shared_password_env): - out.append( - Check( - f"shared_pw:{app.name}", - FAIL, - f"env var {app.shared_password_env} is unset", - ) - ) - else: - out.append( - Check( - f"shared_pw:{app.name}", - PASS, - f"{app.shared_password_env} set", - ) - ) - return out - - -def _check_oauth_importable(config: PlatformConfig) -> Optional[Check]: - """If OAuth providers are configured, authlib must be importable.""" - if not config.auth.enabled or not config.auth.oauth: - return None - providers = ", ".join(sorted(config.auth.oauth)) - try: - import authlib # noqa: F401 - except ImportError: - return Check( - "oauth_import", - FAIL, - f"oauth providers ({providers}) configured but authlib not " - "installed. Install with `pip install enlace[oauth]`.", - ) - return Check("oauth_import", PASS, f"authlib importable; providers: {providers}") - - def _check_frontend_dirs(config: PlatformConfig) -> list[Check]: """Apps declaring frontend_dir should actually have a directory there.""" out: list[Check] = [] @@ -226,55 +151,6 @@ def _http_get( return None, {}, None, f"unexpected error: {e}" -def _check_csrf(base_url: str, timeout: float) -> Check: - """GET /auth/csrf must return JSON with a 'csrf' key. - - This is THE check that would have caught the i2mint/enlace#11 regression: - when auth is silently disabled, the SPA catch-all returns - ```` instead of JSON. - """ - url = f"{base_url.rstrip('/')}/auth/csrf" - status, headers, body, err = _http_get(url, timeout=timeout) - if err: - return Check("http:/auth/csrf", FAIL, err) - ct = headers.get("content-type", "") - if status != 200: - snippet = (body or b"").decode("utf-8", errors="replace")[:120] - return Check( - "http:/auth/csrf", - FAIL, - f"status={status} content-type={ct!r} body[:120]={snippet!r}", - extra={"status": status, "content_type": ct}, - ) - if "application/json" not in ct.lower(): - snippet = (body or b"").decode("utf-8", errors="replace")[:120] - return Check( - "http:/auth/csrf", - FAIL, - f"expected JSON, got content-type={ct!r}; " - f"body[:120]={snippet!r} (auth silently disabled?)", - extra={"status": status, "content_type": ct}, - ) - try: - data = json.loads(body.decode("utf-8")) - except Exception as e: - return Check( - "http:/auth/csrf", - FAIL, - f"body is not valid JSON: {e}", - ) - if not isinstance(data, dict) or "csrf" not in data: - keys = list(data) if isinstance(data, dict) else type(data).__name__ - return Check( - "http:/auth/csrf", - FAIL, - f"JSON response missing 'csrf' key: keys={keys}", - ) - return Check( - "http:/auth/csrf", PASS, f"JSON with csrf token ({len(data['csrf'])} chars)" - ) - - def _check_frontend_mount(base_url: str, app_name: str, timeout: float) -> Check: """GET /{app_name}/ — mount must exist. @@ -333,6 +209,10 @@ def _check_api_mount( # --------------------------------------------------------------------------- +StaticCheckFn = Callable[[PlatformConfig], "Iterable[Check]"] +HttpCheckFn = Callable[[PlatformConfig, str, float], "Iterable[Check]"] + + def run_doctor( config: PlatformConfig, *, @@ -340,6 +220,8 @@ def run_doctor( timeout: float = _DEFAULT_TIMEOUT, app_filter: Optional[Iterable[str]] = None, include_env_checks: bool = True, + extra_static_checks: Iterable[StaticCheckFn] = (), + extra_http_checks: Iterable[HttpCheckFn] = (), ) -> Report: """Run all checks and return a ``Report``. @@ -350,30 +232,27 @@ def run_doctor( timeout: Per-request timeout for HTTP probes. app_filter: If given, only probe these app names (static checks still run across all apps). - include_env_checks: When False, env-var checks (signing key, - shared-password hashes) are skipped. Useful when probing from - a shell that doesn't have the gateway's env loaded — the HTTP - probes then serve as the source of truth. Callers that have - loaded the env (e.g. ``--envfile``) should leave this True. + include_env_checks: Forwarded to ``extra_static_checks`` callbacks + via ``config`` (those that read env vars should respect their + caller's intent — see ``enlace_auth.diagnostics`` for the + convention). enlace itself has no env-var checks of its own. + extra_static_checks: Plugin-provided static checks. Each is a + callable that receives ``config`` and returns ``Iterable[Check]``. + extra_http_checks: Plugin-provided HTTP checks. Each is a callable + that receives ``(config, base_url, timeout)`` and returns + ``Iterable[Check]``. Only invoked when ``base_url`` is set. """ + _ = include_env_checks # signal to plugin authors via convention report = Report(base_url=base_url) - # Static checks that depend on the local environment. - if include_env_checks: - report.checks.append(_check_signing_key(config)) - report.checks.extend(_check_shared_passwords(config)) - # Static checks that only depend on the repo / config — always run. - oauth_check = _check_oauth_importable(config) - if oauth_check is not None: - report.checks.append(oauth_check) report.checks.extend(_check_frontend_dirs(config)) + for fn in extra_static_checks: + report.checks.extend(fn(config)) # HTTP probes — only when a base URL is provided. if base_url: - if config.auth.enabled: - report.checks.append(_check_csrf(base_url, timeout)) - else: - report.checks.append(Check("http:/auth/csrf", SKIP, "auth.enabled=false")) + for fn in extra_http_checks: + report.checks.extend(fn(config, base_url, timeout)) apps = list(config.apps) if app_filter is not None: diff --git a/enlace/stores/__init__.py b/enlace/stores/__init__.py deleted file mode 100644 index d120dd9..0000000 --- a/enlace/stores/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Per-user stores for enlace. - -Apps never import from this package. Apps read ``request.state.store`` — a -``MutableMapping`` scoped to ``{user_id}/{app_id}/`` via ``PrefixedStore``. - -Public helpers: - -- ``PrefixedStore`` — wraps any MutableMapping with a key prefix. -- ``sanitize_key`` — path-traversal guard for user-supplied keys. -- ``make_file_store_factory`` — file-backed MutableMapping factory. -- ``StoreInjectionMiddleware`` — pure-ASGI middleware that injects - ``scope["state"]["store"]`` based on ``user_id`` and ``app_id``. -- ``make_store_router`` — FastAPI router for ``/api/{app_id}/store/{key}``. -""" - -from enlace.stores.backends import StoreFactory, make_file_store_factory -from enlace.stores.middleware import StoreInjectionMiddleware, make_store_router -from enlace.stores.prefixed import PrefixedStore -from enlace.stores.validation import sanitize_key - -__all__ = [ - "PrefixedStore", - "StoreFactory", - "StoreInjectionMiddleware", - "make_file_store_factory", - "make_store_router", - "sanitize_key", -] diff --git a/enlace/stores/backends.py b/enlace/stores/backends.py deleted file mode 100644 index be3c5f3..0000000 --- a/enlace/stores/backends.py +++ /dev/null @@ -1,117 +0,0 @@ -"""MutableMapping-backed store factories for enlace. - -The file backend is the MVP default: one directory per named store under a -platform root (``~/.enlace/platform_store/`` by default). Values are JSON. - -When ``dol`` is installed (via ``enlace[auth]``), ``make_file_store_factory`` -uses ``dol.Files`` + a JSON codec. When it isn't, we fall back to a tiny -stdlib implementation so the core package keeps working. -""" - -from __future__ import annotations - -import json -import os -from collections.abc import Iterator, MutableMapping -from pathlib import Path -from typing import Callable - -StoreFactory = Callable[[str], MutableMapping] - - -class _FileDict(MutableMapping): - """Minimal JSON-file-per-key MutableMapping. Used when dol isn't available.""" - - def __init__(self, root: Path): - self._root = Path(root) - self._root.mkdir(parents=True, exist_ok=True) - - def _path(self, key: str) -> Path: - return self._root / key - - def __getitem__(self, key: str): - p = self._path(key) - if not p.exists(): - raise KeyError(key) - with p.open("rb") as f: - return json.loads(f.read()) - - def __setitem__(self, key: str, value) -> None: - p = self._path(key) - p.parent.mkdir(parents=True, exist_ok=True) - data = json.dumps(value).encode("utf-8") - tmp = p.with_suffix(p.suffix + ".tmp") - with tmp.open("wb") as f: - f.write(data) - os.replace(tmp, p) - - def __delitem__(self, key: str) -> None: - p = self._path(key) - if not p.exists(): - raise KeyError(key) - p.unlink() - - def __iter__(self) -> Iterator[str]: - for p in self._root.rglob("*"): - if p.is_file() and not p.name.endswith(".tmp"): - yield str(p.relative_to(self._root)) - - def __len__(self) -> int: - return sum(1 for _ in iter(self)) - - def __contains__(self, key: object) -> bool: - return isinstance(key, str) and self._path(key).exists() - - -def _make_dol_factory(root: Path) -> StoreFactory: - """Build a factory using dol.Files with a JSON codec. - - dol.Files uses absolute filesystem paths as keys but doesn't auto-create - parent directories on write; we wrap setitem to ``mkdir -p`` the parent - so per-user subpaths like ``alice/chord/settings`` work out of the box. - """ - from dol import Files, wrap_kvs # type: ignore - - class _MkdirFiles(Files): - def __setitem__(self, k, v): - Path(k).parent.mkdir(parents=True, exist_ok=True) - super().__setitem__(k, v) - - def factory(name: str) -> MutableMapping: - d = root / name - d.mkdir(parents=True, exist_ok=True) - base = _MkdirFiles(str(d)) - - def _postget(k, v): - if isinstance(v, (bytes, bytearray)): - v = v.decode("utf-8") - return json.loads(v) - - def _preset(k, v): - return json.dumps(v).encode("utf-8") - - return wrap_kvs(base, postget=_postget, preset=_preset) - - return factory - - -def make_file_store_factory(root: str, *, use_dol: bool = False) -> StoreFactory: - """Return a ``StoreFactory`` backed by JSON files under ``root``. - - ``factory(name)`` returns a ``MutableMapping`` rooted at ``root/name/``. - - Defaults to a small stdlib implementation that auto-creates parent - directories on write. Pass ``use_dol=True`` to use ``dol.Files`` instead - (pulls in the soft dep and expects flat keys). - """ - root_path = Path(os.path.expanduser(root)) - if use_dol: - try: - return _make_dol_factory(root_path) - except ImportError: - pass - - def factory(name: str) -> MutableMapping: - return _FileDict(root_path / name) - - return factory diff --git a/enlace/stores/middleware.py b/enlace/stores/middleware.py deleted file mode 100644 index ec094d6..0000000 --- a/enlace/stores/middleware.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Store injection middleware and per-app store router. - -``StoreInjectionMiddleware`` runs after the auth middleware. It reads -``scope["state"]["user_id"]`` and ``scope["state"]["app_id"]`` (the latter set -by the per-mount wrapper in ``enlace.compose``) and attaches a -``PrefixedStore(base, f"{user_id}/{app_id}/")`` to ``scope["state"]["store"]``. - -If either id is missing, ``store`` is ``None`` — apps are expected to handle -that case gracefully (they do so naturally by providing a dict fallback in -standalone mode). -""" - -from __future__ import annotations - -from collections.abc import MutableMapping -from typing import Callable, Optional - -from fastapi import APIRouter, HTTPException, Request - -from enlace.stores.prefixed import PrefixedStore -from enlace.stores.validation import sanitize_key - - -class StoreInjectionMiddleware: - """Pure-ASGI middleware that injects ``request.state.store``.""" - - def __init__(self, app, *, base_store: Optional[MutableMapping] = None): - self.app = app - self._base = base_store - - async def __call__(self, scope, receive, send): - if scope["type"] not in ("http", "websocket"): - await self.app(scope, receive, send) - return - - state = scope.setdefault("state", {}) - user_id = state.get("user_id") - app_id = state.get("app_id") - - if self._base is not None and user_id and app_id: - try: - prefix = f"{sanitize_key(str(user_id))}/{sanitize_key(app_id)}/" - state["store"] = PrefixedStore(self._base, prefix) - except ValueError: - state["store"] = None - else: - state["store"] = None - - await self.app(scope, receive, send) - - -def make_store_router( - *, - base_store_getter: Callable[[], Optional[MutableMapping]], - protected_apps: set[str], -) -> APIRouter: - """Return a router exposing ``/api/{app_id}/store/{key}`` endpoints. - - Only apps whose name is in ``protected_apps`` (i.e. ``protected:user`` - access level) can have their store accessed this way. The router assumes - ``PlatformAuthMiddleware`` has already set ``request.state.user_id``. - """ - router = APIRouter() - - def _scoped_store(request: Request, app_id: str) -> PrefixedStore: - if app_id not in protected_apps: - raise HTTPException( - status_code=404, detail=f"No user store for app '{app_id}'" - ) - user_id = getattr(request.state, "user_id", None) - if not user_id: - raise HTTPException(status_code=401, detail="Not authenticated") - base = base_store_getter() - if base is None: - raise HTTPException(status_code=503, detail="User data store disabled") - try: - prefix = f"{sanitize_key(str(user_id))}/{sanitize_key(app_id)}/" - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - return PrefixedStore(base, prefix) - - @router.get("/api/{app_id}/store/{key:path}") - async def get_value(app_id: str, key: str, request: Request): - store = _scoped_store(request, app_id) - try: - sanitize_key(key) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - try: - return {"value": store[key]} - except KeyError: - raise HTTPException(status_code=404, detail=f"Key '{key}' not found") - - @router.put("/api/{app_id}/store/{key:path}") - async def put_value(app_id: str, key: str, request: Request): - store = _scoped_store(request, app_id) - try: - sanitize_key(key) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - try: - body = await request.json() - except Exception as e: - raise HTTPException(status_code=400, detail="Body must be JSON") from e - value = ( - body.get("value") if isinstance(body, dict) and "value" in body else body - ) - store[key] = value - return {"ok": True} - - @router.delete("/api/{app_id}/store/{key:path}") - async def delete_value(app_id: str, key: str, request: Request): - store = _scoped_store(request, app_id) - try: - sanitize_key(key) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - try: - del store[key] - except KeyError: - raise HTTPException(status_code=404, detail=f"Key '{key}' not found") - return {"ok": True} - - return router diff --git a/enlace/stores/prefixed.py b/enlace/stores/prefixed.py deleted file mode 100644 index 9c07b58..0000000 --- a/enlace/stores/prefixed.py +++ /dev/null @@ -1,73 +0,0 @@ -"""PrefixedStore — MutableMapping wrapper that scopes keys under a prefix. - -The per-user injection pattern: a single base store is shared across all users -and apps, but each request sees a ``PrefixedStore(base, f"{user_id}/{app_id}/")`` -so keys can't collide across tenants. - -Keys the caller passes are validated via ``sanitize_key``; the prefix itself is -sanitized at construction (each slash-separated segment). -""" - -from collections.abc import Iterator, MutableMapping -from typing import Any - -from enlace.stores.validation import sanitize_key - - -def _validate_prefix(prefix: str) -> str: - """Sanitize each slash-separated segment of the prefix.""" - if not prefix: - raise ValueError("prefix must be non-empty") - if not prefix.endswith("/"): - prefix = prefix + "/" - parts = [p for p in prefix.split("/") if p] - if not parts: - raise ValueError("prefix must contain at least one segment") - for p in parts: - sanitize_key(p) - return "/".join(parts) + "/" - - -class PrefixedStore(MutableMapping): - """Transparently prepend a prefix to every key operation on a base store.""" - - def __init__(self, base: MutableMapping, prefix: str): - self._base = base - self._prefix = _validate_prefix(prefix) - - @property - def prefix(self) -> str: - return self._prefix - - def _k(self, key: str) -> str: - return self._prefix + sanitize_key(key) - - def __getitem__(self, key: str) -> Any: - return self._base[self._k(key)] - - def __setitem__(self, key: str, value: Any) -> None: - self._base[self._k(key)] = value - - def __delitem__(self, key: str) -> None: - del self._base[self._k(key)] - - def __iter__(self) -> Iterator[str]: - p = self._prefix - plen = len(p) - for k in self._base: - if isinstance(k, str) and k.startswith(p): - yield k[plen:] - - def __len__(self) -> int: - return sum(1 for _ in iter(self)) - - def __contains__(self, key: object) -> bool: - if not isinstance(key, str): - return False - try: - return self._k(key) in self._base - except ValueError: - return False - - def __repr__(self) -> str: - return f"PrefixedStore(prefix={self._prefix!r})" diff --git a/enlace/stores/validation.py b/enlace/stores/validation.py deleted file mode 100644 index 37481d6..0000000 --- a/enlace/stores/validation.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Key sanitization for user-supplied store keys. - -Blocks the standard path-traversal attack surface: ``..`` segments, backslashes, -null bytes, control characters, absolute paths, and URL-encoded variants. Keys -that survive sanitization are still filesystem-safe and can be composed into a -prefix without risk of escaping the tenant namespace. -""" - -from urllib.parse import unquote - -_BAD_SEGMENTS = ("..", "\\", "\x00") - -# URL-encoded forms of ``..``, ``/``, ``\``, ``%`` that attackers use to slip -# traversal past naive validators. -_ENCODED_MARKERS = ("%2e", "%2f", "%5c", "%00", "%25") - - -def sanitize_key(key: str) -> str: - """Return ``key`` unchanged if safe for use as a store path component. - - Raises ``ValueError`` with a specific reason if the key is unsafe. The goal - is fail-fast: we want the caller to see exactly why a key was rejected, not - a silently rewritten value. - """ - if not isinstance(key, str): - raise ValueError(f"key must be a string, got {type(key).__name__}") - if not key: - raise ValueError("key must be non-empty") - if len(key) > 1024: - raise ValueError("key is too long (max 1024 chars)") - - lowered = key.lower() - for marker in _ENCODED_MARKERS: - if marker in lowered: - raise ValueError( - f"key contains URL-encoded marker '{marker}' — " - "decode and sanitize before passing in" - ) - - # Decode once as a defence in depth; if the decoded form differs, the - # caller was trying something clever. Compare case-insensitively so the - # check doesn't reject plain percent-less keys that happened to round-trip. - decoded = unquote(key) - if decoded != key: - raise ValueError("key contains percent-encoding") - - for seg in _BAD_SEGMENTS: - if seg in key: - raise ValueError(f"key contains disallowed substring '{seg!r}'") - - if key.startswith("/") or key.startswith("."): - raise ValueError("key must not start with '/' or '.'") - - for ch in key: - code = ord(ch) - if code < 0x20 or code == 0x7F: - raise ValueError(f"key contains control character U+{code:04X}") - - return key diff --git a/enlace/tests/test_auth_failfast.py b/enlace/tests/test_auth_failfast.py deleted file mode 100644 index a8c2758..0000000 --- a/enlace/tests/test_auth_failfast.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Fail-fast behavior when [auth].enabled and the signing key is unusable. - -Covers the silent-degradation regression from i2mint/enlace#11: if the signing -key env var is missing, the gateway used to boot with /auth/* un-mounted. It -now refuses to start unless the operator sets ENLACE_ALLOW_UNSIGNED=1. -""" - -from __future__ import annotations - -import pytest - -from enlace import EnlaceConfigError, build_backend -from enlace.base import AuthConfig, PlatformConfig -from enlace.discover import discover_apps - -_KEY_ENV = "ENLACE_TEST_SIGNING_KEY" -_OPT_OUT = "ENLACE_ALLOW_UNSIGNED" -_GOOD_KEY = "x" * 48 # any string >= 32 chars passes the length check - - -@pytest.fixture -def auth_config(): - return AuthConfig(enabled=True, signing_key_env=_KEY_ENV, secure_cookies=False) - - -@pytest.fixture -def clean_env(monkeypatch): - for var in (_KEY_ENV, _OPT_OUT): - monkeypatch.delenv(var, raising=False) - yield monkeypatch - - -def _config_with_auth(single_app_dir, auth_cfg): - cfg = PlatformConfig(apps_dir=single_app_dir, auth=auth_cfg) - return discover_apps(cfg) - - -def test_missing_signing_key_raises(clean_env, single_app_dir, auth_config): - """No ENLACE_SIGNING_KEY + auth enabled → EnlaceConfigError at build time.""" - cfg = _config_with_auth(single_app_dir, auth_config) - with pytest.raises(EnlaceConfigError) as exc_info: - build_backend(cfg) - msg = str(exc_info.value) - assert _KEY_ENV in msg - assert "auth-generate-signing-key" in msg - - -def test_empty_signing_key_raises(clean_env, single_app_dir, auth_config): - """Whitespace-only key is treated as empty.""" - clean_env.setenv(_KEY_ENV, " ") - cfg = _config_with_auth(single_app_dir, auth_config) - with pytest.raises(EnlaceConfigError): - build_backend(cfg) - - -def test_short_signing_key_raises(clean_env, single_app_dir, auth_config): - """Keys below the minimum length are rejected as malformed.""" - clean_env.setenv(_KEY_ENV, "too-short") - cfg = _config_with_auth(single_app_dir, auth_config) - with pytest.raises(EnlaceConfigError) as exc_info: - build_backend(cfg) - assert "too short" in str(exc_info.value) - - -def test_opt_out_env_keeps_current_behavior( - clean_env, single_app_dir, auth_config, caplog -): - """ENLACE_ALLOW_UNSIGNED=1 suppresses the raise and logs a loud error.""" - clean_env.setenv(_OPT_OUT, "1") - cfg = _config_with_auth(single_app_dir, auth_config) - with caplog.at_level("ERROR", logger="enlace"): - app = build_backend(cfg) - assert app is not None - # The log line must clearly state that auth was disabled. - joined = "\n".join(r.message for r in caplog.records) - assert "auth" in joined.lower() - assert "disabled" in joined.lower() or "unsigned" in joined.lower() - - -def test_good_key_builds_normally(clean_env, single_app_dir, auth_config): - """A key of sufficient length lets the gateway build as before.""" - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _config_with_auth(single_app_dir, auth_config) - app = build_backend(cfg) - # /auth/csrf must be routable (proof the router was mounted). - from starlette.testclient import TestClient - - client = TestClient(app) - resp = client.get("/auth/csrf") - assert resp.status_code == 200 - assert "csrf" in resp.json() - - -def test_auth_disabled_is_unaffected_by_missing_key(clean_env, single_app_dir): - """When [auth].enabled=False, missing key is fine — no auth wiring at all.""" - cfg = PlatformConfig( - apps_dir=single_app_dir, - auth=AuthConfig(enabled=False, signing_key_env=_KEY_ENV), - ) - cfg = discover_apps(cfg) - app = build_backend(cfg) - assert app is not None diff --git a/enlace/tests/test_auth_middleware.py b/enlace/tests/test_auth_middleware.py deleted file mode 100644 index 3774788..0000000 --- a/enlace/tests/test_auth_middleware.py +++ /dev/null @@ -1,204 +0,0 @@ -"""PlatformAuthMiddleware: path normalization, header stripping, access rules.""" - -import asyncio - -import pytest - -from enlace.auth import SessionStore, sign_cookie -from enlace.auth.middleware import ( - AccessRule, - PlatformAuthMiddleware, - _normalize_path, - _strip_identity_headers, -) - -SIGNING_KEY = "test-signing-key-32bytes-minimumlen" - - -class _Capture: - """Helper: run an ASGI app and capture the response messages.""" - - def __init__(self): - self.messages: list[dict] = [] - self.called_downstream = False - - async def send(self, msg): - self.messages.append(msg) - - async def receive(self): - return {"type": "http.request", "body": b"", "more_body": False} - - def status(self) -> int: - for m in self.messages: - if m["type"] == "http.response.start": - return m["status"] - return -1 - - -async def _ok_app(scope, receive, send): - await send({"type": "http.response.start", "status": 200, "headers": []}) - await send({"type": "http.response.body", "body": b"ok"}) - - -def _run(coro): - return ( - asyncio.get_event_loop().run_until_complete(coro) - if False - else asyncio.run(coro) - ) - - -@pytest.mark.parametrize( - "path,expected", - [ - ("/foo", "/foo"), - ("//foo//bar", "/foo/bar"), - ("/foo/..", None), - ("/foo/%2e%2e/bar", None), - ("/foo/%2e%2e%2fbar", None), - ("/foo/%2f%2fbar", None), - ("/a\\b", None), - ("/a%00b", None), - ], -) -def test_path_normalization(path, expected): - assert _normalize_path(path) == expected - - -def test_strip_identity_headers_removes_spoofed(): - headers = [ - (b"content-type", b"application/json"), - (b"x-user-id", b"attacker"), - (b"X-Forwarded-User", b"bob"), - (b"accept", b"*/*"), - ] - out = _strip_identity_headers(headers) - names = {k.lower() for k, _ in out} - assert b"x-user-id" not in names - assert b"x-forwarded-user" not in names - assert b"content-type" in names - - -def _make_mw(rules=None, session_store=None): - rules = rules or [] - session_store = session_store or SessionStore({}) - return PlatformAuthMiddleware( - _ok_app, - access_rules=rules, - session_store=session_store, - signing_key=SIGNING_KEY, - ) - - -def _http_scope(path, cookies=None): - headers = [] - if cookies: - cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items()) - headers.append((b"cookie", cookie_str.encode())) - return { - "type": "http", - "method": "GET", - "path": path, - "headers": headers, - "state": {}, - } - - -def test_public_app_passes_through(): - mw = _make_mw([AccessRule(prefix="/foo", level="public", app_id="foo")]) - cap = _Capture() - _run(mw(_http_scope("/foo/stuff"), cap.receive, cap.send)) - assert cap.status() == 200 - - -def test_unknown_path_denied_by_default(): - mw = _make_mw([]) - cap = _Capture() - _run(mw(_http_scope("/totally-unknown"), cap.receive, cap.send)) - assert cap.status() == 401 - - -def test_protected_user_without_session_denied(): - mw = _make_mw([AccessRule(prefix="/api/x", level="protected:user", app_id="x")]) - cap = _Capture() - _run(mw(_http_scope("/api/x/thing"), cap.receive, cap.send)) - assert cap.status() == 401 - - -def test_protected_user_with_valid_session_accepted(): - sessions = SessionStore({}) - sid = sessions.create("alice", "alice@x") - token = sign_cookie(sid, SIGNING_KEY, salt="session") - mw = _make_mw( - [AccessRule(prefix="/api/x", level="protected:user", app_id="x")], - session_store=sessions, - ) - cap = _Capture() - _run( - mw( - _http_scope("/api/x/thing", {"enlace_session": token}), - cap.receive, - cap.send, - ) - ) - assert cap.status() == 200 - - -def test_protected_user_with_tampered_cookie_denied(): - sessions = SessionStore({}) - sid = sessions.create("alice", "alice@x") - bad_token = sign_cookie(sid, "different-key", salt="session") - mw = _make_mw( - [AccessRule(prefix="/api/x", level="protected:user", app_id="x")], - session_store=sessions, - ) - cap = _Capture() - _run( - mw( - _http_scope("/api/x/thing", {"enlace_session": bad_token}), - cap.receive, - cap.send, - ) - ) - assert cap.status() == 401 - - -def test_auth_prefix_bypasses_check(): - mw = _make_mw([]) # empty rules: everything else is deny-by-default - cap = _Capture() - _run(mw(_http_scope("/auth/login"), cap.receive, cap.send)) - assert cap.status() == 200 - - -def test_longest_prefix_wins(): - rules = [ - AccessRule(prefix="/api", level="protected:user", app_id="catchall"), - AccessRule(prefix="/api/public", level="public", app_id="public_one"), - ] - mw = _make_mw(rules) - cap = _Capture() - _run(mw(_http_scope("/api/public/ok"), cap.receive, cap.send)) - assert cap.status() == 200 - - -def test_protected_shared_with_valid_cookie(): - rule = AccessRule(prefix="/s", level="protected:shared", app_id="s") - mw = _make_mw([rule]) - token = sign_cookie("1", SIGNING_KEY, salt="shared:s") - cap = _Capture() - _run(mw(_http_scope("/s/page", {"shared_auth_s": token}), cap.receive, cap.send)) - assert cap.status() == 200 - - -def test_protected_shared_without_cookie_denied(): - mw = _make_mw([AccessRule(prefix="/s", level="protected:shared", app_id="s")]) - cap = _Capture() - _run(mw(_http_scope("/s/page"), cap.receive, cap.send)) - assert cap.status() == 401 - - -def test_traversal_path_rejected(): - mw = _make_mw([AccessRule(prefix="/foo", level="public", app_id="foo")]) - cap = _Capture() - _run(mw(_http_scope("/foo/%2e%2e/etc"), cap.receive, cap.send)) - assert cap.status() == 400 diff --git a/enlace/tests/test_base_auth_config.py b/enlace/tests/test_base_auth_config.py deleted file mode 100644 index acfc14b..0000000 --- a/enlace/tests/test_base_auth_config.py +++ /dev/null @@ -1,70 +0,0 @@ -"""TOML parsing for [auth], [auth.stores], [auth.oauth.*], [stores.user_data].""" - -from pathlib import Path - -from enlace.base import PlatformConfig - - -def test_auth_section_parsed(tmp_path: Path): - toml = tmp_path / "platform.toml" - toml.write_text( - """ -[auth] -enabled = true -session_cookie_name = "my_session" -session_max_age_seconds = 3600 -signing_key_env = "MY_KEY" -secure_cookies = false - -[auth.stores] -backend = "file" -path = "/tmp/platform_store" - -[auth.oauth.google] -client_id_env = "GOOGLE_ID" -client_secret_env = "GOOGLE_SECRET" -scopes = ["openid", "email"] - -[stores.user_data] -backend = "file" -path = "/tmp/user_data" -""" - ) - config = PlatformConfig.from_toml(toml) - assert config.auth.enabled is True - assert config.auth.session_cookie_name == "my_session" - assert config.auth.session_max_age_seconds == 3600 - assert config.auth.signing_key_env == "MY_KEY" - assert config.auth.secure_cookies is False - assert config.auth.stores.path == "/tmp/platform_store" - assert "google" in config.auth.oauth - g = config.auth.oauth["google"] - assert g.client_id_env == "GOOGLE_ID" - assert g.scopes == ["openid", "email"] - assert "user_data" in config.stores - assert config.stores["user_data"].path == "/tmp/user_data" - - -def test_auth_defaults_when_section_absent(tmp_path: Path): - toml = tmp_path / "platform.toml" - toml.write_text("") - config = PlatformConfig.from_toml(toml) - assert config.auth.enabled is False - assert config.auth.session_cookie_name == "enlace_session" - assert config.stores == {} - - -def test_shared_password_env_parsed_in_app(tmp_path: Path): - """An app's shared_password_env should round-trip through AppConfig TOML.""" - # Build an AppConfig directly to avoid needing full discovery fixtures. - from enlace.base import AppConfig - - app = AppConfig( - name="secret_app", - route_prefix="/api/secret_app", - app_type="asgi_app", - access="protected:shared", - shared_password_env="SECRET_APP_PW", - ) - assert app.shared_password_env == "SECRET_APP_PW" - assert app.access == "protected:shared" diff --git a/enlace/tests/test_csrf.py b/enlace/tests/test_csrf.py deleted file mode 100644 index a50b28a..0000000 --- a/enlace/tests/test_csrf.py +++ /dev/null @@ -1,110 +0,0 @@ -"""CSRFMiddleware: double-submit accept/reject, exempt paths, cookie issuance.""" - -import asyncio - -from enlace.auth import sign_cookie -from enlace.auth.middleware import CSRFMiddleware - -SIGNING_KEY = "csrf-signing-key-32bytes-minimumlen" - - -class _Cap: - def __init__(self): - self.messages: list[dict] = [] - - async def send(self, msg): - self.messages.append(msg) - - async def receive(self): - return {"type": "http.request", "body": b"", "more_body": False} - - def status(self) -> int: - for m in self.messages: - if m["type"] == "http.response.start": - return m["status"] - return -1 - - def set_cookie_headers(self) -> list[bytes]: - out: list[bytes] = [] - for m in self.messages: - if m["type"] == "http.response.start": - for k, v in m.get("headers", []): - if k.lower() == b"set-cookie": - out.append(v) - return out - - -async def _ok(scope, receive, send): - await send({"type": "http.response.start", "status": 200, "headers": []}) - await send({"type": "http.response.body", "body": b"ok"}) - - -def _scope(method, path, cookies=None, headers=None): - hdrs = list(headers or []) - if cookies: - hdrs.append( - (b"cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()).encode()) - ) - return {"type": "http", "method": method, "path": path, "headers": hdrs} - - -def test_safe_method_sets_cookie_when_missing(): - mw = CSRFMiddleware(_ok, signing_key=SIGNING_KEY) - cap = _Cap() - asyncio.run(mw(_scope("GET", "/"), cap.receive, cap.send)) - assert cap.status() == 200 - headers = cap.set_cookie_headers() - assert any(b"enlace_csrf=" in h for h in headers) - - -def test_state_changing_without_cookie_rejected(): - mw = CSRFMiddleware(_ok, signing_key=SIGNING_KEY) - cap = _Cap() - asyncio.run(mw(_scope("POST", "/x"), cap.receive, cap.send)) - assert cap.status() == 403 - - -def test_state_changing_with_matching_header_accepted(): - raw_token = "abc123" - signed = sign_cookie(raw_token, SIGNING_KEY, salt="csrf") - mw = CSRFMiddleware(_ok, signing_key=SIGNING_KEY) - cap = _Cap() - asyncio.run( - mw( - _scope( - "POST", - "/x", - cookies={"enlace_csrf": signed}, - headers=[(b"x-csrf-token", raw_token.encode())], - ), - cap.receive, - cap.send, - ) - ) - assert cap.status() == 200 - - -def test_state_changing_with_mismatched_header_rejected(): - signed = sign_cookie("abc", SIGNING_KEY, salt="csrf") - mw = CSRFMiddleware(_ok, signing_key=SIGNING_KEY) - cap = _Cap() - asyncio.run( - mw( - _scope( - "POST", - "/x", - cookies={"enlace_csrf": signed}, - headers=[(b"x-csrf-token", b"wrong")], - ), - cap.receive, - cap.send, - ) - ) - assert cap.status() == 403 - - -def test_exempt_path_skips_check(): - mw = CSRFMiddleware(_ok, signing_key=SIGNING_KEY) - cap = _Cap() - asyncio.run(mw(_scope("POST", "/auth/callback/google"), cap.receive, cap.send)) - assert cap.status() == 200 diff --git a/enlace/tests/test_doctor.py b/enlace/tests/test_doctor.py deleted file mode 100644 index a1e1b57..0000000 --- a/enlace/tests/test_doctor.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for enlace.doctor — static checks + HTTP probes. - -HTTP probes are tested against a real ASGI app served by a background -Uvicorn process via starlette's TestClient-less route. We spin up a gateway -in-process via `starlette.testclient.TestClient` for /auth/csrf semantics, -and monkeypatch `doctor._http_get` for the offline cases. -""" - -from __future__ import annotations - -import pytest - -from enlace import doctor as doctor_mod -from enlace.base import AuthConfig, PlatformConfig -from enlace.compose import build_backend -from enlace.discover import discover_apps - -_KEY_ENV = "ENLACE_TEST_SIGNING_KEY" -_GOOD_KEY = "x" * 48 - - -@pytest.fixture -def clean_env(monkeypatch): - for v in (_KEY_ENV, "ENLACE_ALLOW_UNSIGNED"): - monkeypatch.delenv(v, raising=False) - yield monkeypatch - - -def _auth_enabled_config(single_app_dir): - cfg = PlatformConfig( - apps_dir=single_app_dir, - auth=AuthConfig(enabled=True, signing_key_env=_KEY_ENV, secure_cookies=False), - ) - return discover_apps(cfg) - - -def test_static_missing_signing_key_is_fail(clean_env, single_app_dir): - """Static check catches the original incident regardless of HTTP probing.""" - cfg = _auth_enabled_config(single_app_dir) - report = doctor_mod.run_doctor(cfg, base_url=None) - sk = [c for c in report.checks if c.name == "signing_key"][0] - assert sk.status == doctor_mod.FAIL - assert _KEY_ENV in sk.detail - assert not report.ok - - -def test_static_good_signing_key_passes(clean_env, single_app_dir): - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - report = doctor_mod.run_doctor(cfg, base_url=None) - sk = [c for c in report.checks if c.name == "signing_key"][0] - assert sk.status == doctor_mod.PASS - assert report.ok - - -def test_static_skip_when_auth_disabled(clean_env, single_app_dir): - cfg = PlatformConfig( - apps_dir=single_app_dir, - auth=AuthConfig(enabled=False, signing_key_env=_KEY_ENV), - ) - cfg = discover_apps(cfg) - report = doctor_mod.run_doctor(cfg, base_url=None) - sk = [c for c in report.checks if c.name == "signing_key"][0] - assert sk.status == doctor_mod.SKIP - assert report.ok - - -def test_report_format_text_renders(clean_env, single_app_dir): - cfg = _auth_enabled_config(single_app_dir) - report = doctor_mod.run_doctor(cfg, base_url=None) - text = report.format_text() - assert "enlace doctor" in text - assert "signing_key" in text - assert "Result:" in text - - -def test_report_as_dict_shape(clean_env, single_app_dir): - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - report = doctor_mod.run_doctor(cfg, base_url=None) - data = report.as_dict() - assert "ok" in data and "summary" in data and "checks" in data - assert isinstance(data["checks"], list) - assert all({"name", "status", "detail"} <= c.keys() for c in data["checks"]) - - -# --------------------------------------------------------------------------- -# HTTP probe checks — use a patched _http_get rather than a live server to -# keep tests fast and portable. -# --------------------------------------------------------------------------- - - -def _fake_http(map_: dict): - """Build a fake _http_get returning canned responses keyed by URL.""" - - def _get(url, *, timeout): # noqa: ARG001 - resp = map_.get(url) - if resp is None: - return None, {}, None, f"unexpected URL in test: {url}" - return resp # (status, headers, body, error) - - return _get - - -def test_csrf_probe_catches_spa_fallthrough(clean_env, single_app_dir, monkeypatch): - """The canonical regression: /auth/csrf returns the SPA's index.html.""" - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - - spa_html = b"SPA" - monkeypatch.setattr( - doctor_mod, - "_http_get", - _fake_http( - { - "http://x/auth/csrf": ( - 200, - {"content-type": "text/html"}, - spa_html, - None, - ), - "http://x/foo/": (200, {"content-type": "text/html"}, b"", None), - "http://x/api/foo/": ( - 200, - {"content-type": "application/json"}, - b"{}", - None, - ), - } - ), - ) - report = doctor_mod.run_doctor(cfg, base_url="http://x") - csrf = [c for c in report.checks if c.name == "http:/auth/csrf"][0] - assert csrf.status == doctor_mod.FAIL - assert "auth silently disabled" in csrf.detail or "expected JSON" in csrf.detail - assert not report.ok - - -def test_csrf_probe_accepts_valid_json(clean_env, single_app_dir, monkeypatch): - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - - monkeypatch.setattr( - doctor_mod, - "_http_get", - _fake_http( - { - "http://x/auth/csrf": ( - 200, - {"content-type": "application/json"}, - b'{"csrf": "abc123"}', - None, - ), - "http://x/api/foo/": ( - 200, - {"content-type": "application/json"}, - b"{}", - None, - ), - } - ), - ) - report = doctor_mod.run_doctor(cfg, base_url="http://x") - csrf = [c for c in report.checks if c.name == "http:/auth/csrf"][0] - assert csrf.status == doctor_mod.PASS - assert report.ok - - -def test_api_probe_fails_on_5xx(clean_env, single_app_dir, monkeypatch): - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - - monkeypatch.setattr( - doctor_mod, - "_http_get", - _fake_http( - { - "http://x/auth/csrf": ( - 200, - {"content-type": "application/json"}, - b'{"csrf": "ok"}', - None, - ), - "http://x/api/foo/": (503, {}, b"", None), - } - ), - ) - report = doctor_mod.run_doctor(cfg, base_url="http://x") - assert not report.ok - api = [c for c in report.checks if c.name == "http:/api/foo/"][0] - assert api.status == doctor_mod.FAIL - - -def test_live_gateway_roundtrip(clean_env, single_app_dir): - """Smoke: with a good key, /auth/csrf on a real TestClient returns JSON. - - We don't use the doctor's urllib path here (TestClient doesn't serve HTTP); - we just re-verify that build_backend mounts the auth router when the key - is valid — the condition doctor's HTTP probe depends on. - """ - clean_env.setenv(_KEY_ENV, _GOOD_KEY) - cfg = _auth_enabled_config(single_app_dir) - app = build_backend(cfg) - from starlette.testclient import TestClient - - client = TestClient(app) - resp = client.get("/auth/csrf") - assert resp.status_code == 200 - assert resp.headers["content-type"].startswith("application/json") - assert "csrf" in resp.json() diff --git a/enlace/tests/test_oauth.py b/enlace/tests/test_oauth.py deleted file mode 100644 index ad19b7b..0000000 --- a/enlace/tests/test_oauth.py +++ /dev/null @@ -1,96 +0,0 @@ -"""OAuth routes with a mocked Authlib registry.""" - -import os -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi import FastAPI -from fastapi.testclient import TestClient -from starlette.responses import RedirectResponse - -from enlace.auth import SessionStore -from enlace.auth.cookies import verify_cookie -from enlace.base import OAuthProviderConfig - -SIGNING_KEY = "oauth-signing-key-32bytes-minlen" - - -def _make_app(providers, user_store, session_store): - """Build a FastAPI app with the OAuth router and env vars set.""" - os.environ["G_ID"] = "fake-client-id" - os.environ["G_SECRET"] = "fake-client-secret" - from enlace.auth.oauth import make_oauth_router - - # Patch Authlib's OAuth registry so we don't hit the network. - fake_client = MagicMock() - fake_client.authorize_redirect = AsyncMock( - return_value=RedirectResponse("http://example.com/fake-authorize") - ) - fake_client.authorize_access_token = AsyncMock( - return_value={"userinfo": {"email": "alice@example.com"}} - ) - fake_registry = MagicMock() - fake_registry.google = fake_client - - with patch("enlace.auth.oauth._build_oauth_registry", return_value=fake_registry): - router = make_oauth_router( - providers=providers, - session_store=session_store, - user_store=user_store, - signing_key=SIGNING_KEY, - secure_cookies=False, - ) - - app = FastAPI() - app.include_router(router) - return app, fake_client - - -def test_oauth_login_redirects(): - providers = { - "google": OAuthProviderConfig( - client_id_env="G_ID", client_secret_env="G_SECRET" - ) - } - users: dict = {} - sessions = SessionStore({}) - app, _ = _make_app(providers, users, sessions) - client = TestClient(app) - r = client.get("/auth/login/google", follow_redirects=False) - assert r.status_code in (302, 307) - - -def test_oauth_callback_creates_session_and_cookie(): - providers = { - "google": OAuthProviderConfig( - client_id_env="G_ID", client_secret_env="G_SECRET" - ) - } - users: dict = {} - sessions = SessionStore({}) - app, _ = _make_app(providers, users, sessions) - client = TestClient(app) - - r = client.get("/auth/callback/google") - assert r.status_code == 200 - assert "alice@example.com" in users - # A session cookie should be set, pointing at a valid session. - set_cookie = r.headers.get("set-cookie", "") - assert "enlace_session=" in set_cookie - token = set_cookie.split("enlace_session=")[1].split(";")[0] - sid = verify_cookie(token, SIGNING_KEY, salt="session") - assert sid is not None - assert sessions.get(sid) is not None - - -def test_no_providers_returns_none(): - from enlace.auth.oauth import make_oauth_router - - assert ( - make_oauth_router( - providers={}, - session_store=SessionStore({}), - user_store={}, - signing_key=SIGNING_KEY, - ) - is None - ) diff --git a/enlace/tests/test_passwords.py b/enlace/tests/test_passwords.py deleted file mode 100644 index 3a5282d..0000000 --- a/enlace/tests/test_passwords.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Password hashing roundtrip and tamper-resistance.""" - -from enlace.auth.passwords import hash_password, verify_password - - -def test_hash_verify_roundtrip(): - h = hash_password("correct horse battery staple") - assert verify_password(h, "correct horse battery staple") is True - - -def test_wrong_password_rejected(): - h = hash_password("secret") - assert verify_password(h, "guess") is False - - -def test_tampered_hash_rejected(): - h = hash_password("secret") - # Flip a character in the hash to break it. - tampered = h[:-1] + ("a" if h[-1] != "a" else "b") - assert verify_password(tampered, "secret") is False - - -def test_nonsense_hash_rejected(): - assert verify_password("not-a-real-hash", "secret") is False - - -def test_hashes_differ_for_same_password(): - """Salting means two hashes of the same password should differ.""" - assert hash_password("x") != hash_password("x") diff --git a/enlace/tests/test_prefixed_store.py b/enlace/tests/test_prefixed_store.py deleted file mode 100644 index 49c0aa8..0000000 --- a/enlace/tests/test_prefixed_store.py +++ /dev/null @@ -1,84 +0,0 @@ -"""PrefixedStore isolation and key-sanitization tests.""" - -import pytest - -from enlace.stores import PrefixedStore, sanitize_key - - -@pytest.mark.parametrize( - "key", - [ - "..", - "../etc/passwd", - "a/../b", - "a\\b", - "a\x00b", - "\n", - "/etc", - ".hidden", - "%2e%2e", - "a%2fb", - "", - ], -) -def test_sanitize_key_rejects_attacks(key): - with pytest.raises(ValueError): - sanitize_key(key) - - -@pytest.mark.parametrize( - "key", - ["hello", "foo.json", "under_score", "dash-ok", "a/b/c", "with spaces"], -) -def test_sanitize_key_allows_safe(key): - assert sanitize_key(key) == key - - -def test_prefix_isolation(): - base: dict = {} - alice = PrefixedStore(base, "alice/chord/") - bob = PrefixedStore(base, "bob/chord/") - alice["song"] = {"title": "A"} - bob["song"] = {"title": "B"} - assert alice["song"]["title"] == "A" - assert bob["song"]["title"] == "B" - # Underlying keys are fully qualified. - assert set(base.keys()) == {"alice/chord/song", "bob/chord/song"} - - -def test_prefixed_store_iteration_strips_prefix(): - base = {"alice/x/a": 1, "alice/x/b": 2, "bob/x/c": 3} - s = PrefixedStore(base, "alice/x/") - assert set(iter(s)) == {"a", "b"} - assert len(s) == 2 - assert "a" in s and "c" not in s - - -def test_prefixed_store_delete(): - base = {"u/app/k": 1} - s = PrefixedStore(base, "u/app/") - del s["k"] - assert "u/app/k" not in base - - -def test_prefixed_store_rejects_traversal_keys(): - s = PrefixedStore({}, "u/app/") - with pytest.raises(ValueError): - s["../../etc/passwd"] = 1 - with pytest.raises(ValueError): - _ = s["../secret"] - - -def test_prefix_must_be_safe(): - with pytest.raises(ValueError): - PrefixedStore({}, "../evil/") - with pytest.raises(ValueError): - PrefixedStore({}, "") - - -def test_nested_prefixed_store(): - base: dict = {} - outer = PrefixedStore(base, "tenant/") - inner = PrefixedStore(outer, "app/") - inner["key"] = "value" - assert base["tenant/app/key"] == "value" diff --git a/enlace/tests/test_sessions.py b/enlace/tests/test_sessions.py deleted file mode 100644 index 46a013b..0000000 --- a/enlace/tests/test_sessions.py +++ /dev/null @@ -1,46 +0,0 @@ -"""SessionStore roundtrip over a dict backend and file backend.""" - -from enlace.auth import SessionStore -from enlace.stores.backends import make_file_store_factory - - -def test_create_get_delete_dict(): - store = SessionStore({}) - sid = store.create("alice@example.com", "alice@example.com") - got = store.get(sid) - assert got is not None - assert got["user_id"] == "alice@example.com" - assert "created_at" in got - assert store.delete(sid) is True - assert store.get(sid) is None - - -def test_unknown_session_returns_none(): - store = SessionStore({}) - assert store.get("nope") is None - assert store.delete("nope") is False - - -def test_list_all_multiple(): - store = SessionStore({}) - sid1 = store.create("a", "a@x") - sid2 = store.create("b", "b@x") - pairs = dict(store.list_all()) - assert sid1 in pairs and sid2 in pairs - - -def test_session_ids_are_unique(): - store = SessionStore({}) - ids = {store.create("u", None) for _ in range(20)} - assert len(ids) == 20 - - -def test_file_backend_persists(tmp_path): - factory = make_file_store_factory(str(tmp_path)) - store = SessionStore(factory("sessions")) - sid = store.create("alice", "alice@x") - # Rebuild — the store should reload the same data. - factory2 = make_file_store_factory(str(tmp_path)) - store2 = SessionStore(factory2("sessions")) - got = store2.get(sid) - assert got is not None and got["user_id"] == "alice" diff --git a/enlace/tests/test_store_middleware.py b/enlace/tests/test_store_middleware.py deleted file mode 100644 index ff7caa5..0000000 --- a/enlace/tests/test_store_middleware.py +++ /dev/null @@ -1,123 +0,0 @@ -"""StoreInjectionMiddleware and store router.""" - -import asyncio - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from enlace.stores import ( - PrefixedStore, - StoreInjectionMiddleware, - make_store_router, -) - - -class _Probe: - """Terminal ASGI app that records scope state for assertions.""" - - def __init__(self): - self.state = None - - async def __call__(self, scope, receive, send): - self.state = dict(scope.get("state", {})) - await send({"type": "http.response.start", "status": 200, "headers": []}) - await send({"type": "http.response.body", "body": b""}) - - -def _scope(state): - return { - "type": "http", - "method": "GET", - "path": "/x", - "headers": [], - "state": state, - } - - -async def _noop_receive(): - return {"type": "http.request"} - - -async def _noop_send(_): - return - - -def test_injection_with_user_and_app_id(): - base: dict = {} - probe = _Probe() - mw = StoreInjectionMiddleware(probe, base_store=base) - asyncio.run( - mw(_scope({"user_id": "alice", "app_id": "chord"}), _noop_receive, _noop_send) - ) - assert isinstance(probe.state["store"], PrefixedStore) - probe.state["store"]["k"] = 1 - assert base["alice/chord/k"] == 1 - - -def test_injection_none_when_no_user(): - probe = _Probe() - mw = StoreInjectionMiddleware(probe, base_store={}) - asyncio.run( - mw(_scope({"user_id": None, "app_id": "chord"}), _noop_receive, _noop_send) - ) - assert probe.state["store"] is None - - -def test_injection_none_when_no_base(): - probe = _Probe() - mw = StoreInjectionMiddleware(probe, base_store=None) - asyncio.run( - mw(_scope({"user_id": "alice", "app_id": "chord"}), _noop_receive, _noop_send) - ) - assert probe.state["store"] is None - - -def test_injection_none_when_user_id_unsafe(): - probe = _Probe() - mw = StoreInjectionMiddleware(probe, base_store={}) - asyncio.run( - mw( - _scope({"user_id": "../escape", "app_id": "chord"}), - _noop_receive, - _noop_send, - ) - ) - assert probe.state["store"] is None - - -def test_store_router_roundtrip(): - base: dict = {} - app = FastAPI() - - @app.middleware("http") - async def _fake_auth(request, call_next): - request.state.user_id = "alice" - return await call_next(request) - - router = make_store_router( - base_store_getter=lambda: base, - protected_apps={"chord"}, - ) - app.include_router(router) - client = TestClient(app) - - # Put - r = client.put("/api/chord/store/settings", json={"value": {"color": "blue"}}) - assert r.status_code == 200 - # Get - r = client.get("/api/chord/store/settings") - assert r.status_code == 200 - assert r.json()["value"] == {"color": "blue"} - # Missing - r = client.get("/api/chord/store/nope") - assert r.status_code == 404 - # Delete - r = client.delete("/api/chord/store/settings") - assert r.status_code == 200 - # Unknown app - r = client.get("/api/unknown/store/x") - assert r.status_code == 404 - # Unsafe key - r = client.get("/api/chord/store/..") - # FastAPI path matching may decode differently — accept either 400 or 404. - assert r.status_code in (400, 404) diff --git a/pyproject.toml b/pyproject.toml index 62f6c2a..7133d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "enlace" -version = "0.0.12" +version = "0.1.0" description = "Serve all your web apps from a single process — they don't import it, they don't know it's there" readme = "README.md" requires-python = ">=3.10" @@ -28,22 +28,10 @@ enlace = "enlace.__main__:main" [project.optional-dependencies] process = ["httpx>=0.24.0"] -auth = [ - "itsdangerous>=2.1", - "argon2-cffi>=23", - "dol>=0.2", - "email-validator>=2.0", -] -oauth = ["authlib>=1.3", "httpx>=0.24.0"] dev = [ "pytest", "httpx", "pytest-asyncio", - "itsdangerous>=2.1", - "argon2-cffi>=23", - "dol>=0.2", - "authlib>=1.3", - "email-validator>=2.0", ] [tool.pytest.ini_options] @@ -59,9 +47,7 @@ ignore = ["D203"] [tool.ruff.lint.per-file-ignores] "enlace/diagnose.py" = ["E501"] # Long lines in diagnostic prose strings -"tests/test_auth_e2e.py" = ["E501"] # Embedded fixture source with long lines "tests/test_standalone_preservation.py" = ["E501"] -"enlace/tests/test_doctor.py" = ["E501"] # Inline HTTP-mock fixture dicts [project.entry-points."skill.skill_packs"] enlace = "enlace:skills_dir" diff --git a/tests/test_auth_e2e.py b/tests/test_auth_e2e.py deleted file mode 100644 index 525ca27..0000000 --- a/tests/test_auth_e2e.py +++ /dev/null @@ -1,211 +0,0 @@ -"""End-to-end test for the auth + stores pipeline. - -Builds a platform with two apps — one public, one ``protected:user`` — and -exercises register/login/protected-access/store-roundtrip/logout through a -live ``TestClient``. -""" - -from __future__ import annotations - -import textwrap - -import pytest -from starlette.testclient import TestClient - -from enlace.auth import hash_password -from enlace.base import PlatformConfig -from enlace.compose import build_backend -from enlace.discover import discover_apps - - -def _make_apps(apps_dir): - """Write two apps: one public, one protected, plus one shared-password.""" - public = apps_dir / "public_app" - public.mkdir() - (public / "server.py").write_text( - textwrap.dedent( - """ - from fastapi import FastAPI, Request - app = FastAPI() - @app.get("/ping") - def ping(): - return {"ok": True} - @app.get("/who") - def who(request: Request): - return {"user_id": getattr(request.state, "user_id", None)} - """ - ).strip() - ) - - private = apps_dir / "private_app" - private.mkdir() - (private / "server.py").write_text( - textwrap.dedent( - """ - from fastapi import FastAPI, Request - app = FastAPI() - @app.get("/me") - def me(request: Request): - store = getattr(request.state, "store", None) - return { - "user_id": request.state.user_id, - "email": getattr(request.state, "user_email", None), - "store_kind": type(store).__name__ if store is not None else None, - } - """ - ).strip() - ) - (private / "app.toml").write_text('access = "protected:user"\n') - - shared = apps_dir / "shared_app" - shared.mkdir() - (shared / "server.py").write_text( - textwrap.dedent( - """ - from fastapi import FastAPI - app = FastAPI() - @app.get("/peek") - def peek(): - return {"ok": True} - """ - ).strip() - ) - (shared / "app.toml").write_text( - 'access = "protected:shared"\nshared_password_env = "SHARED_APP_PW"\n' - ) - - -@pytest.fixture -def e2e_client(tmp_path, monkeypatch): - apps_dir = tmp_path / "apps" - apps_dir.mkdir() - _make_apps(apps_dir) - - # Signing key + shared password hash live in env vars. - monkeypatch.setenv("ENLACE_SIGNING_KEY", "e2e-key-32bytes-minimumlength!!!") - monkeypatch.setenv("SHARED_APP_PW", hash_password("open-sesame")) - - platform_store = tmp_path / "platform_store" - user_data = tmp_path / "user_data" - - config = PlatformConfig( - apps_dir=apps_dir, - auth={ - "enabled": True, - "secure_cookies": False, - "stores": {"backend": "file", "path": str(platform_store)}, - }, - stores={"user_data": {"backend": "file", "path": str(user_data)}}, - ) - config = discover_apps(config) - app = build_backend(config) - return TestClient(app) - - -def _csrf_pair(client: TestClient) -> tuple[str, str]: - """Do a safe GET so the server issues a CSRF cookie, then extract it.""" - r = client.get("/api/public_app/ping") - assert r.status_code == 200 - signed = client.cookies.get("enlace_csrf") - assert signed is not None - from enlace.auth.cookies import verify_cookie - - raw = verify_cookie(signed, "e2e-key-32bytes-minimumlength!!!", salt="csrf") - assert raw is not None - return raw, signed - - -def test_public_app_accessible_without_login(e2e_client): - r = e2e_client.get("/api/public_app/ping") - assert r.status_code == 200 - assert r.json() == {"ok": True} - - -def test_protected_app_denied_without_session(e2e_client): - r = e2e_client.get("/api/private_app/me") - assert r.status_code == 401 - - -def test_register_login_access_store_logout(e2e_client): - raw, _ = _csrf_pair(e2e_client) - headers = {"X-CSRF-Token": raw} - - r = e2e_client.post( - "/auth/register", - json={"email": "alice@example.com", "password": "secretpw123"}, - headers=headers, - ) - assert r.status_code == 200, r.text - - # Now a protected endpoint works. - r = e2e_client.get("/api/private_app/me") - assert r.status_code == 200 - body = r.json() - assert body["user_id"] == "alice@example.com" - - # Store round-trip via the platform /api/{app}/store endpoint. - r = e2e_client.put( - "/api/private_app/store/settings", - json={"value": {"color": "blue"}}, - headers=headers, - ) - assert r.status_code == 200 - r = e2e_client.get("/api/private_app/store/settings") - assert r.status_code == 200 - assert r.json()["value"] == {"color": "blue"} - - # Logout clears the session. - r = e2e_client.post("/auth/logout", headers=headers) - assert r.status_code == 200 - # Protected access now denied again. - e2e_client.cookies.clear() - r = e2e_client.get("/api/private_app/me") - assert r.status_code == 401 - - -def test_login_wrong_password_rejected(e2e_client): - raw, _ = _csrf_pair(e2e_client) - headers = {"X-CSRF-Token": raw} - r = e2e_client.post( - "/auth/register", - json={"email": "bob@example.com", "password": "correct"}, - headers=headers, - ) - assert r.status_code == 200 - e2e_client.cookies.clear() - raw, _ = _csrf_pair(e2e_client) - headers = {"X-CSRF-Token": raw} - r = e2e_client.post( - "/auth/login", - json={"email": "bob@example.com", "password": "wrong"}, - headers=headers, - ) - assert r.status_code == 401 - - -def test_shared_password_flow(e2e_client): - raw, _ = _csrf_pair(e2e_client) - headers = {"X-CSRF-Token": raw} - r = e2e_client.get("/api/shared_app/peek") - assert r.status_code == 401 - - r = e2e_client.post( - "/auth/shared-login", - json={"app": "shared_app", "password": "open-sesame"}, - headers=headers, - ) - assert r.status_code == 200 - - r = e2e_client.get("/api/shared_app/peek") - assert r.status_code == 200 - - -def test_identity_header_stripped(e2e_client): - """A spoofed X-User-ID header must not leak into request.state.user_id.""" - r = e2e_client.get( - "/api/public_app/who", - headers={"X-User-ID": "attacker"}, - ) - assert r.status_code == 200 - # Public app sets no session, so user_id is None even with header present. - assert r.json()["user_id"] is None