diff --git a/.env.example b/.env.example index ed163bacf..c615a36c7 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,21 @@ # ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.5 # ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.5 # ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.5 + +# =================== +# Web UI Authentication (optional) +# =================== +# Require sign-in through an OpenID Connect provider (Microsoft Entra ID, +# Authentik, Keycloak, ...) before anyone can use the web UI. Recommended +# whenever the UI is reachable from other machines (--host 0.0.0.0). +# Roles (admin / developer / readonly per project) are assigned in +# ~/.autoforge/auth.yaml - see examples/auth.yaml. +# +# AUTOFORGE_AUTH_MODE=oidc +# AUTOFORGE_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +# AUTOFORGE_OIDC_CLIENT_ID= +# AUTOFORGE_OIDC_CLIENT_SECRET= +# AUTOFORGE_OIDC_REDIRECT_URI=https://autoforge.example.com/api/auth/callback +# AUTOFORGE_OIDC_PROVIDER_NAME=Microsoft +# AUTOFORGE_OIDC_GROUP_CLAIMS=groups,roles +# AUTOFORGE_SESSION_TTL_HOURS=12 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0a6eb4c..3be9a2fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: run: ruff check . - name: Run security tests run: python test_security.py + - name: Run auth tests + run: python -m pytest test_auth.py -q ui: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 6f59910be..888b3ec3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,16 @@ The FastAPI server provides REST and WebSocket endpoints for the UI: - `devserver.py` - Dev server control (start/stop) and config - `schedules.py` - CRUD for time-based agent scheduling - `settings.py` - Global settings management (model selection, YOLO, batch size, headless browser) +- `auth.py` - OIDC login/callback/logout and `/api/auth/me` (role per project) + +**Auth** (`server/auth/`, optional, enabled with `AUTOFORGE_AUTH_MODE=oidc`): +- `config.py` - Settings from `AUTOFORGE_AUTH_*` / `AUTOFORGE_OIDC_*` env vars (server refuses to start if invalid) +- `oidc.py` - Authorization code flow + PKCE, ID token verification (PyJWT/JWKS), works with Entra ID, Authentik, Keycloak +- `session.py` - In-memory server-side sessions (`autoforge_session` cookie) +- `roles.py` - `~/.autoforge/auth.yaml` role assignments (admin global; developer/readonly per project, glob patterns), hot-reloaded +- `policy.py` - Single table mapping method + path to required access; unknown `/api` and `/ws` routes require admin +- `middleware.py` - Pure-ASGI `AuthMiddleware` enforcing the policy on HTTP and WebSocket, plus same-origin checks for state-changing requests +- When adding a router, add its paths to `policy.py` (and tests in `test_auth.py`) **Services** (`server/services/`): - `process_manager.py` - Agent process lifecycle management diff --git a/README.md b/README.md index 0ad71c941..bca064ad7 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,57 @@ When test progress increases, the agent sends: } ``` +### Web UI Authentication (OIDC) and Roles + +By default the web UI has no login and only accepts connections from localhost. If you expose it on your network (`--host 0.0.0.0`), you can require sign-in through any OpenID Connect provider, such as **Microsoft Entra ID**, **Authentik** or **Keycloak**, and give people per-project roles. + +| Role | Scope | Can | +|---|---|---| +| `admin` | global | Everything: create/delete projects, global settings, server folder browser | +| `developer` | per project | Start/stop agents, edit features and prompts, terminal, dev server, chats | +| `readonly` | per project | View progress, features, logs and past conversations | + +**1. Register AutoForge with your provider** as a web application (authorization code flow) with the redirect URI `https:///api/auth/callback`. + +- *Entra ID:* App registrations > New registration > Web platform. Create a client secret. For roles, define **App roles** (e.g. `autoforge-admins`, `my-app-devs`) and assign users/groups to them; they arrive in the `roles` claim. +- *Authentik:* Create an OAuth2/OpenID provider + application (confidential client). The default `profile` scope includes the user's group names in the `groups` claim. + +**2. Configure `.env`:** + +```bash +AUTOFORGE_AUTH_MODE=oidc +# Entra ID: https://login.microsoftonline.com//v2.0 +# Authentik: https://authentik.example.com/application/o// +AUTOFORGE_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +AUTOFORGE_OIDC_CLIENT_ID= +AUTOFORGE_OIDC_CLIENT_SECRET= +AUTOFORGE_OIDC_REDIRECT_URI=https://autoforge.example.com/api/auth/callback +AUTOFORGE_OIDC_PROVIDER_NAME=Microsoft # label on the sign-in button +``` + +Optional: `AUTOFORGE_OIDC_SCOPES` (default `openid profile email`), `AUTOFORGE_OIDC_GROUP_CLAIMS` (default `groups,roles`), `AUTOFORGE_SESSION_TTL_HOURS` (default 12), `AUTOFORGE_AUTH_CONFIG` (roles file path), `AUTOFORGE_COOKIE_SECURE`, `AUTOFORGE_AUTH_TRUSTED_ORIGINS` (e.g. `http://localhost:5173` when using the Vite dev server). + +**3. Assign roles** in `~/.autoforge/auth.yaml` (copy [`examples/auth.yaml`](examples/auth.yaml)). The file is reloaded automatically when it changes: + +```yaml +version: 1 +admins: + users: [alice@example.com] + groups: [autoforge-admins] +default_role: none # role on projects not listed below +projects: + "*": + readonly: { groups: [engineering] } + my-app: + developer: { users: [bob@example.com], groups: [my-app-devs] } +``` + +Notes: +- The server refuses to start if `AUTOFORGE_AUTH_MODE=oidc` is set without an issuer and client ID. If the roles file is missing or invalid, signed-in users get no access until it is fixed. +- Sessions are stored in server memory, so everyone signs in again after a restart. +- Serve AutoForge over HTTPS (e.g. behind a reverse proxy) when it is reachable from other machines, so session cookies are not sent in clear text. +- Auth protects the web UI and its API. The agents themselves still run as the OS user that started the server. + ### Alternative API Providers (GLM, Ollama, Kimi, Custom) Alternative providers are configured via the **Settings UI** (gear icon > API Provider). Select your provider, set the base URL, auth token, and model directly in the UI — no `.env` changes needed. diff --git a/examples/auth.yaml b/examples/auth.yaml new file mode 100644 index 000000000..cd11ba269 --- /dev/null +++ b/examples/auth.yaml @@ -0,0 +1,55 @@ +# AutoForge Web UI roles +# ====================== +# +# Copy to ~/.autoforge/auth.yaml (or point AUTOFORGE_AUTH_CONFIG at it) and +# enable sign-in with AUTOFORGE_AUTH_MODE=oidc (see .env.example). +# The file is re-read automatically when it changes; no restart needed. +# If it is missing or invalid, signed-in users get no access (fail closed). +# +# Roles +# admin Everything: create/delete projects, global settings, the +# server folder browser, and developer on every project. +# developer Per project: start/stop agents, edit features and prompts, +# terminal, dev server, spec/expand/assistant chats. +# readonly Per project: view progress, features, logs and conversations. +# +# Matching +# users: matched case-insensitively against the email, +# preferred_username or sub claim. +# groups: matched against the claims in AUTOFORGE_OIDC_GROUP_CLAIMS +# (default "groups,roles"). +# - Entra ID: app role values (recommended) from the "roles" claim, +# or group object IDs from the "groups" claim. +# - Authentik: group names from the "groups" claim. +# +# A user's role on a project is the highest role granted by any entry. + +version: 1 + +admins: + users: + - alice@example.com + groups: + - autoforge-admins + +# Role for signed-in users on projects not granted below: none | readonly | developer +default_role: none + +projects: + # Glob patterns are allowed; "*" matches every project. + "*": + readonly: + groups: + - engineering + + my-app: + developer: + users: + - bob@example.com + groups: + - my-app-devs + + "client-*": + developer: + groups: + - client-team diff --git a/requirements-prod.txt b/requirements-prod.txt index 12d5d327c..a77893211 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -12,6 +12,8 @@ aiofiles>=24.0.0 apscheduler>=3.10.0,<4.0.0 pywinpty>=2.0.0; sys_platform == "win32" pyyaml>=6.0.0 +httpx>=0.27.0 +pyjwt[crypto]>=2.8.0 python-docx>=1.1.0 openpyxl>=3.1.0 PyPDF2>=3.0.0 diff --git a/requirements.txt b/requirements.txt index 23c9afdbd..99462ac86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,8 @@ aiofiles>=24.0.0 apscheduler>=3.10.0,<4.0.0 pywinpty>=2.0.0; sys_platform == "win32" pyyaml>=6.0.0 +httpx>=0.27.0 +pyjwt[crypto]>=2.8.0 python-docx>=1.1.0 openpyxl>=3.1.0 PyPDF2>=3.0.0 diff --git a/server/auth/__init__.py b/server/auth/__init__.py new file mode 100644 index 000000000..af6f15848 --- /dev/null +++ b/server/auth/__init__.py @@ -0,0 +1,40 @@ +""" +Authentication & Authorization +============================== + +Optional OpenID Connect (OIDC) login with a small role system for the web UI. + +Disabled by default: with ``AUTOFORGE_AUTH_MODE`` unset (or ``none``) the +server behaves exactly as before. With ``AUTOFORGE_AUTH_MODE=oidc`` every API +and WebSocket request must carry a valid session, obtained by signing in with +any standards-compliant OIDC provider (Microsoft Entra ID, Authentik, +Keycloak, Google, ...). + +Roles: + +* ``admin`` - global: everything, including creating/deleting projects, + global settings and the server filesystem browser. +* ``developer`` - per project: read and change the project (start agents, + edit features, terminal, dev server, ...). +* ``readonly`` - per project: view the project only. + +Role assignments live in ``~/.autoforge/auth.yaml`` (see +``examples/auth.yaml``) and are re-read whenever the file changes. +""" + +from .config import AuthSettings, get_auth_settings, reset_auth_settings +from .middleware import AuthMiddleware +from .roles import Role, RolesConfig, get_roles_config +from .session import AuthUser, get_session_store + +__all__ = [ + "AuthMiddleware", + "AuthSettings", + "AuthUser", + "Role", + "RolesConfig", + "get_auth_settings", + "get_roles_config", + "get_session_store", + "reset_auth_settings", +] diff --git a/server/auth/config.py b/server/auth/config.py new file mode 100644 index 000000000..3e1dfef02 --- /dev/null +++ b/server/auth/config.py @@ -0,0 +1,131 @@ +""" +Auth settings loaded from environment variables. + +All variables are optional unless ``AUTOFORGE_AUTH_MODE=oidc``: + +================================= ============================================= +``AUTOFORGE_AUTH_MODE`` ``none`` (default) or ``oidc`` +``AUTOFORGE_OIDC_ISSUER`` Issuer URL (required for oidc) +``AUTOFORGE_OIDC_CLIENT_ID`` Client / application ID (required for oidc) +``AUTOFORGE_OIDC_CLIENT_SECRET`` Client secret (omit for public PKCE clients) +``AUTOFORGE_OIDC_REDIRECT_URI`` Callback URL registered with the provider, + e.g. ``https://host/api/auth/callback``. + Derived from the request when unset. +``AUTOFORGE_OIDC_SCOPES`` Default ``openid profile email`` +``AUTOFORGE_OIDC_GROUP_CLAIMS`` Claims holding group/role names, + default ``groups,roles`` +``AUTOFORGE_OIDC_PROVIDER_NAME`` Label for the login button (default ``SSO``) +``AUTOFORGE_AUTH_CONFIG`` Role file, default ``~/.autoforge/auth.yaml`` +``AUTOFORGE_SESSION_TTL_HOURS`` Session lifetime, default 12 +``AUTOFORGE_COOKIE_SECURE`` Force the Secure cookie flag on/off + (default: on when the request is HTTPS) +``AUTOFORGE_AUTH_TRUSTED_ORIGINS`` Extra browser origins allowed to make + state-changing requests (comma-separated), + e.g. ``http://localhost:5173`` for Vite dev +================================= ============================================= +""" + +import os +from dataclasses import dataclass, field +from pathlib import Path + +AUTH_MODE_NONE = "none" +AUTH_MODE_OIDC = "oidc" + + +def _csv(value: str) -> tuple[str, ...]: + return tuple(part.strip() for part in value.split(",") if part.strip()) + + +def _bool_or_none(value: str | None) -> bool | None: + if value is None or value.strip() == "": + return None + return value.strip().lower() in ("1", "true", "yes", "on") + + +@dataclass(frozen=True) +class AuthSettings: + """Immutable snapshot of the auth configuration.""" + + mode: str = AUTH_MODE_NONE + issuer: str = "" + client_id: str = "" + client_secret: str = "" + redirect_uri: str = "" + scopes: str = "openid profile email" + group_claims: tuple[str, ...] = ("groups", "roles") + provider_name: str = "SSO" + roles_file: Path | None = None + session_ttl_seconds: int = 12 * 3600 + cookie_secure: bool | None = None + trusted_origins: tuple[str, ...] = field(default_factory=tuple) + + @property + def enabled(self) -> bool: + return self.mode != AUTH_MODE_NONE + + +def load_auth_settings(env: dict[str, str] | None = None) -> AuthSettings: + """Build settings from environment variables. + + Raises: + RuntimeError: if the configuration is invalid. The server refuses to + start rather than silently running without the requested auth. + """ + env = dict(os.environ) if env is None else env + mode = env.get("AUTOFORGE_AUTH_MODE", "").strip().lower() or AUTH_MODE_NONE + + if mode not in (AUTH_MODE_NONE, AUTH_MODE_OIDC): + raise RuntimeError( + f"Invalid AUTOFORGE_AUTH_MODE={mode!r}; expected 'none' or 'oidc'." + ) + if mode == AUTH_MODE_NONE: + return AuthSettings() + + issuer = env.get("AUTOFORGE_OIDC_ISSUER", "").strip().rstrip("/") + client_id = env.get("AUTOFORGE_OIDC_CLIENT_ID", "").strip() + if not issuer or not client_id: + raise RuntimeError( + "AUTOFORGE_AUTH_MODE=oidc requires AUTOFORGE_OIDC_ISSUER and AUTOFORGE_OIDC_CLIENT_ID." + ) + + try: + ttl_hours = float(env.get("AUTOFORGE_SESSION_TTL_HOURS", "") or 12) + except ValueError as e: + raise RuntimeError("AUTOFORGE_SESSION_TTL_HOURS must be a number.") from e + if ttl_hours <= 0: + raise RuntimeError("AUTOFORGE_SESSION_TTL_HOURS must be positive.") + + roles_file_env = env.get("AUTOFORGE_AUTH_CONFIG", "").strip() + + return AuthSettings( + mode=mode, + issuer=issuer, + client_id=client_id, + client_secret=env.get("AUTOFORGE_OIDC_CLIENT_SECRET", "").strip(), + redirect_uri=env.get("AUTOFORGE_OIDC_REDIRECT_URI", "").strip(), + scopes=env.get("AUTOFORGE_OIDC_SCOPES", "").strip() or "openid profile email", + group_claims=_csv(env.get("AUTOFORGE_OIDC_GROUP_CLAIMS", "")) or ("groups", "roles"), + provider_name=env.get("AUTOFORGE_OIDC_PROVIDER_NAME", "").strip() or "SSO", + roles_file=Path(roles_file_env).expanduser() if roles_file_env else None, + session_ttl_seconds=int(ttl_hours * 3600), + cookie_secure=_bool_or_none(env.get("AUTOFORGE_COOKIE_SECURE")), + trusted_origins=tuple(o.rstrip("/").lower() for o in _csv(env.get("AUTOFORGE_AUTH_TRUSTED_ORIGINS", ""))), + ) + + +_settings: AuthSettings | None = None + + +def get_auth_settings() -> AuthSettings: + """Return the process-wide auth settings (loaded once from the environment).""" + global _settings + if _settings is None: + _settings = load_auth_settings() + return _settings + + +def reset_auth_settings(settings: AuthSettings | None = None) -> None: + """Replace (or clear) the cached settings. Intended for tests.""" + global _settings + _settings = settings diff --git a/server/auth/middleware.py b/server/auth/middleware.py new file mode 100644 index 000000000..554f0ee84 --- /dev/null +++ b/server/auth/middleware.py @@ -0,0 +1,194 @@ +""" +Authentication / authorization ASGI middleware. + +Implemented as raw ASGI (like ``server/utils/ws_security.py``) so that it +guards WebSocket handshakes as well as HTTP requests: Starlette's +``@app.middleware("http")`` never runs for WebSockets. +""" + +import json +import logging +from collections.abc import Awaitable, Callable, MutableMapping +from http.cookies import SimpleCookie +from typing import Any +from urllib.parse import urlparse + +from .config import AuthSettings, get_auth_settings +from .policy import SAFE_METHODS, Access, required_access +from .roles import Role, RolesConfig, get_roles_config +from .session import SESSION_COOKIE, AuthUser, get_session_store + +logger = logging.getLogger(__name__) + +Scope = MutableMapping[str, Any] +Message = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[Message]] +Send = Callable[[Message], Awaitable[None]] + +WS_CLOSE_UNAUTHORIZED = 4401 +WS_CLOSE_FORBIDDEN = 4403 + +_DEFAULT_PORTS = {"http": "80", "https": "443", "ws": "80", "wss": "443"} + + +def get_cookie(scope: Scope, name: str) -> str | None: + for header, value in scope.get("headers", []): + if header == b"cookie": + cookie: SimpleCookie = SimpleCookie() + try: + cookie.load(value.decode("latin-1")) + except Exception: + continue + if name in cookie: + return cookie[name].value + return None + + +def _header(scope: Scope, name: bytes) -> str | None: + for header, value in scope.get("headers", []): + if header == name: + return str(value.decode("latin-1")) + return None + + +def _normalize_netloc(scheme: str, netloc: str) -> str: + netloc = netloc.lower() + default = _DEFAULT_PORTS.get(scheme) + if default and netloc.endswith(f":{default}"): + netloc = netloc[: -len(default) - 1] + return netloc + + +def is_same_origin_request(origin: str | None, host: str | None, trusted_origins: tuple[str, ...] = ()) -> bool: + """Check a browser ``Origin`` against the server the request was sent to. + + Unlike the hostname-only WebSocket check, this compares host *and port*: + generated apps' dev servers run on the same machine (same site, so the + session cookie is sent) and must not be able to drive the API. + Requests without an Origin header (non-browser clients) pass. + """ + if not origin: + return True + origin = origin.rstrip("/").lower() + if origin in trusted_origins: + return True + if not host: + return False + try: + parsed = urlparse(origin) + except ValueError: + return False + if not parsed.scheme or not parsed.netloc: + return False + return _normalize_netloc(parsed.scheme, parsed.netloc) == _normalize_netloc( + "https" if parsed.scheme in ("https", "wss") else "http", host + ) + + +def has_access(user: AuthUser, roles: RolesConfig, access: Access, project: str | None) -> bool: + if access in (Access.PUBLIC, Access.AUTHENTICATED): + return True + if roles.is_admin(user): + return True + if access == Access.ADMIN or project is None: + return False + role = roles.role_for(user, project) + if access == Access.READ: + return role >= Role.READONLY + return role >= Role.DEVELOPER + + +class AuthMiddleware: + """Require a valid session and the right role for API/WebSocket routes. + + A no-op when auth is disabled. On success the signed-in user is exposed + to handlers as ``request.state.auth_user``. + """ + + def __init__(self, app: Any, settings: AuthSettings | None = None) -> None: + self.app = app + self._settings = settings + + @property + def settings(self) -> AuthSettings: + return self._settings or get_auth_settings() + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + settings = self.settings + if scope["type"] not in ("http", "websocket") or not settings.enabled: + await self.app(scope, receive, send) + return + + is_ws = scope["type"] == "websocket" + method = "GET" if is_ws else str(scope.get("method", "GET")).upper() + path = scope.get("path", "") + + if method == "OPTIONS": + await self.app(scope, receive, send) + return + + requirement = required_access(method, path) + if requirement.access == Access.PUBLIC: + await self.app(scope, receive, send) + return + + # CSRF / cross-origin protection for anything that changes state. + if is_ws or method not in SAFE_METHODS: + origin = _header(scope, b"origin") + if not is_same_origin_request(origin, _header(scope, b"host"), settings.trusted_origins): + logger.warning("Rejected cross-origin %s %s from origin %r", method, path, origin) + await self._deny(scope, receive, send, 403, "Cross-origin request rejected") + return + + session = get_session_store().get(get_cookie(scope, SESSION_COOKIE)) + if session is None: + await self._deny(scope, receive, send, 401, "Authentication required") + return + + user = session.user + roles = get_roles_config(settings.roles_file) + if not has_access(user, roles, requirement.access, requirement.project): + logger.info( + "Denied %s %s for %s (needs %s on %s)", + method, path, user.display_name, requirement.access.value, requirement.project or "-", + ) + await self._deny(scope, receive, send, 403, "You do not have permission to do this") + return + + scope.setdefault("state", {})["auth_user"] = user + await self.app(scope, receive, send) + + @staticmethod + async def _deny(scope: Scope, receive: Receive, send: Send, status: int, detail: str) -> None: + if scope["type"] == "websocket": + message = await receive() + if message["type"] == "websocket.connect": + code = WS_CLOSE_UNAUTHORIZED if status == 401 else WS_CLOSE_FORBIDDEN + await send({"type": "websocket.close", "code": code}) + return + + body = json.dumps({"detail": detail}).encode() + await send({ + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) + await send({"type": "http.response.body", "body": body}) + + +def can_read_project(scope: Scope, project: str) -> bool: + """Whether the request's user may see ``project`` (always True with auth off). + + For handlers that return data about several projects (e.g. the project + list), where the path-based policy cannot decide on its own. + """ + settings = get_auth_settings() + if not settings.enabled: + return True + user = scope.get("state", {}).get("auth_user") + if user is None: + return False + return has_access(user, get_roles_config(settings.roles_file), Access.READ, project) diff --git a/server/auth/oidc.py b/server/auth/oidc.py new file mode 100644 index 000000000..ad6d1dbe9 --- /dev/null +++ b/server/auth/oidc.py @@ -0,0 +1,232 @@ +""" +Minimal OpenID Connect relying party (authorization code flow + PKCE). + +Works with any provider that publishes ``/.well-known/openid-configuration``, +for example: + +* Microsoft Entra ID: ``https://login.microsoftonline.com//v2.0`` +* Authentik: ``https://authentik.example.com/application/o//`` +* Keycloak: ``https://keycloak.example.com/realms/`` +""" + +import asyncio +import base64 +import hashlib +import logging +import secrets +import time +from typing import Any +from urllib.parse import urlencode + +import httpx +import jwt + +from .config import AuthSettings +from .session import AuthUser + +logger = logging.getLogger(__name__) + +_DISCOVERY_TTL_SECONDS = 3600 +_HTTP_TIMEOUT = 15.0 +_ASYMMETRIC_ALGS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"] +_SYMMETRIC_ALGS = ["HS256", "HS384", "HS512"] + + +class OIDCError(Exception): + """Raised when any step of the OIDC flow fails.""" + + +def generate_pkce_pair() -> tuple[str, str]: + """Return ``(code_verifier, code_challenge)`` for the S256 method.""" + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + return verifier, challenge + + +class OIDCClient: + def __init__(self, settings: AuthSettings) -> None: + self.settings = settings + self._metadata: dict[str, Any] | None = None + self._metadata_fetched_at = 0.0 + self._jwk_client: jwt.PyJWKClient | None = None + self._lock = asyncio.Lock() + + async def metadata(self) -> dict[str, Any]: + """Fetch (and cache) the provider's discovery document.""" + async with self._lock: + if self._metadata and time.time() - self._metadata_fetched_at < _DISCOVERY_TTL_SECONDS: + return self._metadata + url = f"{self.settings.issuer}/.well-known/openid-configuration" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + response = await client.get(url) + response.raise_for_status() + data: dict[str, Any] = response.json() + except (httpx.HTTPError, ValueError) as e: + raise OIDCError(f"Could not load OIDC discovery document from {url}: {e}") from e + if not isinstance(data, dict): + raise OIDCError("OIDC discovery document is not a JSON object") + + for key in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"): + if not data.get(key): + raise OIDCError(f"OIDC discovery document is missing '{key}'") + if str(data["issuer"]).rstrip("/") != self.settings.issuer: + raise OIDCError( + f"Issuer mismatch: configured {self.settings.issuer!r}, provider says {data['issuer']!r}" + ) + + if self._metadata is None or self._metadata.get("jwks_uri") != data["jwks_uri"]: + self._jwk_client = jwt.PyJWKClient(data["jwks_uri"], cache_keys=True, timeout=int(_HTTP_TIMEOUT)) + self._metadata = data + self._metadata_fetched_at = time.time() + return data + + async def authorization_url(self, redirect_uri: str, state: str, nonce: str, code_challenge: str) -> str: + meta = await self.metadata() + params = { + "response_type": "code", + "client_id": self.settings.client_id, + "redirect_uri": redirect_uri, + "scope": self.settings.scopes, + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + separator = "&" if "?" in meta["authorization_endpoint"] else "?" + return f"{meta['authorization_endpoint']}{separator}{urlencode(params)}" + + async def end_session_url(self, id_token: str, post_logout_redirect_uri: str) -> str | None: + """RP-initiated logout URL, if the provider supports it.""" + try: + meta = await self.metadata() + except OIDCError: + return None + endpoint = meta.get("end_session_endpoint") + if not endpoint: + return None + params = {"client_id": self.settings.client_id, "post_logout_redirect_uri": post_logout_redirect_uri} + if id_token: + params["id_token_hint"] = id_token + separator = "&" if "?" in endpoint else "?" + return f"{endpoint}{separator}{urlencode(params)}" + + async def exchange_code(self, code: str, code_verifier: str, redirect_uri: str) -> dict[str, Any]: + meta = await self.metadata() + form = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self.settings.client_id, + "code_verifier": code_verifier, + } + if self.settings.client_secret: + form["client_secret"] = self.settings.client_secret + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + response = await client.post(meta["token_endpoint"], data=form, headers={"Accept": "application/json"}) + except httpx.HTTPError as e: + raise OIDCError(f"Token request failed: {e}") from e + if response.status_code != 200: + raise OIDCError(f"Token endpoint returned HTTP {response.status_code}: {response.text[:300]}") + try: + tokens: dict[str, Any] = response.json() + except ValueError as e: + raise OIDCError("Token endpoint returned invalid JSON") from e + if not tokens.get("id_token"): + raise OIDCError("Token response did not include an id_token (is the 'openid' scope enabled?)") + return tokens + + async def validate_id_token(self, id_token: str, nonce: str) -> dict[str, Any]: + """Verify the ID token signature and standard claims.""" + await self.metadata() + try: + header = jwt.get_unverified_header(id_token) + except jwt.PyJWTError as e: + raise OIDCError(f"Malformed id_token: {e}") from e + + alg = header.get("alg", "") + key: Any + if alg in _SYMMETRIC_ALGS: + # e.g. Authentik without a signing key: HMAC with the client secret + if not self.settings.client_secret: + raise OIDCError(f"id_token uses {alg} but no client secret is configured") + key, algorithms = self.settings.client_secret, _SYMMETRIC_ALGS + elif alg in _ASYMMETRIC_ALGS: + assert self._jwk_client is not None # set by metadata() + try: + key = (await asyncio.to_thread(self._jwk_client.get_signing_key_from_jwt, id_token)).key + except jwt.PyJWTError as e: + raise OIDCError(f"Could not fetch id_token signing key: {e}") from e + algorithms = _ASYMMETRIC_ALGS + else: + raise OIDCError(f"Unsupported id_token algorithm {alg!r}") + + try: + claims: dict[str, Any] = jwt.decode( + id_token, + key=key, + algorithms=algorithms, + audience=self.settings.client_id, + leeway=60, + options={"require": ["exp", "iat", "sub", "iss"]}, + ) + except jwt.PyJWTError as e: + raise OIDCError(f"Invalid id_token: {e}") from e + + # Compared manually because some providers (Authentik) put a trailing + # slash in "iss" while the configured issuer is stored without one. + if str(claims["iss"]).rstrip("/") != self.settings.issuer: + raise OIDCError("Invalid id_token: issuer mismatch") + if not secrets.compare_digest(str(claims.get("nonce", "")), nonce): + raise OIDCError("Invalid id_token: nonce mismatch") + return claims + + async def userinfo(self, access_token: str) -> dict[str, Any]: + """Fetch extra claims (e.g. groups) from the userinfo endpoint. Best effort.""" + try: + meta = await self.metadata() + except OIDCError: + return {} + endpoint = meta.get("userinfo_endpoint") + if not endpoint or not access_token: + return {} + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + response = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"}) + if response.status_code != 200: + return {} + data = response.json() + return data if isinstance(data, dict) else {} + except (httpx.HTTPError, ValueError): + logger.debug("userinfo request failed", exc_info=True) + return {} + + +def user_from_claims(claims: dict[str, Any], group_claims: tuple[str, ...]) -> AuthUser: + """Build an AuthUser from ID token (and userinfo) claims.""" + groups: list[str] = [] + for claim in group_claims: + value = claims.get(claim) + if isinstance(value, str): + value = [value] + if isinstance(value, list): + groups.extend(str(v) for v in value if str(v) and str(v) not in groups) + + return AuthUser( + sub=str(claims["sub"]), + email=str(claims.get("email") or ""), + username=str(claims.get("preferred_username") or claims.get("upn") or ""), + name=str(claims.get("name") or ""), + groups=tuple(groups), + ) + + +_client: OIDCClient | None = None + + +def get_oidc_client(settings: AuthSettings) -> OIDCClient: + global _client + if _client is None or _client.settings != settings: + _client = OIDCClient(settings) + return _client diff --git a/server/auth/policy.py b/server/auth/policy.py new file mode 100644 index 000000000..eede8dedd --- /dev/null +++ b/server/auth/policy.py @@ -0,0 +1,89 @@ +""" +Route access policy. + +Maps an HTTP method + path (or WebSocket path) to the access level required +to use it. Keeping the whole policy in one table makes it easy to audit, and +anything not listed under ``/api`` or ``/ws`` requires admin (deny by +default), so a new router cannot accidentally become readable by everyone. +""" + +import re +from dataclasses import dataclass +from enum import Enum + +SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + + +class Access(Enum): + PUBLIC = "public" # no session needed (login flow, health, static UI) + AUTHENTICATED = "authenticated" # any signed-in user + READ = "read" # readonly or developer on the project + WRITE = "write" # developer on the project + ADMIN = "admin" # global admin + + +@dataclass(frozen=True) +class Requirement: + access: Access + project: str | None = None + + +# Shorthands for the rule table: (read access, write access) +_READ_WRITE = (Access.READ, Access.WRITE) +_WRITE_ONLY = (Access.WRITE, Access.WRITE) +_ADMIN_ONLY = (Access.ADMIN, Access.ADMIN) + +_P = r"(?P[^/]+)" + +# (pattern, (access for safe methods, access for other methods)). +# First match wins. ``project`` group names the project the rule is scoped to. +_RULES: list[tuple[re.Pattern[str], tuple[Access, Access]]] = [ + (re.compile(p), access) + for p, access in [ + # Auth endpoints and health check are public + (r"^/api/auth(/.*)?$", (Access.PUBLIC, Access.PUBLIC)), + (r"^/api/health$", (Access.PUBLIC, Access.PUBLIC)), + # Global, non-project endpoints + (r"^/api/setup/status$", (Access.AUTHENTICATED, Access.ADMIN)), + (r"^/api/settings(/.*)?$", (Access.AUTHENTICATED, Access.ADMIN)), + # The project list is filtered per user inside the router + (r"^/api/projects/?$", (Access.AUTHENTICATED, Access.ADMIN)), + (r"^/api/filesystem(/.*)?$", _ADMIN_ONLY), + (r"^/api/scaffold(/.*)?$", _ADMIN_ONLY), + # Project resources + (rf"^/api/projects/{_P}/?$", (Access.READ, Access.ADMIN)), # GET detail / DELETE project + (rf"^/api/projects/{_P}/(prompts|stats|settings|reset)/?$", _READ_WRITE), + (rf"^/api/projects/{_P}/(agent|features|schedules|devserver)(/.*)?$", _READ_WRITE), + (rf"^/ws/projects/{_P}$", (Access.READ, Access.READ)), + # Interactive sessions that run Claude or a shell require developer + (rf"^/api/terminal/ws/{_P}/[^/]+$", _WRITE_ONLY), + (rf"^/api/terminal/{_P}(/.*)?$", _WRITE_ONLY), + (rf"^/api/spec/ws/{_P}$", _WRITE_ONLY), + (rf"^/api/spec/(sessions|status)/{_P}$", _READ_WRITE), + (rf"^/api/expand/ws/{_P}$", _WRITE_ONLY), + (rf"^/api/expand/sessions/{_P}$", _READ_WRITE), + # The assistant can create features, so chatting requires developer + (rf"^/api/assistant/ws/{_P}$", _WRITE_ONLY), + (rf"^/api/assistant/sessions/{_P}$", _READ_WRITE), + (rf"^/api/assistant/conversations/{_P}(/.*)?$", _READ_WRITE), + ] +] + + +def required_access(method: str, path: str) -> Requirement: + """Return the access needed for ``method`` on ``path``. + + WebSocket handshakes should pass ``"GET"`` as the method. + """ + if not (path == "/api" or path.startswith("/api/") or path == "/ws" or path.startswith("/ws/")): + # Static UI assets and SPA routes: the login page must load. + return Requirement(Access.PUBLIC) + + safe = method.upper() in SAFE_METHODS + for pattern, (read_access, write_access) in _RULES: + match = pattern.match(path) + if match: + project = match.groupdict().get("project") + return Requirement(read_access if safe else write_access, project) + + return Requirement(Access.ADMIN) diff --git a/server/auth/roles.py b/server/auth/roles.py new file mode 100644 index 000000000..89a0056ea --- /dev/null +++ b/server/auth/roles.py @@ -0,0 +1,204 @@ +""" +Role assignments (``~/.autoforge/auth.yaml``). + +Example:: + + version: 1 + # Full access to everything: create/delete projects, global settings, + # the server filesystem browser, and developer on every project. + admins: + users: [alice@example.com] + groups: [autoforge-admins] + + # Role every signed-in user gets on projects not granted below: + # none (default) | readonly | developer + default_role: none + + projects: + "*": # glob patterns match project names + readonly: + groups: [engineering] + my-app: + developer: + users: [bob@example.com] + groups: [my-app-devs] + +Users are matched (case-insensitively) on their ``email``, +``preferred_username`` or ``sub`` claim; groups on the claims listed in +``AUTOFORGE_OIDC_GROUP_CLAIMS`` (Entra ID app roles / group object IDs, +Authentik group names, ...). A user's role on a project is the highest role +granted by any matching entry. +""" + +import fnmatch +import logging +import threading +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path +from typing import Any + +import yaml + +from .session import AuthUser + +logger = logging.getLogger(__name__) + + +class Role(IntEnum): + """Per-project role. Ordered so that a higher value grants more.""" + + NONE = 0 + READONLY = 1 + DEVELOPER = 2 + + @property + def label(self) -> str: + return self.name.lower() + + @classmethod + def parse(cls, value: Any) -> "Role": + text = str(value).strip().lower().replace("-", "").replace("_", "") + aliases = { + "none": cls.NONE, + "readonly": cls.READONLY, + "read": cls.READONLY, + "viewer": cls.READONLY, + "developer": cls.DEVELOPER, + "dev": cls.DEVELOPER, + "write": cls.DEVELOPER, + } + if text not in aliases: + raise ValueError(f"Unknown role {value!r}; expected none, readonly or developer") + return aliases[text] + + +@dataclass(frozen=True) +class Subjects: + """A set of users and groups an assignment applies to.""" + + users: frozenset[str] = frozenset() + groups: frozenset[str] = frozenset() + + def matches(self, user: AuthUser) -> bool: + if self.users and self.users & user.identifiers(): + return True + return bool(self.groups and self.groups & set(user.groups)) + + +@dataclass(frozen=True) +class ProjectGrant: + pattern: str + role: Role + subjects: Subjects + + +@dataclass(frozen=True) +class RolesConfig: + admins: Subjects = field(default_factory=Subjects) + default_role: Role = Role.NONE + grants: tuple[ProjectGrant, ...] = () + + def is_admin(self, user: AuthUser) -> bool: + return self.admins.matches(user) + + def role_for(self, user: AuthUser, project: str) -> Role: + """Effective role of ``user`` on ``project``.""" + if self.is_admin(user): + return Role.DEVELOPER + role = self.default_role + for grant in self.grants: + if grant.role > role and fnmatch.fnmatchcase(project, grant.pattern) and grant.subjects.matches(user): + role = grant.role + return role + + +def _parse_subjects(raw: Any, where: str) -> Subjects: + if raw is None: + return Subjects() + if not isinstance(raw, dict): + raise ValueError(f"{where}: expected a mapping with 'users' and/or 'groups'") + + def _names(key: str, casefold: bool) -> frozenset[str]: + values = raw.get(key) or [] + if isinstance(values, str): + values = [values] + if not isinstance(values, list): + raise ValueError(f"{where}.{key}: expected a list") + return frozenset(str(v).strip().lower() if casefold else str(v).strip() for v in values if str(v).strip()) + + return Subjects(users=_names("users", casefold=True), groups=_names("groups", casefold=False)) + + +def parse_roles_config(data: Any) -> RolesConfig: + """Validate and convert the parsed YAML document into a RolesConfig.""" + if data is None: + return RolesConfig() + if not isinstance(data, dict): + raise ValueError("auth config must be a mapping") + + admins = _parse_subjects(data.get("admins"), "admins") + default_role = Role.parse(data.get("default_role", "none")) + + grants: list[ProjectGrant] = [] + projects = data.get("projects") or {} + if not isinstance(projects, dict): + raise ValueError("projects: expected a mapping of project name/pattern to roles") + for pattern, roles in projects.items(): + if not isinstance(roles, dict): + raise ValueError(f"projects.{pattern}: expected a mapping of role to users/groups") + for role_name, subjects in roles.items(): + role = Role.parse(role_name) + grants.append(ProjectGrant(str(pattern), role, _parse_subjects(subjects, f"projects.{pattern}.{role_name}"))) + + return RolesConfig(admins=admins, default_role=default_role, grants=tuple(grants)) + + +class _RolesFileCache: + """Loads the roles file and reloads it when its mtime changes. + + An unreadable or invalid file yields an empty config (nobody has access) + rather than keeping stale grants, so mistakes fail closed. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._path: Path | None = None + self._mtime: float | None = None + self._config = RolesConfig() + + def get(self, path: Path) -> RolesConfig: + try: + mtime: float | None = path.stat().st_mtime + except OSError: + mtime = None + + with self._lock: + if path == self._path and mtime == self._mtime: + return self._config + self._path, self._mtime = path, mtime + if mtime is None: + logger.warning("Auth roles file %s not found; no user has access until it is created", path) + self._config = RolesConfig() + return self._config + try: + self._config = parse_roles_config(yaml.safe_load(path.read_text(encoding="utf-8"))) + logger.info("Loaded auth roles from %s", path) + except (OSError, ValueError, yaml.YAMLError) as e: + logger.error("Invalid auth roles file %s: %s; denying access until fixed", path, e) + self._config = RolesConfig() + return self._config + + +_cache = _RolesFileCache() + + +def default_roles_path() -> Path: + from registry import get_config_dir + + return get_config_dir() / "auth.yaml" + + +def get_roles_config(path: Path | None = None) -> RolesConfig: + """Return the current role assignments, reloading the file if it changed.""" + return _cache.get(path or default_roles_path()) diff --git a/server/auth/session.py b/server/auth/session.py new file mode 100644 index 000000000..446edaec9 --- /dev/null +++ b/server/auth/session.py @@ -0,0 +1,121 @@ +""" +Server-side session storage. + +Sessions are kept in memory and identified by a random, unguessable cookie +value. Keeping them server-side means logout truly revokes a session and the +cookie stays small no matter how many groups the identity provider returns. +Sessions do not survive a server restart; users simply sign in again. +""" + +import secrets +import threading +import time +from dataclasses import dataclass, field + +SESSION_COOKIE = "autoforge_session" +LOGIN_STATE_COOKIE = "autoforge_login_state" + +# Pending logins (between redirect to the provider and the callback). +LOGIN_TTL_SECONDS = 600 + + +@dataclass(frozen=True) +class AuthUser: + """Identity of a signed-in user, taken from the OIDC claims.""" + + sub: str + email: str = "" + username: str = "" + name: str = "" + groups: tuple[str, ...] = () + + @property + def display_name(self) -> str: + return self.name or self.email or self.username or self.sub + + def identifiers(self) -> set[str]: + """Lower-cased values a roles file may use to refer to this user.""" + return {v.lower() for v in (self.sub, self.email, self.username) if v} + + +@dataclass +class Session: + user: AuthUser + expires_at: float + id_token: str = "" + + +@dataclass +class PendingLogin: + nonce: str + code_verifier: str + redirect_uri: str + next_path: str + expires_at: float = field(default_factory=lambda: time.time() + LOGIN_TTL_SECONDS) + + +class SessionStore: + """Thread-safe in-memory store for sessions and pending logins.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sessions: dict[str, Session] = {} + self._pending: dict[str, PendingLogin] = {} + + def _prune(self, now: float) -> None: + for sid in [k for k, s in self._sessions.items() if s.expires_at <= now]: + del self._sessions[sid] + for state in [k for k, p in self._pending.items() if p.expires_at <= now]: + del self._pending[state] + + def create(self, user: AuthUser, ttl_seconds: int, id_token: str = "") -> str: + sid = secrets.token_urlsafe(32) + now = time.time() + with self._lock: + self._prune(now) + self._sessions[sid] = Session(user=user, expires_at=now + ttl_seconds, id_token=id_token) + return sid + + def get(self, sid: str | None) -> Session | None: + if not sid: + return None + now = time.time() + with self._lock: + session = self._sessions.get(sid) + if session is None: + return None + if session.expires_at <= now: + del self._sessions[sid] + return None + return session + + def delete(self, sid: str | None) -> Session | None: + if not sid: + return None + with self._lock: + return self._sessions.pop(sid, None) + + def add_pending(self, state: str, pending: PendingLogin) -> None: + with self._lock: + self._prune(time.time()) + self._pending[state] = pending + + def pop_pending(self, state: str) -> PendingLogin | None: + """Consume a pending login (single use).""" + with self._lock: + pending = self._pending.pop(state, None) + if pending is None or pending.expires_at <= time.time(): + return None + return pending + + def clear(self) -> None: + with self._lock: + self._sessions.clear() + self._pending.clear() + + +_store = SessionStore() + + +def get_session_store() -> SessionStore: + return _store diff --git a/server/main.py b/server/main.py index 1ea369764..97377ed79 100644 --- a/server/main.py +++ b/server/main.py @@ -40,9 +40,11 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from .auth import AuthMiddleware, get_auth_settings from .routers import ( agent_router, assistant_chat_router, + auth_router, devserver_router, expand_project_router, features_router, @@ -127,12 +129,20 @@ async def lifespan(app: FastAPI): "Only use this in trusted network environments." ) +# Optional OIDC authentication (AUTOFORGE_AUTH_MODE=oidc). Loaded here so an +# invalid configuration stops the server instead of running unprotected. +AUTH_SETTINGS = get_auth_settings() +if AUTH_SETTINGS.enabled: + logger.info("Authentication enabled (OIDC issuer: %s)", AUTH_SETTINGS.issuer) + # CORS - allow all origins when remote access is enabled, otherwise localhost only if ALLOW_REMOTE: app.add_middleware( CORSMiddleware, allow_origins=["*"], # Allow all origins for remote access - allow_credentials=True, + # With auth on, never let other origins make credentialed requests: + # the UI is same-origin and does not need CORS. + allow_credentials=not AUTH_SETTINGS.enabled, allow_methods=["*"], allow_headers=["*"], ) @@ -162,6 +172,10 @@ async def lifespan(app: FastAPI): # Registered unconditionally so it applies even when AUTOFORGE_ALLOW_REMOTE=1. app.add_middleware(WebSocketOriginMiddleware, allow_remote=ALLOW_REMOTE) +# Session + role checks for every API and WebSocket route (no-op when auth is +# disabled). Pure ASGI so it also covers WebSocket handshakes. +app.add_middleware(AuthMiddleware, settings=AUTH_SETTINGS) + if not ALLOW_REMOTE: @app.middleware("http") async def require_localhost(request: Request, call_next): @@ -179,6 +193,7 @@ async def require_localhost(request: Request, call_next): # Include Routers # ============================================================================ +app.include_router(auth_router) app.include_router(projects_router) app.include_router(features_router) app.include_router(agent_router) diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 58f2d00dd..a5abf92a5 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -7,6 +7,7 @@ from .agent import router as agent_router from .assistant_chat import router as assistant_chat_router +from .auth import router as auth_router from .devserver import router as devserver_router from .expand_project import router as expand_project_router from .features import router as features_router @@ -19,6 +20,7 @@ from .terminal import router as terminal_router __all__ = [ + "auth_router", "projects_router", "features_router", "agent_router", diff --git a/server/routers/auth.py b/server/routers/auth.py new file mode 100644 index 000000000..98b16ba75 --- /dev/null +++ b/server/routers/auth.py @@ -0,0 +1,184 @@ +""" +Auth Router +=========== + +OIDC login/logout and "who am I" endpoints. All routes here are public in +the access policy; they work (and report ``enabled: false``) when auth is off. +""" + +import html +import logging +import secrets + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response + +from ..auth.config import AuthSettings, get_auth_settings +from ..auth.middleware import get_cookie +from ..auth.oidc import OIDCError, generate_pkce_pair, get_oidc_client, user_from_claims +from ..auth.roles import get_roles_config +from ..auth.session import LOGIN_STATE_COOKIE, LOGIN_TTL_SECONDS, SESSION_COOKIE, PendingLogin, get_session_store +from ..schemas import AuthMeResponse, AuthUserInfo, LogoutResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _require_enabled() -> AuthSettings: + settings = get_auth_settings() + if not settings.enabled: + raise HTTPException(status_code=404, detail="Authentication is not enabled") + return settings + + +def _safe_next(next_path: str | None) -> str: + """Only allow same-site relative redirects after login (no open redirect).""" + if not next_path or not next_path.startswith("/") or next_path.startswith("//") or "\\" in next_path: + return "/" + return next_path + + +def _redirect_uri(request: Request, settings: AuthSettings) -> str: + return settings.redirect_uri or str(request.url_for("auth_callback")) + + +def _cookie_secure(request: Request, settings: AuthSettings, redirect_uri: str = "") -> bool: + if settings.cookie_secure is not None: + return settings.cookie_secure + return request.url.scheme == "https" or redirect_uri.startswith("https://") + + +def _error_page(message: str, status_code: int = 400) -> HTMLResponse: + body = ( + "Sign-in failed" + "" + f"

