diff --git a/.env.example b/.env.example index a72dbd1b..1d75556b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ CORS_ORIGINS=http://localhost:3000 DB_AUTO_MIGRATE=true LOG_LEVEL=INFO REQUEST_LOG_SLOW_MS=1000 +AUTH_MODE=local +LOCAL_AUTH_TOKEN=change-me # --- frontend settings --- # REQUIRED: Public URL used by the browser to reach the API. diff --git a/README.md b/README.md index 615cc27a..24ebb1fa 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,20 @@ Operational deep dives: - Production notes: [Production notes](./docs/production/README.md) - Troubleshooting: [Troubleshooting](./docs/troubleshooting/README.md) -## Authentication (Clerk) +## Authentication -**Clerk is currently required**. +Mission Control supports two auth modes via `AUTH_MODE`: -You must configure Clerk keys for: -- the frontend (`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`) -- the backend (`CLERK_SECRET_KEY`) +- `local`: shared bearer token auth for self-hosted deployments +- `clerk`: Clerk JWT auth -See: [Deployment guide](./docs/deployment/README.md#clerk-auth-notes). +`local` mode requires: +- backend: `AUTH_MODE=local`, `LOCAL_AUTH_TOKEN=` +- frontend: `NEXT_PUBLIC_AUTH_MODE=local`, then enter the token in the login screen + +`clerk` mode requires: +- backend: `AUTH_MODE=clerk`, `CLERK_SECRET_KEY=` +- frontend: `NEXT_PUBLIC_AUTH_MODE=clerk`, `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=` ## Deployment modes @@ -53,8 +58,9 @@ cp .env.example .env # NEXT_PUBLIC_API_URL must be reachable from the *browser* (host), not an internal Docker network name. # Missing/blank NEXT_PUBLIC_API_URL will break frontend API calls (e.g. Activity feed). -# REQUIRED: Clerk config. -# Provide real Clerk values via frontend/.env (recommended) and backend/.env. +# Auth defaults in .env.example are local mode. +# For production, set LOCAL_AUTH_TOKEN to a strong random value. +# For Clerk mode, set AUTH_MODE=clerk and provide Clerk keys. docker compose -f compose.yml --env-file .env up -d --build ``` diff --git a/backend/.env.example b/backend/.env.example index e2c65f26..0cd2515b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,8 +8,12 @@ DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_contr CORS_ORIGINS=http://localhost:3000 BASE_URL= -# Clerk (auth only) -CLERK_SECRET_KEY=sk_test_your_clerk_secret_key +# Auth mode: clerk or local. +AUTH_MODE=local +LOCAL_AUTH_TOKEN=change-me + +# Clerk (auth only; used when AUTH_MODE=clerk) +CLERK_SECRET_KEY= CLERK_API_URL=https://api.clerk.com CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 273f53cf..4ac7f6a1 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,8 +1,9 @@ -"""User authentication helpers backed by Clerk JWT verification.""" +"""User authentication helpers for Clerk and local-token auth modes.""" from __future__ import annotations from dataclasses import dataclass +from hmac import compare_digest from typing import TYPE_CHECKING, Literal import httpx @@ -29,6 +30,9 @@ logger = get_logger(__name__) security = HTTPBearer(auto_error=False) SECURITY_DEP = Depends(security) SESSION_DEP = Depends(get_session) +LOCAL_AUTH_USER_ID = "local-auth-user" +LOCAL_AUTH_EMAIL = "admin@home.local" +LOCAL_AUTH_NAME = "Local User" class ClerkTokenPayload(BaseModel): @@ -45,6 +49,18 @@ class AuthContext: user: User | None = None +def _extract_bearer_token(authorization: str | None) -> str | None: + if not authorization: + return None + value = authorization.strip() + if not value: + return None + if not value.lower().startswith("bearer "): + return None + token = value.split(" ", maxsplit=1)[1].strip() + return token or None + + def _non_empty_str(value: object) -> str | None: if not isinstance(value, str): return None @@ -228,6 +244,9 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No async def delete_clerk_user(clerk_user_id: str) -> None: """Delete a Clerk user via the official Clerk SDK.""" + if settings.auth_mode != "clerk": + return + secret = settings.clerk_secret_key.strip() secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown" server_url = _normalize_clerk_server_url(settings.clerk_api_url or "") @@ -343,6 +362,55 @@ async def _get_or_sync_user( return user +async def _get_or_create_local_user(session: AsyncSession) -> User: + defaults: dict[str, object] = { + "email": LOCAL_AUTH_EMAIL, + "name": LOCAL_AUTH_NAME, + } + user, _created = await crud.get_or_create( + session, + User, + clerk_user_id=LOCAL_AUTH_USER_ID, + defaults=defaults, + ) + changed = False + if not user.email: + user.email = LOCAL_AUTH_EMAIL + changed = True + if not user.name: + user.name = LOCAL_AUTH_NAME + changed = True + if changed: + session.add(user) + await session.commit() + await session.refresh(user) + + from app.services.organizations import ensure_member_for_user + + await ensure_member_for_user(session, user) + return user + + +async def _resolve_local_auth_context( + *, + request: Request, + session: AsyncSession, + required: bool, +) -> AuthContext | None: + token = _extract_bearer_token(request.headers.get("Authorization")) + if token is None: + if required: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return None + expected = settings.local_auth_token.strip() + if not expected or not compare_digest(token, expected): + if required: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return None + user = await _get_or_create_local_user(session) + return AuthContext(actor_type="user", user=user) + + def _parse_subject(claims: dict[str, object]) -> str | None: payload = ClerkTokenPayload.model_validate(claims) return payload.sub @@ -353,7 +421,17 @@ async def get_auth_context( credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP, session: AsyncSession = SESSION_DEP, ) -> AuthContext: - """Resolve required authenticated user context from Clerk JWT headers.""" + """Resolve required authenticated user context for the configured auth mode.""" + if settings.auth_mode == "local": + local_auth = await _resolve_local_auth_context( + request=request, + session=session, + required=True, + ) + if local_auth is None: # pragma: no cover + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return local_auth + request_state = await _authenticate_clerk_request(request) if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -388,6 +466,13 @@ async def get_auth_context_optional( """Resolve user context if available, otherwise return `None`.""" if request.headers.get("X-Agent-Token"): return None + if settings.auth_mode == "local": + return await _resolve_local_auth_context( + request=request, + session=session, + required=False, + ) + request_state = await _authenticate_clerk_request(request) if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict): return None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b563ca14..08482ed5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Self +from typing import Literal, Self from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -26,8 +26,12 @@ class Settings(BaseSettings): environment: str = "dev" database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency" + # Auth mode: "clerk" for Clerk JWT auth, "local" for shared bearer token auth. + auth_mode: Literal["clerk", "local"] + local_auth_token: str = "" + # Clerk auth (auth only; roles stored in DB) - clerk_secret_key: str = Field(min_length=1) + clerk_secret_key: str = "" clerk_api_url: str = "https://api.clerk.com" clerk_verify_iat: bool = True clerk_leeway: float = 10.0 @@ -47,8 +51,16 @@ class Settings(BaseSettings): @model_validator(mode="after") def _defaults(self) -> Self: - if not self.clerk_secret_key.strip(): - raise ValueError("CLERK_SECRET_KEY must be set and non-empty.") + if self.auth_mode == "clerk": + if not self.clerk_secret_key.strip(): + raise ValueError( + "CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk.", + ) + elif self.auth_mode == "local": + if not self.local_auth_token.strip(): + raise ValueError( + "LOCAL_AUTH_TOKEN must be set and non-empty when AUTH_MODE=local.", + ) # In dev, default to applying Alembic migrations at startup to avoid # schema drift (e.g. missing newly-added columns). if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev": diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 80ba349f..db99bf63 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,15 @@ # ruff: noqa: INP001 """Pytest configuration shared across backend tests.""" +import os import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) + +# Tests should fail fast if auth-mode wiring breaks, but still need deterministic +# defaults during import-time settings initialization. +os.environ.setdefault("AUTH_MODE", "local") +os.environ.setdefault("LOCAL_AUTH_TOKEN", "test-local-token") diff --git a/backend/tests/test_authenticate_request_flow.py b/backend/tests/test_authenticate_request_flow.py index 130bf1ff..a85612fb 100644 --- a/backend/tests/test_authenticate_request_flow.py +++ b/backend/tests/test_authenticate_request_flow.py @@ -21,6 +21,9 @@ class _FakeSession: async def test_get_auth_context_raises_401_when_clerk_signed_out( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", "clerk") + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + from clerk_backend_api.security.types import AuthStatus, RequestState async def _fake_authenticate(_request: Any) -> RequestState: @@ -42,6 +45,9 @@ async def test_get_auth_context_raises_401_when_clerk_signed_out( async def test_get_auth_context_uses_request_state_payload_claims( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", "clerk") + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + from clerk_backend_api.security.types import AuthStatus, RequestState async def _fake_authenticate(_request: Any) -> RequestState: @@ -82,6 +88,9 @@ async def test_get_auth_context_uses_request_state_payload_claims( async def test_get_auth_context_optional_returns_none_for_agent_token( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", "clerk") + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + async def _boom(_request: Any) -> Any: # pragma: no cover raise AssertionError("_authenticate_clerk_request should not be called") @@ -93,3 +102,46 @@ async def test_get_auth_context_optional_returns_none_for_agent_token( session=_FakeSession(), # type: ignore[arg-type] ) assert out is None + + +@pytest.mark.asyncio +async def test_get_auth_context_local_mode_requires_valid_bearer_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", "local") + monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") + + async def _fake_local_user(_session: Any) -> User: + return User(clerk_user_id="local-auth-user", email="local@localhost", name="Local User") + + monkeypatch.setattr(auth, "_get_or_create_local_user", _fake_local_user) + + ctx = await auth.get_auth_context( # type: ignore[arg-type] + request=SimpleNamespace(headers={"Authorization": "Bearer expected-token"}), + credentials=None, + session=_FakeSession(), # type: ignore[arg-type] + ) + + assert ctx.actor_type == "user" + assert ctx.user is not None + assert ctx.user.clerk_user_id == "local-auth-user" + + +@pytest.mark.asyncio +async def test_get_auth_context_optional_local_mode_returns_none_without_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", "local") + monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") + + async def _boom(_session: Any) -> User: # pragma: no cover + raise AssertionError("_get_or_create_local_user should not be called") + + monkeypatch.setattr(auth, "_get_or_create_local_user", _boom) + + out = await auth.get_auth_context_optional( # type: ignore[arg-type] + request=SimpleNamespace(headers={}), + credentials=None, + session=_FakeSession(), # type: ignore[arg-type] + ) + assert out is None diff --git a/backend/tests/test_local_auth_integration.py b/backend/tests/test_local_auth_integration.py new file mode 100644 index 00000000..135afdff --- /dev/null +++ b/backend/tests/test_local_auth_integration.py @@ -0,0 +1,99 @@ +# ruff: noqa: INP001 +"""Integration tests for local auth mode on protected API routes.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.users import router as users_router +from app.core import auth as auth_module +from app.core.config import settings +from app.db.session import get_session + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as conn, conn.begin(): + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +def _build_test_app( + session_maker: async_sessionmaker[AsyncSession], +) -> FastAPI: + app = FastAPI() + api_v1 = APIRouter(prefix="/api/v1") + api_v1.include_router(users_router) + app.include_router(api_v1) + + async def _override_get_session() -> AsyncSession: + async with session_maker() as session: + yield session + + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[auth_module.get_session] = _override_get_session + return app + + +@pytest.mark.asyncio +async def test_local_auth_users_me_requires_and_accepts_valid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + unique_suffix = uuid4().hex + expected_user_id = f"local-auth-integration-{unique_suffix}" + expected_email = f"local-{unique_suffix}@localhost" + expected_name = "Local Integration User" + + monkeypatch.setattr(settings, "auth_mode", "local") + monkeypatch.setattr(settings, "local_auth_token", "integration-token") + monkeypatch.setattr(auth_module, "LOCAL_AUTH_USER_ID", expected_user_id) + monkeypatch.setattr(auth_module, "LOCAL_AUTH_EMAIL", expected_email) + monkeypatch.setattr(auth_module, "LOCAL_AUTH_NAME", expected_name) + + engine = await _make_engine() + session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + app = _build_test_app(session_maker) + + try: + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + missing = await client.get("/api/v1/users/me") + assert missing.status_code == 401 + + invalid = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert invalid.status_code == 401 + + authorized = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer integration-token"}, + ) + assert authorized.status_code == 200 + payload = authorized.json() + assert payload["clerk_user_id"] == expected_user_id + assert payload["email"] == expected_email + assert payload["name"] == expected_name + + repeat = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer integration-token"}, + ) + assert repeat.status_code == 200 + assert repeat.json()["id"] == payload["id"] + finally: + await engine.dispose() diff --git a/compose.yml b/compose.yml index 4d046640..30ee9318 100644 --- a/compose.yml +++ b/compose.yml @@ -30,6 +30,8 @@ services: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true} + AUTH_MODE: ${AUTH_MODE} + LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-change-me} depends_on: db: condition: service_healthy @@ -41,6 +43,7 @@ services: context: ./frontend args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} # Optional, user-managed env file. # IMPORTANT: do NOT load `.env.example` here because it contains non-empty # placeholder Clerk keys, which can accidentally flip Clerk "on". @@ -49,6 +52,7 @@ services: required: false environment: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} depends_on: - backend ports: diff --git a/docs/05-architecture.md b/docs/05-architecture.md index 8ec794c8..d0056952 100644 --- a/docs/05-architecture.md +++ b/docs/05-architecture.md @@ -7,7 +7,7 @@ Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections. -> Auth note: **Clerk is required for production**. The codebase includes gating so CI/local can run without “real” keys, but real deployments should configure Clerk. +> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`. ## Components @@ -48,14 +48,17 @@ Common UI-driven data shapes: - “boards/tasks” views → board/task CRUD + streams. - “activity feed” → activity/events endpoints. -### 2) Authentication (Clerk) +### 2) Authentication (`local` or Clerk) -- **Frontend**: Clerk is enabled only when a publishable key is present/valid. - - Gating/wrappers: `frontend/src/auth/clerkKey.ts`, `frontend/src/auth/clerk.tsx`. -- **Frontend → backend**: API calls attach `Authorization: Bearer ` when available. - - Token injection: `frontend/src/api/mutator.ts` (uses `window.Clerk.session.getToken()`). -- **Backend**: validates inbound auth and resolves a user context. - - Implementation: `backend/app/core/auth.py` (uses `clerk_backend_api` SDK with `CLERK_SECRET_KEY`). +- **Frontend**: + - `local`: token entry + token storage (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`). + - `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`). +- **Frontend → backend**: + - API calls attach `Authorization: Bearer ` from local mode token or Clerk session token (`frontend/src/api/mutator.ts`). +- **Backend**: + - `local`: validates `LOCAL_AUTH_TOKEN`. + - `clerk`: validates Clerk request state via `clerk_backend_api` + `CLERK_SECRET_KEY`. + - Implementation: `backend/app/core/auth.py`. ### 3) Agent automation surface (`/api/v1/agent/*`) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 183de6fd..ca78f901 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -2,7 +2,7 @@ Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections. -> Auth note: **Clerk is required for now** (current product direction). The codebase includes gating so CI/local can run with placeholders, but real deployments should configure Clerk. +> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`. At a high level: - The **frontend** is a Next.js app used by humans. @@ -29,10 +29,11 @@ flowchart LR - Routes/pages: `frontend/src/app/*` (Next.js App Router) - API utilities: `frontend/src/lib/*` and `frontend/src/api/*` -**Auth (Clerk, required)** -- Clerk is required for real deployments and currently required by backend config (see `backend/app/core/config.py`). -- Frontend uses Clerk when keys are configured; see `frontend/src/auth/clerkKey.ts` and `frontend/src/auth/clerk.tsx`. -- Backend authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY`; see `backend/app/core/auth.py`. +**Auth (`local` or Clerk)** +- `local` mode authenticates a shared bearer token (`LOCAL_AUTH_TOKEN`) and resolves a local user context. +- `clerk` mode verifies Clerk JWTs using `CLERK_SECRET_KEY`. +- Frontend mode switch + wrappers: `frontend/src/auth/clerk.tsx`, `frontend/src/auth/localAuth.ts`, and `frontend/src/components/providers/AuthProvider.tsx`. +- Backend mode switch: `backend/app/core/config.py` and `backend/app/core/auth.py`. ### Backend (FastAPI) @@ -64,9 +65,13 @@ Mission Control can call into an OpenClaw Gateway over WebSockets. 2. Frontend calls backend endpoints under `/api/v1/*`. 3. Backend reads/writes Postgres. -### Auth (Clerk — required) -- **Frontend** uses Clerk when keys are configured (see `frontend/src/auth/*`). -- **Backend** authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY` (see `backend/app/core/auth.py`). +### Auth (`local` or Clerk) +- **Frontend**: + - `local`: token entry screen + session storage token (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`). + - `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`). +- **Backend**: + - `local`: validates `Authorization: Bearer `. + - `clerk`: validates Clerk request state with SDK + `CLERK_SECRET_KEY`. ### Agent access (X-Agent-Token) Automation/agents can use the “agent” API surface: - Endpoints under `/api/v1/agent/*` (router: `backend/app/api/agent.py`). @@ -92,7 +97,7 @@ Backend: Frontend: - `frontend/src/app/` — Next.js routes - `frontend/src/components/` — UI components -- `frontend/src/auth/` — Clerk gating/wrappers +- `frontend/src/auth/` — auth mode helpers (`clerk` and `local`) - `frontend/src/lib/` — utilities + API base ## Where to start reading code @@ -106,7 +111,7 @@ Backend: Frontend: 1. `frontend/src/app/*` — main UI routes 2. `frontend/src/lib/api-base.ts` — backend calls -3. `frontend/src/auth/*` — Clerk integration (gated for CI/local) +3. `frontend/src/auth/*` — auth mode integration (`local` + Clerk) ## Related docs - Self-host (Docker Compose): see repo root README: [Quick start (self-host with Docker Compose)](../../README.md#quick-start-self-host-with-docker-compose) diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 0d5aba68..60add961 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -110,49 +110,58 @@ Instead, it supports an optional user-managed env file: If present, Compose will load it. -## Clerk (auth) notes +## Authentication modes -Clerk is currently required. +Mission Control supports two deployment auth modes: -### Frontend (Clerk keys) +- `AUTH_MODE=local`: shared bearer token auth (self-host default) +- `AUTH_MODE=clerk`: Clerk JWT auth -Create `frontend/.env` (this file is **not** committed; `compose.yml` loads it if present): +### Local mode (self-host default) + +Set in `.env` (repo root): ```env -# Frontend → Backend -NEXT_PUBLIC_API_URL=http://localhost:8000 - -# Frontend → Clerk -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY -CLERK_SECRET_KEY=YOUR_SECRET_KEY - -# Optional (but recommended) redirects -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards +AUTH_MODE=local +LOCAL_AUTH_TOKEN=replace-with-strong-random-token ``` -### Backend (auth) +Set frontend mode (optional override in `frontend/.env`): -The backend authenticates requests using the Clerk SDK and **`CLERK_SECRET_KEY`** (see `backend/app/core/auth.py`). +```env +NEXT_PUBLIC_AUTH_MODE=local +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` -Create `backend/.env` (this file is **not** committed) with at least: +Users enter `LOCAL_AUTH_TOKEN` in the local login screen. + +### Clerk mode + +Set in `.env` (repo root): + +```env +AUTH_MODE=clerk +``` + +Create `backend/.env` with at least: ```env CLERK_SECRET_KEY=sk_test_your_real_key - -# Optional tuning CLERK_API_URL=https://api.clerk.com CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 ``` -Then either: -1) update `compose.yml` to load `backend/.env` (recommended), or -2) pass the values via `services.backend.environment`. +Create `frontend/.env` with at least: -**Security:** treat `CLERK_SECRET_KEY` like a password. Do not commit it. +```env +NEXT_PUBLIC_AUTH_MODE=clerk +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_real_key +CLERK_SECRET_KEY=sk_test_your_real_key +``` + +**Security:** treat `LOCAL_AUTH_TOKEN` and `CLERK_SECRET_KEY` like passwords. Do not commit them. ## Troubleshooting diff --git a/docs/production/README.md b/docs/production/README.md index 2933c888..ac50ff17 100644 --- a/docs/production/README.md +++ b/docs/production/README.md @@ -59,8 +59,10 @@ Recommended approach: Secrets guidelines: -- **Clerk auth is required for now**: you must configure Clerk keys/JWKS for the app to work. -- Never commit Clerk secret key. +- Choose auth mode explicitly: + - `AUTH_MODE=local`: set a strong `LOCAL_AUTH_TOKEN` + - `AUTH_MODE=clerk`: configure Clerk keys +- Never commit `LOCAL_AUTH_TOKEN` or Clerk secret key. - Prefer passing secrets as environment variables from the host (or use Docker secrets if you later migrate to Swarm/K8s). - Rotate secrets if they ever hit logs. @@ -75,7 +77,7 @@ sudo git clone https://github.com/abhi1693/openclaw-mission-control.git mission- cd mission-control cp .env.example .env -# edit .env with real values (domains, Clerk keys, etc.) +# edit .env with real values (domains, auth mode + secrets, etc.) docker compose -f compose.yml --env-file .env up -d --build ``` diff --git a/frontend/.env.example b/frontend/.env.example index f8e1716d..425dc8d9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,8 +2,14 @@ # Must be reachable from the browser (host). NEXT_PUBLIC_API_URL=http://localhost:8000 -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY -CLERK_SECRET_KEY=YOUR_SECRET_KEY +# Auth mode: clerk or local. +# - clerk: Clerk sign-in flow +# - local: shared bearer token entered in UI +NEXT_PUBLIC_AUTH_MODE=local + +# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk) +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fb396bf9..a834856f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,6 +15,8 @@ COPY . ./ # Allows configuring the API URL at build time. ARG NEXT_PUBLIC_API_URL=http://localhost:8000 ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ARG NEXT_PUBLIC_AUTH_MODE +ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} RUN npm run build @@ -22,10 +24,12 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +ARG NEXT_PUBLIC_AUTH_MODE # If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well # (but note some values may be baked at build time). ENV NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} COPY --from=builder /app/.next ./.next # `public/` is optional in Next.js apps; repo may not have it. diff --git a/frontend/README.md b/frontend/README.md index 8b858b4a..1bcc1eb0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,7 +4,9 @@ This package is the **Next.js** web UI for OpenClaw Mission Control. - Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`). - Uses **React Query** for data fetching. -- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key). +- Supports two auth modes: + - **local** shared bearer token mode (self-host default) + - **clerk** mode ## Prerequisites @@ -53,27 +55,23 @@ Example: NEXT_PUBLIC_API_URL=http://localhost:8000 ``` -### Optional: Clerk authentication +### Authentication mode -Clerk is **optional**. +Set `NEXT_PUBLIC_AUTH_MODE` to one of: -The app only enables Clerk when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks like a real key. -Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live_...`) in `src/auth/clerkKey.ts`. +- `local` (default for self-host) +- `clerk` -#### Env vars +For `local` mode: + +- users enter the token in the local login screen +- requests use that token as `Authorization: Bearer ...` + +For `clerk` mode, configure: - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` - - If **unset/blank/placeholder**, Clerk is treated as **disabled**. - `CLERK_SECRET_KEY` - - Required only if you enable Clerk features that need server-side verification. -- Redirect URLs (optional; used by Clerk UI flows): - - `NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL` - -**Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`. -Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”. +- optional Clerk redirect env vars ## How the frontend talks to the backend @@ -107,7 +105,7 @@ All Orval-generated requests go through the custom mutator (`src/api/mutator.ts` It will: - set `Content-Type: application/json` when there is a body and you didn’t specify a content type -- add `Authorization: Bearer ` automatically **if** Clerk is enabled and there is an active Clerk session in the browser +- add `Authorization: Bearer ` automatically from local mode token or Clerk session - parse errors into an `ApiError` with status + parsed response body ## Common commands @@ -149,11 +147,11 @@ cp .env.example .env.local - Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port. - If accessing from another device (LAN), use a reachable backend URL (not `localhost`). -### Clerk redirects / auth UI shows unexpectedly +### Wrong auth mode UI -Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` publishable key. - -- Remove/blank `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in your `.env.local` to force Clerk off. +- Ensure `NEXT_PUBLIC_AUTH_MODE` matches backend `AUTH_MODE`. +- For local mode, set `NEXT_PUBLIC_AUTH_MODE=local`. +- For Clerk mode, set `NEXT_PUBLIC_AUTH_MODE=clerk` and a real Clerk publishable key. ### Dev server blocked by origin restrictions diff --git a/frontend/src/api/mutator.ts b/frontend/src/api/mutator.ts index 755b1041..610125f3 100644 --- a/frontend/src/api/mutator.ts +++ b/frontend/src/api/mutator.ts @@ -1,3 +1,5 @@ +import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; + type ClerkSession = { getToken: () => Promise; }; @@ -48,6 +50,12 @@ export const customFetch = async ( if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } + if (isLocalAuthMode() && !headers.has("Authorization")) { + const token = getLocalAuthToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } if (!headers.has("Authorization")) { const token = await resolveClerkToken(); if (token) { diff --git a/frontend/src/auth/clerk.tsx b/frontend/src/auth/clerk.tsx index c134a699..a6508d42 100644 --- a/frontend/src/auth/clerk.tsx +++ b/frontend/src/auth/clerk.tsx @@ -16,21 +16,33 @@ import { } from "@clerk/nextjs"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; + +function hasLocalAuthToken(): boolean { + return Boolean(getLocalAuthToken()); +} export function isClerkEnabled(): boolean { // IMPORTANT: keep this in sync with AuthProvider; otherwise components like // may render without a and crash during prerender. + if (isLocalAuthMode()) return false; return isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); } export function SignedIn(props: { children: ReactNode }) { + if (isLocalAuthMode()) { + return hasLocalAuthToken() ? <>{props.children} : null; + } if (!isClerkEnabled()) return null; return {props.children}; } export function SignedOut(props: { children: ReactNode }) { + if (isLocalAuthMode()) { + return hasLocalAuthToken() ? null : <>{props.children}; + } if (!isClerkEnabled()) return <>{props.children}; return {props.children}; } @@ -49,6 +61,13 @@ export function SignOutButton( } export function useUser() { + if (isLocalAuthMode()) { + return { + isLoaded: true, + isSignedIn: hasLocalAuthToken(), + user: null, + } as const; + } if (!isClerkEnabled()) { return { isLoaded: true, isSignedIn: false, user: null } as const; } @@ -56,6 +75,16 @@ export function useUser() { } export function useAuth() { + if (isLocalAuthMode()) { + const token = getLocalAuthToken(); + return { + isLoaded: true, + isSignedIn: Boolean(token), + userId: token ? "local-user" : null, + sessionId: token ? "local-session" : null, + getToken: async () => token, + } as const; + } if (!isClerkEnabled()) { return { isLoaded: true, diff --git a/frontend/src/auth/localAuth.ts b/frontend/src/auth/localAuth.ts new file mode 100644 index 00000000..caba1f9c --- /dev/null +++ b/frontend/src/auth/localAuth.ts @@ -0,0 +1,43 @@ +"use client"; + +let localToken: string | null = null; +const STORAGE_KEY = "mc_local_auth_token"; + +export function isLocalAuthMode(): boolean { + return process.env.NEXT_PUBLIC_AUTH_MODE === "local"; +} + +export function setLocalAuthToken(token: string): void { + localToken = token; + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(STORAGE_KEY, token); + } catch { + // Ignore storage failures (private mode / policy). + } +} + +export function getLocalAuthToken(): string | null { + if (localToken) return localToken; + if (typeof window === "undefined") return null; + try { + const stored = window.sessionStorage.getItem(STORAGE_KEY); + if (stored) { + localToken = stored; + return stored; + } + } catch { + // Ignore storage failures (private mode / policy). + } + return null; +} + +export function clearLocalAuthToken(): void { + localToken = null; + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage failures (private mode / policy). + } +} diff --git a/frontend/src/components/organisms/LocalAuthLogin.tsx b/frontend/src/components/organisms/LocalAuthLogin.tsx new file mode 100644 index 00000000..b071d593 --- /dev/null +++ b/frontend/src/components/organisms/LocalAuthLogin.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { Lock } from "lucide-react"; + +import { setLocalAuthToken } from "@/auth/localAuth"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +export function LocalAuthLogin() { + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const cleaned = token.trim(); + if (!cleaned) { + setError("Bearer token is required."); + return; + } + setLocalAuthToken(cleaned); + setError(null); + window.location.reload(); + }; + + return ( +
+ + +
+ +
+
+

