Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tenant-id>/v2.0
# AUTOFORGE_OIDC_CLIENT_ID=<application-id>
# AUTOFORGE_OIDC_CLIENT_SECRET=<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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-host>/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/<tenant-id>/v2.0
# Authentik: https://authentik.example.com/application/o/<app-slug>/
AUTOFORGE_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
AUTOFORGE_OIDC_CLIENT_ID=<application-id>
AUTOFORGE_OIDC_CLIENT_SECRET=<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.
Expand Down
55 changes: 55 additions & 0 deletions examples/auth.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions requirements-prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions server/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
131 changes: 131 additions & 0 deletions server/auth/config.py
Original file line number Diff line number Diff line change
@@ -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
Loading