Sign-in failed

{html.escape(message)}

" + "

Try again

" + ) + return HTMLResponse(body, status_code=status_code) + + +@router.get("/me", response_model=AuthMeResponse) +async def me(request: Request): + """Return the auth status of the caller and their role on each project.""" + settings = get_auth_settings() + if not settings.enabled: + return AuthMeResponse(enabled=False, authenticated=False) + + session = get_session_store().get(get_cookie(request.scope, SESSION_COOKIE)) + if session is None: + return AuthMeResponse(enabled=True, authenticated=False, provider_name=settings.provider_name) + + from registry import list_registered_projects + + user = session.user + roles = get_roles_config(settings.roles_file) + project_roles = {name: roles.role_for(user, name).label for name in list_registered_projects()} + + return AuthMeResponse( + enabled=True, + authenticated=True, + provider_name=settings.provider_name, + user=AuthUserInfo( + sub=user.sub, + name=user.display_name, + email=user.email or None, + username=user.username or None, + groups=list(user.groups), + ), + is_admin=roles.is_admin(user), + project_roles=project_roles, + ) + + +@router.get("/login") +async def login(request: Request, next: str | None = None): + """Start the OIDC authorization code flow.""" + settings = _require_enabled() + client = get_oidc_client(settings) + + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + verifier, challenge = generate_pkce_pair() + redirect_uri = _redirect_uri(request, settings) + + try: + url = await client.authorization_url(redirect_uri, state, nonce, challenge) + except OIDCError as e: + logger.error("OIDC login failed: %s", e) + return _error_page(f"The identity provider is unavailable: {e}", status_code=502) + + get_session_store().add_pending( + state, PendingLogin(nonce=nonce, code_verifier=verifier, redirect_uri=redirect_uri, next_path=_safe_next(next)) + ) + response = RedirectResponse(url, status_code=302) + # Binds the login to this browser so a forged callback cannot log the + # victim into the attacker's account (login CSRF). + response.set_cookie( + LOGIN_STATE_COOKIE, state, max_age=LOGIN_TTL_SECONDS, httponly=True, samesite="lax", + secure=_cookie_secure(request, settings, redirect_uri), path="/api/auth", + ) + return response + + +@router.get("/callback", name="auth_callback") +async def callback( + request: Request, + code: str | None = None, + state: str | None = None, + error: str | None = None, + error_description: str | None = None, +): + """Handle the redirect back from the identity provider.""" + settings = _require_enabled() + store = get_session_store() + + if error: + return _error_page(f"{error}: {error_description or 'the identity provider rejected the request'}") + if not code or not state: + return _error_page("Missing code or state parameter.") + + browser_state = get_cookie(request.scope, LOGIN_STATE_COOKIE) or "" + pending = store.pop_pending(state) + if pending is None or not secrets.compare_digest(browser_state, state): + return _error_page("This sign-in link has expired or was not started from this browser.") + + client = get_oidc_client(settings) + try: + tokens = await client.exchange_code(code, pending.code_verifier, pending.redirect_uri) + claims = await client.validate_id_token(tokens["id_token"], pending.nonce) + if not any(claim in claims for claim in settings.group_claims): + # Some providers only expose groups via userinfo. + extra = await client.userinfo(str(tokens.get("access_token", ""))) + if extra.get("sub") == claims["sub"]: + claims = {**extra, **claims} + except OIDCError as e: + logger.warning("OIDC callback failed: %s", e) + return _error_page(str(e)) + + user = user_from_claims(claims, settings.group_claims) + sid = store.create(user, settings.session_ttl_seconds, id_token=str(tokens["id_token"])) + logger.info("User %s signed in (groups: %s)", user.display_name, ", ".join(user.groups) or "-") + + response = RedirectResponse(pending.next_path, status_code=302) + response.set_cookie( + SESSION_COOKIE, sid, max_age=settings.session_ttl_seconds, httponly=True, samesite="lax", + secure=_cookie_secure(request, settings, pending.redirect_uri), path="/", + ) + response.delete_cookie(LOGIN_STATE_COOKIE, path="/api/auth") + return response + + +@router.post("/logout", response_model=LogoutResponse) +async def logout(request: Request, response: Response): + """End the session. Returns the provider's logout URL when it has one.""" + settings = get_auth_settings() + session = get_session_store().delete(get_cookie(request.scope, SESSION_COOKIE)) + response.delete_cookie(SESSION_COOKIE, path="/") + + logout_url = None + if settings.enabled and session is not None: + home = str(request.base_url) + logout_url = await get_oidc_client(settings).end_session_url(session.id_token, home) + return LogoutResponse(logout_url=logout_url) diff --git a/server/routers/projects.py b/server/routers/projects.py index ff0e38b8a..f2d8d6285 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -12,8 +12,9 @@ from pathlib import Path from typing import Any, Callable -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request +from ..auth.middleware import can_read_project from ..schemas import ( ProjectCreate, ProjectDetail, @@ -117,8 +118,8 @@ def get_project_stats(project_dir: Path) -> ProjectStats: @router.get("", response_model=list[ProjectSummary]) -async def list_projects(): - """List all registered projects.""" +async def list_projects(request: Request): + """List all registered projects the caller may see.""" _init_imports() assert _check_spec_exists is not None # guaranteed by _init_imports() (_, _, _, list_registered_projects, validate_project_path, @@ -128,6 +129,9 @@ async def list_projects(): result = [] for name, info in projects.items(): + if not can_read_project(request.scope, name): + continue + project_dir = Path(info["path"]) # Skip if path no longer exists diff --git a/server/schemas.py b/server/schemas.py index d54f00aa9..72cda5f01 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -717,3 +717,32 @@ class NextRunResponse(BaseModel): next_end: datetime | None # UTC (latest end if overlapping) is_currently_running: bool active_schedule_count: int + + +# ============================================================================ +# Auth Schemas +# ============================================================================ + +class AuthUserInfo(BaseModel): + """Signed-in user as reported by /api/auth/me.""" + sub: str + name: str + email: str | None = None + username: str | None = None + groups: list[str] = Field(default_factory=list) + + +class AuthMeResponse(BaseModel): + """Auth status of the caller.""" + enabled: bool + authenticated: bool + provider_name: str | None = None + user: AuthUserInfo | None = None + is_admin: bool = False + # Role per registered project: "none" | "readonly" | "developer" + project_roles: dict[str, str] = Field(default_factory=dict) + + +class LogoutResponse(BaseModel): + """Result of POST /api/auth/logout.""" + logout_url: str | None = None diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 000000000..b62b2ab07 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,494 @@ +""" +Tests for the optional OIDC authentication and role system (server/auth/). + +Run with: python -m pytest test_auth.py +""" + +import os +import tempfile +import time +import unittest +from pathlib import Path +from typing import Any +from unittest import mock +from urllib.parse import parse_qs, urlparse + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI, Request, WebSocket +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from server.auth import oidc as oidc_module +from server.auth.config import AuthSettings, load_auth_settings, reset_auth_settings +from server.auth.middleware import ( + WS_CLOSE_FORBIDDEN, + WS_CLOSE_UNAUTHORIZED, + AuthMiddleware, + can_read_project, + is_same_origin_request, +) +from server.auth.policy import Access, required_access +from server.auth.roles import Role, parse_roles_config +from server.auth.session import LOGIN_STATE_COOKIE, SESSION_COOKIE, AuthUser, get_session_store +from server.routers.auth import _safe_next +from server.routers.auth import router as auth_router + +ISSUER = "https://idp.example.com/realms/test" +CLIENT_ID = "autoforge" + +ROLES_YAML = """ +version: 1 +admins: + users: [Admin@Example.com] + groups: [autoforge-admins] +default_role: none +projects: + "*": + readonly: + groups: [viewers] + my-app: + developer: + users: [dev@example.com] + "team-*": + developer: + groups: [team-devs] +""" + + +class TestSettings(unittest.TestCase): + def test_disabled_by_default(self): + settings = load_auth_settings({}) + assert settings.enabled is False + + def test_invalid_mode_rejected(self): + with self.assertRaises(RuntimeError): + load_auth_settings({"AUTOFORGE_AUTH_MODE": "basic"}) + + def test_oidc_requires_issuer_and_client(self): + with self.assertRaises(RuntimeError): + load_auth_settings({"AUTOFORGE_AUTH_MODE": "oidc", "AUTOFORGE_OIDC_ISSUER": ISSUER}) + + def test_oidc_settings_parsed(self): + settings = load_auth_settings({ + "AUTOFORGE_AUTH_MODE": "OIDC", + "AUTOFORGE_OIDC_ISSUER": ISSUER + "/", + "AUTOFORGE_OIDC_CLIENT_ID": CLIENT_ID, + "AUTOFORGE_OIDC_GROUP_CLAIMS": "groups, app_roles", + "AUTOFORGE_SESSION_TTL_HOURS": "2", + "AUTOFORGE_AUTH_TRUSTED_ORIGINS": "http://localhost:5173/", + }) + assert settings.enabled + assert settings.issuer == ISSUER + assert settings.group_claims == ("groups", "app_roles") + assert settings.session_ttl_seconds == 7200 + assert settings.trusted_origins == ("http://localhost:5173",) + + +class TestPolicy(unittest.TestCase): + def check(self, method: str, path: str, access: Access, project: str | None = None): + req = required_access(method, path) + assert (req.access, req.project) == (access, project), f"{method} {path} -> {req}" + + def test_public_routes(self): + self.check("GET", "/", Access.PUBLIC) + self.check("GET", "/assets/index.js", Access.PUBLIC) + self.check("GET", "/api/health", Access.PUBLIC) + self.check("GET", "/api/auth/login", Access.PUBLIC) + self.check("POST", "/api/auth/logout", Access.PUBLIC) + + def test_project_routes(self): + self.check("GET", "/api/projects", Access.AUTHENTICATED) + self.check("POST", "/api/projects", Access.ADMIN) + self.check("GET", "/api/projects/app", Access.READ, "app") + self.check("DELETE", "/api/projects/app", Access.ADMIN, "app") + self.check("GET", "/api/projects/app/features", Access.READ, "app") + self.check("POST", "/api/projects/app/features", Access.WRITE, "app") + self.check("PATCH", "/api/projects/app/features/3", Access.WRITE, "app") + self.check("GET", "/api/projects/app/agent/status", Access.READ, "app") + self.check("POST", "/api/projects/app/agent/start", Access.WRITE, "app") + self.check("POST", "/api/projects/app/reset", Access.WRITE, "app") + self.check("PUT", "/api/projects/app/prompts", Access.WRITE, "app") + self.check("GET", "/ws/projects/app", Access.READ, "app") + + def test_interactive_routes_need_write(self): + self.check("GET", "/api/terminal/app", Access.WRITE, "app") + self.check("GET", "/api/terminal/ws/app/t1", Access.WRITE, "app") + self.check("GET", "/api/spec/ws/app", Access.WRITE, "app") + self.check("GET", "/api/expand/ws/app", Access.WRITE, "app") + self.check("GET", "/api/assistant/ws/app", Access.WRITE, "app") + self.check("GET", "/api/assistant/conversations/app", Access.READ, "app") + + def test_global_routes(self): + self.check("GET", "/api/settings", Access.AUTHENTICATED) + self.check("PATCH", "/api/settings", Access.ADMIN) + self.check("GET", "/api/filesystem/list", Access.ADMIN) + self.check("POST", "/api/scaffold/run", Access.ADMIN) + self.check("GET", "/api/spec/sessions", Access.ADMIN) + + def test_unknown_api_routes_default_to_admin(self): + self.check("GET", "/api/something-new", Access.ADMIN) + self.check("GET", "/ws/other", Access.ADMIN) + + +class TestRoles(unittest.TestCase): + def setUp(self): + self.roles = parse_roles_config(__import__("yaml").safe_load(ROLES_YAML)) + + def test_admin_by_user_case_insensitive(self): + user = AuthUser(sub="1", email="admin@example.com") + assert self.roles.is_admin(user) + assert self.roles.role_for(user, "anything") == Role.DEVELOPER + + def test_admin_by_group(self): + assert self.roles.is_admin(AuthUser(sub="1", groups=("autoforge-admins",))) + + def test_project_roles(self): + dev = AuthUser(sub="2", email="dev@example.com", groups=("viewers",)) + assert self.roles.role_for(dev, "my-app") == Role.DEVELOPER + assert self.roles.role_for(dev, "other") == Role.READONLY + assert not self.roles.is_admin(dev) + + def test_glob_patterns(self): + user = AuthUser(sub="3", groups=("team-devs",)) + assert self.roles.role_for(user, "team-alpha") == Role.DEVELOPER + assert self.roles.role_for(user, "my-app") == Role.NONE + + def test_default_role(self): + roles = parse_roles_config({"default_role": "readonly"}) + assert roles.role_for(AuthUser(sub="x"), "p") == Role.READONLY + + def test_invalid_role_rejected(self): + with self.assertRaises(ValueError): + parse_roles_config({"projects": {"p": {"owner": {"users": ["a"]}}}}) + + +class TestOrigin(unittest.TestCase): + def test_same_origin(self): + assert is_same_origin_request(None, "host:8888") + assert is_same_origin_request("http://host:8888", "host:8888") + assert is_same_origin_request("https://af.example.com", "af.example.com") + assert is_same_origin_request("https://af.example.com", "af.example.com:443") + + def test_other_port_rejected(self): + """A generated app's dev server on the same host must not drive the API.""" + assert not is_same_origin_request("http://host:3000", "host:8888") + assert not is_same_origin_request("http://evil.com", "host:8888") + assert not is_same_origin_request("null", "host:8888") + + def test_trusted_origin(self): + assert is_same_origin_request("http://localhost:5173", "127.0.0.1:8888", ("http://localhost:5173",)) + + +def _build_app(settings: AuthSettings) -> FastAPI: + app = FastAPI() + app.add_middleware(AuthMiddleware, settings=settings) + app.include_router(auth_router) + + @app.get("/api/projects") + async def projects(request: Request): + return [p for p in ("my-app", "other", "secret") if can_read_project(request.scope, p)] + + @app.get("/api/projects/{name}/features") + async def list_features(name: str): + return {"project": name} + + @app.post("/api/projects/{name}/features") + async def create_feature(name: str): + return {"created": name} + + @app.get("/api/filesystem/list") + async def fs_list(): + return [] + + @app.websocket("/ws/projects/{name}") + async def project_ws(websocket: WebSocket, name: str): + await websocket.accept() + await websocket.send_json({"project": name}) + await websocket.close() + + @app.websocket("/api/terminal/ws/{name}/{tid}") + async def terminal_ws(websocket: WebSocket, name: str, tid: str): + await websocket.accept() + await websocket.close() + + @app.get("/") + async def index(): + return {"ui": True} + + return app + + +class AuthAppTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + roles_file = Path(self.tmp.name) / "auth.yaml" + roles_file.write_text(ROLES_YAML, encoding="utf-8") + self.settings = AuthSettings( + mode="oidc", issuer=ISSUER, client_id=CLIENT_ID, client_secret="s3cret", + roles_file=roles_file, cookie_secure=False, + ) + reset_auth_settings(self.settings) + get_session_store().clear() + self.client = TestClient(_build_app(self.settings), base_url="http://testserver") + + def tearDown(self): + reset_auth_settings(None) + get_session_store().clear() + self.tmp.cleanup() + + def login_as(self, user: AuthUser) -> None: + sid = get_session_store().create(user, ttl_seconds=3600) + self.client.cookies.set(SESSION_COOKIE, sid) + + +class TestMiddleware(AuthAppTestCase): + def test_disabled_auth_is_noop(self): + client = TestClient(_build_app(AuthSettings())) + assert client.get("/api/projects/x/features").status_code == 200 + assert client.post("/api/projects/x/features").status_code == 200 + + def test_unauthenticated(self): + assert self.client.get("/").status_code == 200 + response = self.client.get("/api/projects/my-app/features") + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" + + def test_unknown_session_rejected(self): + self.client.cookies.set(SESSION_COOKIE, "forged") + assert self.client.get("/api/projects/my-app/features").status_code == 401 + + def test_expired_session_rejected(self): + sid = get_session_store().create(AuthUser(sub="x", email="dev@example.com"), ttl_seconds=3600) + get_session_store()._sessions[sid].expires_at = time.time() - 1 + self.client.cookies.set(SESSION_COOKIE, sid) + assert self.client.get("/api/projects/my-app/features").status_code == 401 + + def test_readonly_user(self): + self.login_as(AuthUser(sub="v", groups=("viewers",))) + assert self.client.get("/api/projects/other/features").status_code == 200 + assert self.client.post("/api/projects/other/features").status_code == 403 + assert self.client.get("/api/filesystem/list").status_code == 403 + + def test_developer_user(self): + self.login_as(AuthUser(sub="d", email="dev@example.com")) + assert self.client.post("/api/projects/my-app/features").status_code == 200 + assert self.client.get("/api/projects/other/features").status_code == 403 + + def test_admin_user(self): + self.login_as(AuthUser(sub="a", groups=("autoforge-admins",))) + assert self.client.post("/api/projects/secret/features").status_code == 200 + assert self.client.get("/api/filesystem/list").status_code == 200 + + def test_project_list_filtered(self): + self.login_as(AuthUser(sub="d", email="dev@example.com")) + assert self.client.get("/api/projects").json() == ["my-app"] + + def test_roles_file_reloaded(self): + user = AuthUser(sub="n", email="new@example.com") + self.login_as(user) + assert self.client.get("/api/projects/secret/features").status_code == 403 + roles_file = self.settings.roles_file + assert roles_file is not None + roles_file.write_text("projects:\n secret:\n readonly:\n users: [new@example.com]\n") + future = time.time() + 5 + os.utime(roles_file, (future, future)) + assert self.client.get("/api/projects/secret/features").status_code == 200 + + def test_invalid_roles_file_fails_closed(self): + roles_file = self.settings.roles_file + assert roles_file is not None + roles_file.write_text("projects: [not, a, mapping]\n") + future = time.time() + 10 + os.utime(roles_file, (future, future)) + self.login_as(AuthUser(sub="d", email="dev@example.com")) + assert self.client.get("/api/projects/my-app/features").status_code == 403 + + def test_cross_origin_post_rejected(self): + self.login_as(AuthUser(sub="d", email="dev@example.com")) + evil = self.client.post("/api/projects/my-app/features", headers={"Origin": "http://testserver:3000"}) + assert evil.status_code == 403 + same = self.client.post("/api/projects/my-app/features", headers={"Origin": "http://testserver"}) + assert same.status_code == 200 + + def test_websocket_requires_session(self): + with self.assertRaises(WebSocketDisconnect) as ctx: + with self.client.websocket_connect("/ws/projects/my-app"): + pass + assert ctx.exception.code == WS_CLOSE_UNAUTHORIZED + + def test_websocket_read_allowed_for_readonly(self): + self.login_as(AuthUser(sub="v", groups=("viewers",))) + with self.client.websocket_connect("/ws/projects/other") as ws: + assert ws.receive_json() == {"project": "other"} + + def test_terminal_websocket_forbidden_for_readonly(self): + self.login_as(AuthUser(sub="v", groups=("viewers",))) + with self.assertRaises(WebSocketDisconnect) as ctx: + with self.client.websocket_connect("/api/terminal/ws/other/t1"): + pass + assert ctx.exception.code == WS_CLOSE_FORBIDDEN + + def test_cross_origin_websocket_rejected(self): + self.login_as(AuthUser(sub="d", email="dev@example.com")) + with self.assertRaises(WebSocketDisconnect) as ctx: + with self.client.websocket_connect("/ws/projects/my-app", headers={"Origin": "http://testserver:3000"}): + pass + assert ctx.exception.code == WS_CLOSE_FORBIDDEN + + +class FakeProvider: + """In-process OIDC provider serving discovery + token endpoints.""" + + def __init__(self, client_id: str = CLIENT_ID): + self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + self.client_id = client_id + self.nonce_override: str | None = None + self.claims: dict[str, Any] = {"sub": "user-1", "email": "dev@example.com", "name": "Dev"} + self.last_token_request: dict[str, list[str]] = {} + self.nonce = "" + + def id_token(self) -> str: + now = int(time.time()) + payload = { + "iss": ISSUER, "aud": self.client_id, "iat": now, "exp": now + 300, + "nonce": self.nonce_override if self.nonce_override is not None else self.nonce, + **self.claims, + } + return jwt.encode(payload, self.key, algorithm="RS256", headers={"kid": "k1"}) + + def handler(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/.well-known/openid-configuration"): + return httpx.Response(200, json={ + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/auth", + "token_endpoint": f"{ISSUER}/token", + "jwks_uri": f"{ISSUER}/certs", + "end_session_endpoint": f"{ISSUER}/logout", + }) + if request.url.path.endswith("/token"): + self.last_token_request = parse_qs(request.content.decode()) + return httpx.Response(200, json={"id_token": self.id_token(), "access_token": "at"}) + return httpx.Response(404) + + +class TestOIDCFlow(AuthAppTestCase): + def setUp(self): + super().setUp() + self.provider = FakeProvider() + transport = httpx.MockTransport(self.provider.handler) + real_client = httpx.AsyncClient + + def client_factory(*args, **kwargs): + kwargs["transport"] = transport + return real_client(*args, **kwargs) + + provider = self.provider + + class FakeJWKClient: + def __init__(self, *args, **kwargs): + pass + + def get_signing_key_from_jwt(self, token): + return mock.Mock(key=provider.key.public_key()) + + self.patches = [ + mock.patch.object(oidc_module.httpx, "AsyncClient", client_factory), + mock.patch.object(oidc_module.jwt, "PyJWKClient", FakeJWKClient), + mock.patch("registry.list_registered_projects", return_value={"my-app": {}, "other": {}}), + ] + for p in self.patches: + p.start() + oidc_module._client = None + + def tearDown(self): + for p in self.patches: + p.stop() + oidc_module._client = None + super().tearDown() + + def start_login(self, next_path: str = "/projects") -> dict[str, list[str]]: + response = self.client.get("/api/auth/login", params={"next": next_path}, follow_redirects=False) + assert response.status_code == 302, response.text + location = urlparse(response.headers["location"]) + assert f"{location.scheme}://{location.netloc}{location.path}" == f"{ISSUER}/auth" + params = parse_qs(location.query) + assert params["client_id"] == [CLIENT_ID] + assert params["code_challenge_method"] == ["S256"] + assert params["redirect_uri"] == ["http://testserver/api/auth/callback"] + self.provider.nonce = params["nonce"][0] + return params + + def test_full_login_flow(self): + assert self.client.get("/api/auth/me").json() == { + "enabled": True, "authenticated": False, "provider_name": "SSO", "user": None, + "is_admin": False, "project_roles": {}, + } + params = self.start_login() + + response = self.client.get( + "/api/auth/callback", params={"code": "abc", "state": params["state"][0]}, follow_redirects=False, + ) + assert response.status_code == 302, response.text + assert response.headers["location"] == "/projects" + assert self.provider.last_token_request["code"] == ["abc"] + assert self.provider.last_token_request["client_secret"] == ["s3cret"] + assert self.provider.last_token_request["code_verifier"][0] + + me = self.client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == "dev@example.com" + assert me["project_roles"] == {"my-app": "developer", "other": "none"} + assert self.client.post("/api/projects/my-app/features").status_code == 200 + + logout = self.client.post("/api/auth/logout").json() + assert logout["logout_url"].startswith(f"{ISSUER}/logout?") + assert self.client.get("/api/projects/my-app/features").status_code == 401 + + def test_state_must_match_browser_cookie(self): + params = self.start_login() + self.client.cookies.set(LOGIN_STATE_COOKIE, "other-state", domain="testserver.local", path="/api/auth") + response = self.client.get( + "/api/auth/callback", params={"code": "abc", "state": params["state"][0]}, follow_redirects=False, + ) + assert response.status_code == 400 + assert SESSION_COOKIE not in self.client.cookies + + def test_state_single_use(self): + params = self.start_login() + state = params["state"][0] + ok = self.client.get("/api/auth/callback", params={"code": "abc", "state": state}, follow_redirects=False) + assert ok.status_code == 302 + self.client.cookies.set(LOGIN_STATE_COOKIE, state, domain="testserver.local", path="/api/auth") + replay = self.client.get("/api/auth/callback", params={"code": "abc", "state": state}) + assert replay.status_code == 400 + + def test_nonce_mismatch_rejected(self): + params = self.start_login() + self.provider.nonce_override = "wrong" + response = self.client.get("/api/auth/callback", params={"code": "abc", "state": params["state"][0]}) + assert response.status_code == 400 + assert "nonce" in response.text + + def test_wrong_audience_rejected(self): + params = self.start_login() + self.provider.client_id = "someone-else" + response = self.client.get("/api/auth/callback", params={"code": "abc", "state": params["state"][0]}) + assert response.status_code == 400 + + def test_open_redirect_blocked(self): + assert _safe_next("https://evil.com") == "/" + assert _safe_next("//evil.com") == "/" + assert _safe_next("/\\evil.com") == "/" + assert _safe_next("/projects?x=1") == "/projects?x=1" + assert _safe_next(None) == "/" + + def test_group_claims_from_token(self): + self.provider.claims = {"sub": "u2", "groups": ["viewers"], "roles": "autoforge-admins"} + user = oidc_module.user_from_claims(self.provider.claims, ("groups", "roles")) + assert user.groups == ("viewers", "autoforge-admins") + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2026f3651..d607aaefd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,13 +1,16 @@ import { AppProvider } from './contexts/AppContext' import { AppShell } from './components/layout/AppShell' import { Modals } from './components/layout/Modals' +import { AuthGate } from './components/AuthGate' function App() { return ( - - - - + + + + + + ) } diff --git a/ui/src/components/AuthGate.tsx b/ui/src/components/AuthGate.tsx new file mode 100644 index 000000000..cdfab2c97 --- /dev/null +++ b/ui/src/components/AuthGate.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react' +import { Loader2, LogIn, LogOut, ShieldAlert } from 'lucide-react' +import { useAuthMe, useLogout } from '../hooks/useAuth' +import { loginUrl } from '../lib/api' +import type { AuthMe } from '../lib/types' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' + +/** + * Renders the app only once the user may use it. + * + * With server auth disabled this is a pass-through. With OIDC enabled it shows + * a sign-in screen until a session exists, and a "no access" screen for + * signed-in users who have not been granted any role yet. + */ +export function AuthGate({ children }: { children: ReactNode }) { + const { data: me, isLoading } = useAuthMe() + + if (isLoading) { + return ( +
+ +
+ ) + } + + // If /api/auth/me fails, fall through: the API still enforces access. + if (!me?.enabled) return <>{children} + if (!me.authenticated) return + if (!hasAnyAccess(me)) return + return <>{children} +} + +function hasAnyAccess(me: AuthMe): boolean { + return me.is_admin || Object.values(me.project_roles).some(role => role !== 'none') +} + +function GateCard({ children }: { children: ReactNode }) { + return ( +
+ + + AutoForge + {children} + + +
+ ) +} + +function LoginScreen({ providerName }: { providerName: string | null }) { + return ( + +

Sign in to AutoForge

+

+ This AutoForge server requires you to sign in before using it. +

+ +
+ ) +} + +function NoAccessScreen({ me }: { me: AuthMe }) { + const logout = useLogout() + const user = me.user + + return ( + + +

No access yet

+

+ You are signed in, but you have not been given a role on any project. + Ask an AutoForge admin to add you to auth.yaml. +

+ {user && ( +
+
user: {user.email || user.username || user.sub}
+
groups: {user.groups.length > 0 ? user.groups.join(', ') : '(none)'}
+
+ )} + +
+ ) +} diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index 06eb8bf00..0c9d7265a 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -4,6 +4,7 @@ import type { ProjectSummary } from '../lib/types' import { NewProjectModal } from './NewProjectModal' import { ConfirmDialog } from './ConfirmDialog' import { useDeleteProject } from '../hooks/useProjects' +import { usePermissions } from '../hooks/useAuth' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -31,6 +32,7 @@ export function ProjectSelector({ }: ProjectSelectorProps) { const [isOpen, setIsOpen] = useState(false) const [showNewProjectModal, setShowNewProjectModal] = useState(false) + const { isAdmin } = usePermissions() const [projectToDelete, setProjectToDelete] = useState(null) const deleteProject = useDeleteProject() @@ -119,14 +121,16 @@ export function ProjectSelector({ )} - + {isAdmin && ( + + )} ))} @@ -136,19 +140,23 @@ export function ProjectSelector({ )} - + {isAdmin && ( + <> + -
- { - setShowNewProjectModal(true) - }} - className="cursor-pointer font-semibold" - > - - New Project - -
+
+ { + setShowNewProjectModal(true) + }} + className="cursor-pointer font-semibold" + > + + New Project + +
+ + )} diff --git a/ui/src/components/layout/ControlBar.tsx b/ui/src/components/layout/ControlBar.tsx index 480143682..f97c94b86 100644 --- a/ui/src/components/layout/ControlBar.tsx +++ b/ui/src/components/layout/ControlBar.tsx @@ -1,10 +1,11 @@ import { useAppContext } from '@/contexts/AppContext' import { AgentControl } from '../AgentControl' import { DevServerControl } from '../DevServerControl' -import { RotateCcw } from 'lucide-react' +import { Eye, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { usePermissions } from '@/hooks/useAuth' /** * Compact horizontal control bar at the top of the content area. @@ -19,6 +20,23 @@ export function ControlBar() { settings, setShowResetModal, } = useAppContext() + const { canWrite } = usePermissions(selectedProject) + + if (!canWrite) { + return ( +
+ + + + + Read-only + + + You can view this project but not change it + +
+ ) + } return (
diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 05f09f3c6..f89624744 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -15,10 +15,12 @@ import { BookOpen, PanelLeftClose, PanelLeftOpen, + LogOut, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { useAuthMe, useLogout } from '@/hooks/useAuth' /** * Collapsible left sidebar for view navigation. @@ -43,6 +45,10 @@ export function Sidebar() { setIsSpecCreating, } = useAppContext() + const { data: authMe } = useAuthMe() + const logout = useLogout() + const authUser = authMe?.enabled ? authMe.user : null + const browserCount = wsState.browserScreenshots.size const logCount = wsState.logs.length @@ -232,6 +238,16 @@ export function Sidebar() { onClick={() => window.open('https://autoforge.cc', '_blank')} /> + {/* Signed-in user (only when server auth is enabled) */} + {authUser && ( + logout.mutate()} + /> + )} + {/* Collapse / expand toggle */}
{ + const onUnauthorized = () => queryClient.invalidateQueries({ queryKey: ['auth-me'] }) + window.addEventListener(api.UNAUTHORIZED_EVENT, onUnauthorized) + return () => window.removeEventListener(api.UNAUTHORIZED_EVENT, onUnauthorized) + }, [queryClient]) + + return useQuery({ + queryKey: ['auth-me'], + queryFn: api.getAuthMe, + staleTime: 60_000, + retry: 1, + }) +} + +export function useLogout() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: api.logout, + onSuccess: (result) => { + if (result.logout_url) { + window.location.href = result.logout_url + return + } + queryClient.clear() + queryClient.invalidateQueries({ queryKey: ['auth-me'] }) + }, + }) +} + +export interface Permissions { + authEnabled: boolean + isAdmin: boolean + role: ProjectRole + canWrite: boolean + readOnly: boolean +} + +export function permissionsFor(me: AuthMe | undefined, projectName: string | null): Permissions { + if (!me?.enabled) { + return { authEnabled: false, isAdmin: true, role: 'developer', canWrite: true, readOnly: false } + } + const role: ProjectRole = me.is_admin + ? 'developer' + : (projectName && me.project_roles[projectName]) || 'none' + return { + authEnabled: true, + isAdmin: me.is_admin, + role, + canWrite: role === 'developer', + readOnly: role === 'readonly', + } +} + +/** Permissions of the signed-in user, optionally for a specific project. */ +export function usePermissions(projectName: string | null = null): Permissions { + const { data } = useAuthMe() + return permissionsFor(data, projectName) +} diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 88da9c0c4..49af28534 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -33,6 +33,7 @@ export function useCreateProject() { api.createProject(name, path, specMethod), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ['auth-me'] }) }, }) } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index d43ed18f7..474b1d99a 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -33,10 +33,14 @@ import type { ScheduleUpdate, ScheduleListResponse, NextRunResponse, + AuthMe, } from './types' const API_BASE = '/api' +/** Fired when the API answers 401 (session missing or expired). */ +export const UNAUTHORIZED_EVENT = 'autoforge:unauthorized' + async function fetchJSON(url: string, options?: RequestInit): Promise { const response = await fetch(`${API_BASE}${url}`, { ...options, @@ -46,6 +50,10 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { }, }) + if (response.status === 401) { + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)) + } + if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Unknown error' })) throw new Error(error.detail || `HTTP ${response.status}`) @@ -59,6 +67,24 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { return response.json() } +// ============================================================================ +// Auth API +// ============================================================================ + +export async function getAuthMe(): Promise { + return fetchJSON('/auth/me') +} + +export async function logout(): Promise<{ logout_url: string | null }> { + return fetchJSON('/auth/logout', { method: 'POST' }) +} + +/** URL that starts the sign-in flow and returns to the current page afterwards. */ +export function loginUrl(): string { + const next = window.location.pathname + window.location.search + window.location.hash + return `${API_BASE}/auth/login?next=${encodeURIComponent(next)}` +} + // ============================================================================ // Projects API // ============================================================================ diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index b2ff61aa2..899b555b8 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -728,3 +728,26 @@ export interface NextRunResponse { is_currently_running: boolean active_schedule_count: number } + +// ============================================================================ +// Auth Types +// ============================================================================ + +export type ProjectRole = 'none' | 'readonly' | 'developer' + +export interface AuthUserInfo { + sub: string + name: string + email: string | null + username: string | null + groups: string[] +} + +export interface AuthMe { + enabled: boolean + authenticated: boolean + provider_name: string | null + user: AuthUserInfo | null + is_admin: boolean + project_roles: Record +}