+ Local Authentication +

+

+ Enter the shared local token configured as + + LOCAL_AUTH_TOKEN + + on the backend. +

+
+
+ +
+ setToken(event.target.value)} + placeholder="Paste token" + autoFocus + /> + {error ?

{error}

: null} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index c074e2fe..1214e9fd 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; import { SignOutButton, useUser } from "@/auth/clerk"; +import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; import { Activity, Bot, @@ -36,13 +37,15 @@ export function UserMenu({ }: UserMenuProps) { const [open, setOpen] = useState(false); const { user } = useUser(); - if (!user) return null; + const localMode = isLocalAuthMode(); + if (!user && !localMode) return null; - const avatarUrl = user.imageUrl ?? null; - const avatarLabelSource = displayNameFromDb ?? user.id ?? "U"; + const avatarUrl = localMode ? null : (user?.imageUrl ?? null); + const avatarLabelSource = + displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U"; const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase(); - const displayName = displayNameFromDb ?? "Account"; - const displayEmail = displayEmailFromDb ?? ""; + const displayName = displayNameFromDb ?? (localMode ? "Local User" : "Account"); + const displayEmail = displayEmailFromDb ?? (localMode ? "local@localhost" : ""); return ( @@ -166,16 +169,31 @@ export function UserMenu({
- + {localMode ? ( - + ) : ( + + + + )}
diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index bbe44ffb..aee53ac9 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -4,8 +4,17 @@ import { ClerkProvider } from "@clerk/nextjs"; import type { ReactNode } from "react"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; +import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; export function AuthProvider({ children }: { children: ReactNode }) { + if (isLocalAuthMode()) { + if (!getLocalAuthToken()) { + return ; + } + return <>{children}; + } + const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; const afterSignOutUrl = process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/"; diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 55604bb3..f636dfec 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -4,6 +4,7 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; const isClerkEnabled = () => + process.env.NEXT_PUBLIC_AUTH_MODE !== "local" && isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, );