From 32c9540d541f3fabdbdbe3aadbffae4990c4ff45 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 12:48:18 +0000 Subject: [PATCH 01/11] docs(api): add module-level index for backend/app/api layer --- docs/07-api-reference.md | 50 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/07-api-reference.md b/docs/07-api-reference.md index 346ffc2b..3b487cf4 100644 --- a/docs/07-api-reference.md +++ b/docs/07-api-reference.md @@ -24,7 +24,7 @@ It is derived from `backend/app/main.py` (router registration) and `backend/app/ | `agents.py` | `/agents` | Thin API wrappers for async agent lifecycle operations. | | `approvals.py` | `/boards/{board_id}/approvals` | Approval listing, streaming, creation, and update endpoints. | | `auth.py` | `/auth` | Authentication bootstrap endpoints for the Mission Control API. | -| `board_group_memory.py` | `` | Board-group memory CRUD and streaming endpoints. | +| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | Board-group memory CRUD and streaming endpoints. | | `board_groups.py` | `/board-groups` | Board group CRUD, snapshot, and heartbeat endpoints. | | `board_memory.py` | `/boards/{board_id}/memory` | Board memory CRUD and streaming endpoints. | | `board_onboarding.py` | `/boards/{board_id}/onboarding` | Board onboarding endpoints for user/agent collaboration. | @@ -37,6 +37,54 @@ It is derived from `backend/app/main.py` (router registration) and `backend/app/ | `tasks.py` | `/boards/{board_id}/tasks` | Task API routes for listing, streaming, and mutating board tasks. | | `users.py` | `/users` | User self-service API endpoints for profile retrieval and updates. | +## Backend API layer notes (how modules are organized) + +Evidence: `backend/app/main.py`, `backend/app/api/*`, `backend/app/api/deps.py`. + +### Conventions + +- Each file under `backend/app/api/*` typically declares an `APIRouter` (`router = APIRouter(...)`) and defines endpoints with decorators like `@router.get(...)`, `@router.post(...)`, etc. +- Board-scoped modules embed `{board_id}` in the prefix (e.g. `/boards/{board_id}/tasks`). +- Streaming endpoints usually expose **SSE** endpoints at `.../stream` (see `sse-starlette` usage). + +### Where key behaviors live + +- **Router wiring / base prefix**: `backend/app/main.py` mounts these routers under `/api/v1/*`. +- **Auth / access control** is mostly expressed through dependencies (see `backend/app/api/deps.py`): + - `require_admin_auth` — require an authenticated *admin user*. + - `require_admin_or_agent` — allow either an admin user or an authenticated agent. + - `get_board_for_actor_read` / `get_board_for_actor_write` — enforce board access for the calling actor. + - `require_org_member` / `require_org_admin` — enforce org membership/admin for user callers. +- **Agent-only surface**: `backend/app/api/agent.py` uses `get_agent_auth_context` (X-Agent-Token) and contains board/task/memory endpoints specifically for automation. + +### Module-by-module map (prefix, key endpoints, and pointers) + +This is a “where to look” index, not a full OpenAPI dump. For exact parameters and response shapes, see: +- route module file (`backend/app/api/.py`) +- schemas (`backend/app/schemas/*`) +- models (`backend/app/models/*`) +- services (`backend/app/services/*`) + +| Module | Prefix (under `/api/v1`) | Key endpoints (examples) | Main deps / auth | Pointers (schemas/models/services) | +|---|---|---|---|---| +| `activity.py` | `/activity` | `GET /activity` (events); `GET /activity/task-comments` + `/stream` | `require_admin_or_agent`, `require_org_member` | `app/models/activity_events.py`, `app/schemas/activity_events.py` | +| `agent.py` | `/agent` | agent automation surface: boards/tasks/memory + gateway coordination | `get_agent_auth_context` (X-Agent-Token) | `backend/app/core/agent_auth.py`, `backend/app/services/openclaw/*` | +| `agents.py` | `/agents` | agent lifecycle + SSE stream + heartbeat | org-admin gated for user callers; some endpoints allow agent access via deps | `app/schemas/agents.py`, `app/services/openclaw/provisioning_db.py` | +| `approvals.py` | `/boards/{board_id}/approvals` | list/create/update approvals + `/stream` | `require_admin_or_agent` + board access deps | `app/models/approvals.py`, `app/schemas/approvals.py` | +| `auth.py` | `/auth` | `POST /auth/bootstrap` | `get_auth_context` (Clerk/user) | `backend/app/core/auth.py`, `app/schemas/users.py` | +| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | list/create + `/stream` (group + board-context views) | `require_admin_or_agent`, `require_org_member`, board access deps | `app/models/board_group_memory.py`, `app/schemas/board_group_memory.py` | +| `board_groups.py` | `/board-groups` | CRUD + snapshot + heartbeat apply | `require_org_member` / `require_org_admin` | `app/services/board_group_snapshot.py` | +| `board_memory.py` | `/boards/{board_id}/memory` | list/create + `/stream` | `require_admin_or_agent` + board access deps | `app/models/board_memory.py`, `app/schemas/board_memory.py` | +| `board_onboarding.py` | `/boards/{board_id}/onboarding` | start/answer/confirm onboarding; agent update callback | mix of admin-user and admin-or-agent deps | `app/models/board_onboarding.py`, `app/services/openclaw/onboarding_service.py` | +| `boards.py` | `/boards` | list/create/update/delete; `/snapshot`; `/group-snapshot` | user/org access (`require_org_member`); read access via `get_board_for_actor_read` in some paths | `app/services/board_snapshot.py`, `app/schemas/boards.py` | +| `gateway.py` | `/gateways` | session inspection + supported commands list | org-admin user only | `backend/app/services/openclaw/gateway_rpc.py`, `app/schemas/gateway_api.py` | +| `gateways.py` | `/gateways` | gateway CRUD + template sync | org-admin user only | `app/models/gateways.py`, `app/services/openclaw/*` | +| `metrics.py` | `/metrics` | `GET /metrics/dashboard` | org-member user access | `app/schemas/metrics.py` | +| `organizations.py` | `/organizations` | org create + invites/membership flows | user (Clerk) + org member/admin checks | `app/services/organizations.py` | +| `souls_directory.py` | `/souls-directory` | search + fetch markdown entries | `require_admin_or_agent` | `app/services/souls_directory.py`, `app/schemas/souls_directory.py` | +| `tasks.py` | `/boards/{board_id}/tasks` | list/create/update/delete tasks; comments; `/stream` | mix of admin-only + admin-or-agent; board access via deps | `app/models/tasks.py`, `app/schemas/tasks.py`, `app/services/task_dependencies.py` | +| `users.py` | `/users` | `GET/PATCH/DELETE /users/me` | user (Clerk) | `app/models/users.py`, `app/schemas/users.py` | + ## `/activity` — `activity.py` *Activity listing and task-comment feed endpoints.* From cacb25abfac26bc29207f099ef7a5b4574527c33 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 12:48:53 +0000 Subject: [PATCH 02/11] docs: add frontend API/auth module reference --- docs/README.md | 1 + docs/frontend-api-auth.md | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/frontend-api-auth.md diff --git a/docs/README.md b/docs/README.md index 8f75bbf1..1e54c70f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ This folder is the canonical documentation set for Mission Control. 5. [Architecture](05-architecture.md) 6. [Configuration](06-configuration.md) 7. [API reference](07-api-reference.md) + - [Frontend API + auth modules](frontend-api-auth.md) 8. [Agents & skills](08-agents-and-skills.md) 9. [Ops / runbooks](09-ops-runbooks.md) 10. [Troubleshooting](10-troubleshooting.md) diff --git a/docs/frontend-api-auth.md b/docs/frontend-api-auth.md new file mode 100644 index 00000000..c7e19899 --- /dev/null +++ b/docs/frontend-api-auth.md @@ -0,0 +1,109 @@ +# Frontend API client and auth integration + +This page documents the frontend integration points you’ll touch when changing how the UI talks to the backend or how auth is applied. + +## Related docs + +- [Architecture](05-architecture.md) +- [Configuration](06-configuration.md) +- [API reference](07-api-reference.md) + +## API base URL + +The frontend uses `NEXT_PUBLIC_API_URL` as the single source of truth for where to send API requests. + +- Code: `frontend/src/lib/api-base.ts` +- Behavior: + - reads `process.env.NEXT_PUBLIC_API_URL` + - normalizes by trimming trailing slashes + - throws early if missing/invalid + +In Docker Compose, `compose.yml` sets `NEXT_PUBLIC_API_URL` both: +- as a **build arg** (for `next build`), and +- as a **runtime env var**. + +## API client layout + +### Generated client + +- Location: `frontend/src/api/generated/*` +- Generator: **Orval** + - Config: `frontend/orval.config.ts` + - Script: `cd frontend && npm run api:gen` + - Convenience target: `make api-gen` + +By default, Orval reads the backend OpenAPI schema from: +- `ORVAL_INPUT` (if set), otherwise +- `http://127.0.0.1:8000/openapi.json` + +Output details (from `orval.config.ts`): +- Mode: `tags-split` +- Target index: `frontend/src/api/generated/index.ts` +- Schemas: `frontend/src/api/generated/model` +- Client: `react-query` +- All requests go through the custom mutator below. + +### Custom fetch / mutator + +All generated requests go through: + +- Code: `frontend/src/api/mutator.ts` +- What it does: + - resolves `NEXT_PUBLIC_API_URL` and builds the full request URL + - sets `Content-Type: application/json` when there’s a body + - injects `Authorization: Bearer ` when a Clerk session token is available + - converts non-2xx responses into a typed `ApiError` (status + parsed response) + +## Auth enablement and token injection + +### Clerk enablement (publishable key gating) + +Clerk is enabled in the frontend only when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks valid. + +- Gating helper (dependency-free): `frontend/src/auth/clerkKey.ts` +- UI-safe wrappers/hooks: `frontend/src/auth/clerk.tsx` + - provides `SignedIn`, `SignedOut`, `SignInButton`, `SignOutButton`, `useUser`, and `useAuth` + - returns safe fallbacks when Clerk is disabled (to allow secretless builds/prerender) + +### Token injection + +When the UI makes an API request, the mutator attempts to read a token from the Clerk session: + +- Code: `frontend/src/api/mutator.ts` (`resolveClerkToken()`) +- If a token is available, the request includes: + - `Authorization: Bearer ` + +### Route protection (middleware) + +Request-time route protection is implemented via Next.js middleware: + +- Code: `frontend/src/proxy.ts` +- Behavior: + - when Clerk is enabled: uses `clerkMiddleware()` to enforce auth on non-public routes + - when Clerk is disabled: passes all requests through + +## Common workflows + +### Update the backend API and regenerate the client + +1. Run the backend so OpenAPI is available: + +```bash +# from repo root +cp backend/.env.example backend/.env +make backend-migrate +cd backend && uv run uvicorn app.main:app --reload --port 8000 +``` + +2. Regenerate the client: + +```bash +# from repo root +make api-gen + +# or from frontend/ +ORVAL_INPUT=http://127.0.0.1:8000/openapi.json npm run api:gen +``` + +3. Review diffs under `frontend/src/api/generated/*`. + From a2a4159a51be331d3bb5784d0b76427686230cc9 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 12:50:06 +0000 Subject: [PATCH 03/11] docs(backend): add core module guide (config/auth/deps/logging/errors) --- docs/12-backend-core.md | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/12-backend-core.md diff --git a/docs/12-backend-core.md b/docs/12-backend-core.md new file mode 100644 index 00000000..980a20c4 --- /dev/null +++ b/docs/12-backend-core.md @@ -0,0 +1,137 @@ +# Backend core modules (auth/config/logging/errors) + +> Evidence basis: repo https://github.com/abhi1693/openclaw-mission-control @ commit `c3490630a4503d9c8142aaa3abf542e0d00b5035`. + +This page documents the backend “core” layer under `backend/app/core/*` plus the API dependency module `backend/app/api/deps.py`. + +It’s written for maintainers who need to answer: + +- “Where does configuration come from?” +- “How do user vs agent auth work?” +- “Where are authorization decisions enforced?” +- “What’s the error envelope / request-id behavior?” +- “How is logging structured and how do I get request-context in logs?” + +## Start here (reading order) + +1. `backend/app/core/config.py` — settings + env file loading +2. `backend/app/core/logging.py` — structured logging + request context +3. `backend/app/core/error_handling.py` — request-id middleware + exception envelope +4. `backend/app/core/auth.py` — Clerk/user auth resolution +5. `backend/app/core/agent_auth.py` — agent token auth resolution +6. `backend/app/api/deps.py` — how routes declare and enforce access + +## Configuration: loading & precedence + +**Primary file:** `backend/app/core/config.py` + +Key facts: +- Uses `pydantic-settings` (`BaseSettings`) to load typed settings from environment. +- Env files are loaded regardless of current working directory: + - `backend/.env` (via `DEFAULT_ENV_FILE`) + - then `.env` (repo root) as an additional source + - See `Settings.model_config.env_file=[DEFAULT_ENV_FILE, ".env"]`. +- Unknown env vars are ignored (`extra="ignore"`). + +Notable settings (security-sensitive in **bold**): +- `DATABASE_URL` / `database_url` +- `CORS_ORIGINS` / `cors_origins` +- `DB_AUTO_MIGRATE` / `db_auto_migrate` +- **`CLERK_SECRET_KEY` / `clerk_secret_key`** (must be non-empty; validator enforces it) +- `CLERK_API_URL`, `CLERK_VERIFY_IAT`, `CLERK_LEEWAY` +- logging knobs: `LOG_LEVEL`, `LOG_FORMAT`, `LOG_USE_UTC`, `REQUEST_LOG_SLOW_MS`, `REQUEST_LOG_INCLUDE_HEALTH` + +### Deployment implication + +- If a deployment accidentally starts the backend with an empty/placeholder `CLERK_SECRET_KEY`, the backend will fail settings validation at startup. + +## Auth model split + +The backend supports two top-level actor types: + +- **User** (human UI / admin) — resolved from the `Authorization: Bearer ` header via Clerk. +- **Agent** (automation) — resolved from `X-Agent-Token: ` (and optionally `Authorization: Bearer ` for agent callers). + +### User auth (Clerk) — `backend/app/core/auth.py` + +What it does: +- Uses the `clerk_backend_api` SDK to authenticate requests (`authenticate_request(...)`) using `CLERK_SECRET_KEY`. +- Resolves a `AuthContext` containing `actor_type="user"` and a `User` model instance. +- The module includes helpers to fetch user profile details from Clerk (`_fetch_clerk_profile`) and to delete a Clerk user (`delete_clerk_user`). + +Security-sensitive notes: +- Treat `CLERK_SECRET_KEY` as a credential; never log it. +- This code calls Clerk API endpoints over the network (timeouts and error handling matter). + +### Agent auth (token hash) — `backend/app/core/agent_auth.py` + +What it does: +- Requires a token header for protected agent endpoints: + - Primary header: `X-Agent-Token` + - Optional parsing: `Authorization: Bearer ...` (only in `get_agent_auth_context`, and only if `accept_authorization=True`) +- Validates token by comparing it against stored `agent_token_hash` values in the DB (`verify_agent_token`). +- “Touches” agent presence (`last_seen_at`, `status`) on authenticated requests. + - For safe methods (`GET/HEAD/OPTIONS`), it commits immediately so read-only polling still shows the agent as online. + +Security-sensitive notes: +- Token verification iterates over agents with a token hash. If this grows large, consider indexing/lookup strategy. +- Never echo full tokens in logs; current code logs only a prefix on invalid tokens. + +## Authorization enforcement: `backend/app/api/deps.py` + +This module is the primary “policy wiring” for most routes. + +Key concepts: + +- `require_admin_auth(...)` + - Requires an authenticated *admin user*. +- `require_admin_or_agent(...)` → returns `ActorContext` + - Allows either: + - admin user (user auth via Clerk), or + - authenticated agent (agent auth via X-Agent-Token). + +Board/task access patterns: +- `get_board_for_actor_read` / `get_board_for_actor_write` + - Enforces that the caller (user or agent) has the correct access to the board. + - Agent access is restricted if the agent is bound to a specific board (`agent.board_id`). +- `get_task_or_404` + - Loads a task and ensures it belongs to the requested board. + +Org access patterns (user callers): +- `require_org_member` and `require_org_admin` + - Resolve/require active org membership. + - Provide an `OrganizationContext` with `organization` + `member`. + +Maintainer tip: +- When debugging a “why is this 403/401?”, start by checking the route’s dependency stack (in the route module) and trace through the relevant dependency in `deps.py`. + +## Logging: structure + request context + +**Primary file:** `backend/app/core/logging.py` + +Highlights: +- Defines a custom TRACE level (`TRACE_LEVEL = 5`). +- Uses `contextvars` to carry `request_id`, `method`, and `path` across async tasks. +- `AppLogFilter` injects `app`, `version`, and request context into each log record. +- Supports JSON output (`JsonFormatter`) and key=value (`KeyValueFormatter`) formats. + +Where request context gets set: +- `backend/app/core/error_handling.py` middleware calls: + - `set_request_id(...)` + - `set_request_route_context(method, path)` + +## Error envelope + request-id + +**Primary file:** `backend/app/core/error_handling.py` + +Key behaviors: +- Installs a `RequestIdMiddleware` (ASGI) that: + - Accepts client-provided `X-Request-Id` or generates one. + - Adds `X-Request-Id` to the response. + - Emits structured “http.request.*” logs, including “slow request” warnings. +- Error responses include `request_id` when available: + - Validation errors (`422`) return `{detail: , request_id: ...}`. + - Other HTTP errors are wrapped similarly. + +Maintainer tip: +- When debugging incidents, ask for the `X-Request-Id` from the client and use it to locate backend logs quickly. From d7e8d41c65ea597396f1b2155757f5a636bf2bf3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 12:55:22 +0000 Subject: [PATCH 04/11] docs: add documentation style guide --- docs/00-style-guide.md | 96 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/00-style-guide.md diff --git a/docs/00-style-guide.md b/docs/00-style-guide.md new file mode 100644 index 00000000..c8a5419c --- /dev/null +++ b/docs/00-style-guide.md @@ -0,0 +1,96 @@ +# Documentation style guide + +This repository aims for a NetBox-like style: clear, technical, and written for working engineers. + +## Voice and tone + +- **Direct and technical.** Prefer short sentences and specific nouns. +- **Narrative flow.** Describe how the system behaves, not how the doc was produced. +- **Calm, professional tone.** Avoid hype. +- **Assume competence, not context.** Define repo-specific terms once, then reuse them. + +## Page structure (default) + +Use a consistent, scan-friendly layout. + +1. **Title** +2. **1–3 sentence intro** + - What this page covers and who it’s for. +3. **Deep dives / Related docs** (optional but common) + - Links to more detailed pages. +4. **Main content** + - Prefer sections that match user intent: “Quickstart”, “How it works”, “Configuration”, “Common workflows”, “Troubleshooting”. +5. **Next steps** (optional) + - Where to go next. + +## Headings and conventions + +- Prefer **verb-led** headings when describing procedures: “Run migrations”, “Regenerate the client”. +- Prefer **intent-led** headings when describing concepts: “How requests flow”, “Auth model”. +- Use numbered steps when order matters. +- Keep headings short; avoid long parentheticals. + +## Cross-linking + +- Treat the numbered IA pages in `docs/` as **entrypoints**. +- Link to deep dives instead of duplicating content. +- Use readable link text: + - Good: “Deployment guide” → `docs/deployment/README.md` + - Avoid: ``docs/deployment/README.md`` + +## Link formatting rules + +- Use markdown links: `[Deployment guide](deployment/README.md)`. +- Use relative paths that work in GitHub and typical markdown renderers. +- Keep code formatting for: + - commands (`make check`) + - environment variables (`NEXT_PUBLIC_API_URL`) + - literal file paths when you mean “this exact file on disk” (not as a navigational link) + +## Avoided phrases (and what to use instead) + +Avoid doc-meta language: + +- Avoid: “evidence basis”, “evidence anchors”, “this page is intentionally…” +- Prefer: + - “Source of truth: …” (only when it matters) + - “See also: …” + - Just link the file or section. + +Avoid hedging: + +- Avoid: “likely”, “probably”, “should” (unless it’s a policy decision) +- Prefer: state what the code does, and point to the file. + +## Preferred patterns + +- **Start here** blocks for role-based entry. +- **Common workflows** sections with copy/paste commands. +- **Troubleshooting** sections with symptoms → checks → fixes. +- **Footguns** called out explicitly when they can cause outages or confusing behavior. + +## Example rewrites + +### Example 1: remove doc-meta “evidence” language + +Before: +> Evidence basis: consolidated from repo root `README.md`, `.github/workflows/ci.yml`, `Makefile`. + +After: +> This page describes the development workflow that matches CI: setup, checks, and common local loops. + +### Example 2: prefer readable links over code-formatted paths + +Before: +- See `docs/deployment/README.md` for deployment. + +After: +- See the [Deployment guide](deployment/README.md). + +### Example 3: replace “first pass” filler with a clear scope boundary + +Before: +- Non-goals (first pass) + +After: +- Out of scope From a84d7d6cab99bfc342a09f82fe066a062fb19dd0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 12:55:43 +0000 Subject: [PATCH 05/11] docs: link style guide from docs landing --- docs/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/README.md b/docs/README.md index 8f75bbf1..2677e738 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,9 @@ This folder is the canonical documentation set for Mission Control. ## Table of contents (IA) +- [Style guide](00-style-guide.md) + + 1. [Overview](01-overview.md) 2. [Quickstart](02-quickstart.md) 3. [Development](03-development.md) From 0f48d201dd1df500d67db00f6573857956615e88 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 13:24:19 +0000 Subject: [PATCH 06/11] test(e2e): reduce Clerk sign-in flake in activity feed spec --- frontend/cypress/e2e/activity_feed.cy.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index a80af55e..ff266bb1 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -4,6 +4,18 @@ describe("/activity feed", () => { const apiBase = "**/api/v1"; const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + // Clerk's Cypress helpers perform async work inside `cy.then()`. + // CI can be slow enough that the default 4s command timeout flakes. + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + function stubStreamEmpty() { cy.intercept( "GET", From 06ff1a972039b8df8324aa45783977cc5039ac53 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 19:10:23 +0530 Subject: [PATCH 07/11] feat: implement local authentication mode and update related components --- .env.example | 2 + README.md | 22 +++-- backend/.env.example | 8 +- backend/app/core/auth.py | 89 ++++++++++++++++- backend/app/core/config.py | 20 +++- backend/tests/conftest.py | 6 ++ .../tests/test_authenticate_request_flow.py | 52 ++++++++++ backend/tests/test_local_auth_integration.py | 99 +++++++++++++++++++ compose.yml | 4 + docs/05-architecture.md | 19 ++-- docs/architecture/README.md | 25 +++-- docs/deployment/README.md | 59 ++++++----- docs/production/README.md | 8 +- frontend/.env.example | 10 +- frontend/Dockerfile | 4 + frontend/README.md | 40 ++++---- frontend/src/api/mutator.ts | 8 ++ frontend/src/auth/clerk.tsx | 29 ++++++ frontend/src/auth/localAuth.ts | 43 ++++++++ .../components/organisms/LocalAuthLogin.tsx | 65 ++++++++++++ .../src/components/organisms/UserMenu.tsx | 34 +++++-- .../src/components/providers/AuthProvider.tsx | 9 ++ frontend/src/proxy.ts | 1 + 23 files changed, 563 insertions(+), 93 deletions(-) create mode 100644 backend/tests/test_local_auth_integration.py create mode 100644 frontend/src/auth/localAuth.ts create mode 100644 frontend/src/components/organisms/LocalAuthLogin.tsx 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, ); From b87f56de7ab312175c78d08c2b1cb19dc90d893a Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 19:13:23 +0530 Subject: [PATCH 08/11] feat: update authentication configuration and documentation for local and Clerk modes --- docs/06-configuration.md | 6 +----- docs/deployment/README.md | 7 ++++--- frontend/.env.example | 4 ---- frontend/README.md | 4 ++-- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/06-configuration.md b/docs/06-configuration.md index 855f0241..564b0e6b 100644 --- a/docs/06-configuration.md +++ b/docs/06-configuration.md @@ -89,16 +89,12 @@ This table is based on `backend/app/core/config.py`, `.env.example`, `backend/.e |---|---:|---|---|---| | `NEXT_PUBLIC_API_URL` | **yes** | Backend base URL used by the browser | `http://localhost:8000` | Must be browser-reachable | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | **yes** | Enables Clerk in the frontend | (none) | Must be a real publishable key | -| `CLERK_SECRET_KEY` | **yes** | Clerk secret key used by the frontend (server-side) and E2E | (none) | Do not commit; required for Clerk-enabled operation | -| `NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL` | optional | Post-login redirect | `/boards` | — | -| `NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL` | optional | Post-signup redirect | `/boards` | — | | `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` | optional | Fallback redirect | `/boards` | — | -| `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL` | optional | Fallback redirect | `/boards` | — | | `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL` | optional | Post-logout redirect | `/` | — | ## Operational footguns -- **Clerk placeholder keys**: `frontend/.env.example` contains non-empty Clerk placeholders. `compose.yml` intentionally does **not** load it, because it can accidentally flip Clerk “on”. Prefer user-managed `frontend/.env` (for Compose) or `frontend/.env.local` (for Next dev). +- **Frontend env template vs runtime env**: `frontend/.env.example` is a template and `compose.yml` intentionally does **not** load it at runtime. Use user-managed `frontend/.env` (for Compose) or `frontend/.env.local` (for Next dev). - **`DB_AUTO_MIGRATE`**: - In `ENVIRONMENT=dev`, backend defaults `DB_AUTO_MIGRATE=true` if you didn’t set it explicitly. - In production, consider disabling auto-migrate and running migrations as an explicit step. diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 60add961..ef844497 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -15,7 +15,9 @@ When running Compose, you get: - Health check: `GET /healthz` - **Frontend UI** (Next.js) on `http://localhost:${FRONTEND_PORT:-3000}` -Auth (Clerk) is **required** right now. You must configure Clerk keys for the frontend and backend (`CLERK_SECRET_KEY`). +Auth is configurable per deployment: +- `AUTH_MODE=local` (self-host default; shared bearer token) +- `AUTH_MODE=clerk` (Clerk JWT auth; backend requires `CLERK_SECRET_KEY`) ## Requirements @@ -86,7 +88,7 @@ These persist across `docker compose down`. ### Root `.env` (Compose) - Copy the template: `cp .env.example .env` -- Edit values as needed (ports, Clerk URLs/keys, etc.) +- Edit values as needed (ports, auth mode, tokens, API URL, etc.) Compose is invoked with: @@ -158,7 +160,6 @@ Create `frontend/.env` with at least: 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. diff --git a/frontend/.env.example b/frontend/.env.example index 425dc8d9..b1db4249 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -9,9 +9,5 @@ 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 -NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/ diff --git a/frontend/README.md b/frontend/README.md index 1bcc1eb0..33052326 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -70,8 +70,8 @@ For `local` mode: For `clerk` mode, configure: - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` -- `CLERK_SECRET_KEY` -- optional Clerk redirect env vars +- optional `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` +- optional `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL` ## How the frontend talks to the backend From 571b4844d9ffdf27c3bfc8c650a9ed35cb442cef Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 19:30:25 +0530 Subject: [PATCH 09/11] feat: update local authentication mode to require a non-placeholder token of at least 50 characters --- .env.example | 3 +- README.md | 5 +- backend/.env.example | 3 +- backend/app/core/auth.py | 7 +- backend/app/core/auth_mode.py | 12 ++ backend/app/core/config.py | 28 +++- backend/tests/conftest.py | 6 +- .../tests/test_authenticate_request_flow.py | 11 +- backend/tests/test_config_auth_mode.py | 70 +++++++++ backend/tests/test_local_auth_integration.py | 3 +- compose.yml | 2 +- docs/deployment/README.md | 5 +- docs/production/README.md | 2 +- frontend/src/auth/localAuth.ts | 4 +- frontend/src/auth/mode.ts | 4 + .../organisms/LocalAuthLogin.test.tsx | 115 +++++++++++++++ .../components/organisms/LocalAuthLogin.tsx | 134 ++++++++++++++---- frontend/src/proxy.ts | 3 +- 18 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 backend/app/core/auth_mode.py create mode 100644 backend/tests/test_config_auth_mode.py create mode 100644 frontend/src/auth/mode.ts create mode 100644 frontend/src/components/organisms/LocalAuthLogin.test.tsx diff --git a/.env.example b/.env.example index 1d75556b..6823a9d0 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,8 @@ DB_AUTO_MIGRATE=true LOG_LEVEL=INFO REQUEST_LOG_SLOW_MS=1000 AUTH_MODE=local -LOCAL_AUTH_TOKEN=change-me +# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars). +LOCAL_AUTH_TOKEN= # --- frontend settings --- # REQUIRED: Public URL used by the browser to reach the API. diff --git a/README.md b/README.md index 24ebb1fa..11b4272a 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,15 @@ Mission Control supports two auth modes via `AUTH_MODE`: ```bash cp .env.example .env +# REQUIRED for local auth mode: +# set LOCAL_AUTH_TOKEN to a non-placeholder value with at least 50 characters. + # REQUIRED: the browser must be able to reach the backend. # 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). # Auth defaults in .env.example are local mode. -# For production, set LOCAL_AUTH_TOKEN to a strong random value. +# For production, set LOCAL_AUTH_TOKEN to a random value with at least 50 characters. # 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 0cd2515b..8d3f3dec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,7 +10,8 @@ BASE_URL= # Auth mode: clerk or local. AUTH_MODE=local -LOCAL_AUTH_TOKEN=change-me +# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars). +LOCAL_AUTH_TOKEN= # Clerk (auth only; used when AUTH_MODE=clerk) CLERK_SECRET_KEY= diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 4ac7f6a1..14decaf9 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -16,6 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ValidationError from starlette.concurrency import run_in_threadpool +from app.core.auth_mode import AuthMode from app.core.config import settings from app.core.logging import get_logger from app.db import crud @@ -244,7 +245,7 @@ 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": + if settings.auth_mode != AuthMode.CLERK: return secret = settings.clerk_secret_key.strip() @@ -422,7 +423,7 @@ async def get_auth_context( session: AsyncSession = SESSION_DEP, ) -> AuthContext: """Resolve required authenticated user context for the configured auth mode.""" - if settings.auth_mode == "local": + if settings.auth_mode == AuthMode.LOCAL: local_auth = await _resolve_local_auth_context( request=request, session=session, @@ -466,7 +467,7 @@ 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": + if settings.auth_mode == AuthMode.LOCAL: return await _resolve_local_auth_context( request=request, session=session, diff --git a/backend/app/core/auth_mode.py b/backend/app/core/auth_mode.py new file mode 100644 index 00000000..c2687fb5 --- /dev/null +++ b/backend/app/core/auth_mode.py @@ -0,0 +1,12 @@ +"""Shared auth-mode enum values.""" + +from __future__ import annotations + +from enum import Enum + + +class AuthMode(str, Enum): + """Supported authentication modes for backend and frontend.""" + + CLERK = "clerk" + LOCAL = "local" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 08482ed5..d857e39e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,13 +3,24 @@ from __future__ import annotations from pathlib import Path -from typing import Literal, Self +from typing import Self from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.auth_mode import AuthMode + BACKEND_ROOT = Path(__file__).resolve().parents[2] DEFAULT_ENV_FILE = BACKEND_ROOT / ".env" +LOCAL_AUTH_TOKEN_MIN_LENGTH = 50 +LOCAL_AUTH_TOKEN_PLACEHOLDERS = frozenset( + { + "change-me", + "changeme", + "replace-me", + "replace-with-strong-random-token", + }, +) class Settings(BaseSettings): @@ -27,7 +38,7 @@ class Settings(BaseSettings): 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"] + auth_mode: AuthMode local_auth_token: str = "" # Clerk auth (auth only; roles stored in DB) @@ -51,15 +62,20 @@ class Settings(BaseSettings): @model_validator(mode="after") def _defaults(self) -> Self: - if self.auth_mode == "clerk": + if self.auth_mode == AuthMode.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(): + elif self.auth_mode == AuthMode.LOCAL: + token = self.local_auth_token.strip() + if ( + not token + or len(token) < LOCAL_AUTH_TOKEN_MIN_LENGTH + or token.lower() in LOCAL_AUTH_TOKEN_PLACEHOLDERS + ): raise ValueError( - "LOCAL_AUTH_TOKEN must be set and non-empty when AUTH_MODE=local.", + "LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.", ) # In dev, default to applying Alembic migrations at startup to avoid # schema drift (e.g. missing newly-added columns). diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index db99bf63..e522fc29 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,6 @@ 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") +# defaults during import-time settings initialization, regardless of shell env. +os.environ["AUTH_MODE"] = "local" +os.environ["LOCAL_AUTH_TOKEN"] = "test-local-token-0123456789-0123456789-0123456789x" diff --git a/backend/tests/test_authenticate_request_flow.py b/backend/tests/test_authenticate_request_flow.py index a85612fb..d0fcde68 100644 --- a/backend/tests/test_authenticate_request_flow.py +++ b/backend/tests/test_authenticate_request_flow.py @@ -9,6 +9,7 @@ import pytest from fastapi import HTTPException from app.core import auth +from app.core.auth_mode import AuthMode from app.models.users import User @@ -21,7 +22,7 @@ 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, "auth_mode", AuthMode.CLERK) monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") from clerk_backend_api.security.types import AuthStatus, RequestState @@ -45,7 +46,7 @@ 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, "auth_mode", AuthMode.CLERK) monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") from clerk_backend_api.security.types import AuthStatus, RequestState @@ -88,7 +89,7 @@ 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, "auth_mode", AuthMode.CLERK) monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") async def _boom(_request: Any) -> Any: # pragma: no cover @@ -108,7 +109,7 @@ async def test_get_auth_context_optional_returns_none_for_agent_token( 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, "auth_mode", AuthMode.LOCAL) monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") async def _fake_local_user(_session: Any) -> User: @@ -131,7 +132,7 @@ async def test_get_auth_context_local_mode_requires_valid_bearer_token( 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, "auth_mode", AuthMode.LOCAL) monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") async def _boom(_session: Any) -> User: # pragma: no cover diff --git a/backend/tests/test_config_auth_mode.py b/backend/tests/test_config_auth_mode.py new file mode 100644 index 00000000..1f913302 --- /dev/null +++ b/backend/tests/test_config_auth_mode.py @@ -0,0 +1,70 @@ +# ruff: noqa: INP001 +"""Settings validation tests for auth-mode configuration.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.core.auth_mode import AuthMode +from app.core.config import Settings + + +def test_local_mode_requires_non_empty_token() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="", + ) + + +def test_local_mode_requires_minimum_length() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="x" * 49, + ) + + +def test_local_mode_rejects_placeholder_token() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="change-me", + ) + + +def test_local_mode_accepts_real_token() -> None: + token = "a" * 50 + settings = Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token=token, + ) + + assert settings.auth_mode == AuthMode.LOCAL + assert settings.local_auth_token == token + + +def test_clerk_mode_requires_secret_key() -> None: + with pytest.raises( + ValidationError, + match="CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.CLERK, + clerk_secret_key="", + ) diff --git a/backend/tests/test_local_auth_integration.py b/backend/tests/test_local_auth_integration.py index 135afdff..79e30f6b 100644 --- a/backend/tests/test_local_auth_integration.py +++ b/backend/tests/test_local_auth_integration.py @@ -14,6 +14,7 @@ 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.auth_mode import AuthMode from app.core.config import settings from app.db.session import get_session @@ -51,7 +52,7 @@ async def test_local_auth_users_me_requires_and_accepts_valid_token( expected_email = f"local-{unique_suffix}@localhost" expected_name = "Local Integration User" - monkeypatch.setattr(settings, "auth_mode", "local") + monkeypatch.setattr(settings, "auth_mode", AuthMode.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) diff --git a/compose.yml b/compose.yml index 30ee9318..f3c85fd1 100644 --- a/compose.yml +++ b/compose.yml @@ -31,7 +31,7 @@ services: 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} + LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} depends_on: db: condition: service_healthy diff --git a/docs/deployment/README.md b/docs/deployment/README.md index ef844497..3423ab26 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -32,6 +32,9 @@ From repo root: ```bash cp .env.example .env +# REQUIRED for local mode: +# set LOCAL_AUTH_TOKEN in .env to a non-placeholder value with at least 50 characters. + docker compose -f compose.yml --env-file .env up -d --build ``` @@ -125,7 +128,7 @@ Set in `.env` (repo root): ```env AUTH_MODE=local -LOCAL_AUTH_TOKEN=replace-with-strong-random-token +LOCAL_AUTH_TOKEN=replace-with-random-token-at-least-50-characters ``` Set frontend mode (optional override in `frontend/.env`): diff --git a/docs/production/README.md b/docs/production/README.md index ac50ff17..98f1db4b 100644 --- a/docs/production/README.md +++ b/docs/production/README.md @@ -60,7 +60,7 @@ Recommended approach: Secrets guidelines: - Choose auth mode explicitly: - - `AUTH_MODE=local`: set a strong `LOCAL_AUTH_TOKEN` + - `AUTH_MODE=local`: set `LOCAL_AUTH_TOKEN` to a random value with at least 50 characters - `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 diff --git a/frontend/src/auth/localAuth.ts b/frontend/src/auth/localAuth.ts index caba1f9c..cfe59c97 100644 --- a/frontend/src/auth/localAuth.ts +++ b/frontend/src/auth/localAuth.ts @@ -1,10 +1,12 @@ "use client"; +import { AuthMode } from "@/auth/mode"; + let localToken: string | null = null; const STORAGE_KEY = "mc_local_auth_token"; export function isLocalAuthMode(): boolean { - return process.env.NEXT_PUBLIC_AUTH_MODE === "local"; + return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local; } export function setLocalAuthToken(token: string): void { diff --git a/frontend/src/auth/mode.ts b/frontend/src/auth/mode.ts new file mode 100644 index 00000000..6bf1d992 --- /dev/null +++ b/frontend/src/auth/mode.ts @@ -0,0 +1,4 @@ +export enum AuthMode { + Clerk = "clerk", + Local = "local", +} diff --git a/frontend/src/components/organisms/LocalAuthLogin.test.tsx b/frontend/src/components/organisms/LocalAuthLogin.test.tsx new file mode 100644 index 00000000..49675f8f --- /dev/null +++ b/frontend/src/components/organisms/LocalAuthLogin.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { LocalAuthLogin } from "./LocalAuthLogin"; + +const setLocalAuthTokenMock = vi.hoisted(() => vi.fn()); +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/auth/localAuth", async () => { + const actual = await vi.importActual( + "@/auth/localAuth", + ); + return { + ...actual, + setLocalAuthToken: setLocalAuthTokenMock, + }; +}); + +describe("LocalAuthLogin", () => { + beforeEach(() => { + fetchMock.mockReset(); + setLocalAuthTokenMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000/"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("requires a non-empty token", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(screen.getByText("Bearer token is required.")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + }); + + it("requires token length of at least 50 characters", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(49)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect( + screen.getByText("Bearer token must be at least 50 characters."), + ).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid token values", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(50)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect(screen.getByText("Token is invalid.")).toBeInTheDocument(), + ); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:8000/api/v1/users/me", + expect.objectContaining({ + method: "GET", + headers: { Authorization: `Bearer ${"x".repeat(50)}` }, + }), + ); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + expect(onAuthenticatedMock).not.toHaveBeenCalled(); + }); + + it("saves token only after successful backend validation", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + const user = userEvent.setup(); + render(); + + const token = ` ${"g".repeat(50)} `; + await user.type(screen.getByPlaceholderText("Paste token"), token); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect(setLocalAuthTokenMock).toHaveBeenCalledWith("g".repeat(50)), + ); + expect(onAuthenticatedMock).toHaveBeenCalledTimes(1); + }); + + it("shows a clear error when backend is unreachable", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockRejectedValueOnce(new TypeError("network error")); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "t".repeat(50)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect( + screen.getByText("Unable to reach backend to validate token."), + ).toBeInTheDocument(), + ); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + expect(onAuthenticatedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/organisms/LocalAuthLogin.tsx b/frontend/src/components/organisms/LocalAuthLogin.tsx index b071d593..0ecccf4e 100644 --- a/frontend/src/components/organisms/LocalAuthLogin.tsx +++ b/frontend/src/components/organisms/LocalAuthLogin.tsx @@ -8,54 +8,132 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -export function LocalAuthLogin() { +const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50; + +async function validateLocalToken(token: string): Promise { + const rawBaseUrl = process.env.NEXT_PUBLIC_API_URL; + if (!rawBaseUrl) { + return "NEXT_PUBLIC_API_URL is not set."; + } + + const baseUrl = rawBaseUrl.replace(/\/+$/, ""); + + let response: Response; + try { + response = await fetch(`${baseUrl}/api/v1/users/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } catch { + return "Unable to reach backend to validate token."; + } + + if (response.ok) { + return null; + } + if (response.status === 401 || response.status === 403) { + return "Token is invalid."; + } + return `Unable to validate token (HTTP ${response.status}).`; +} + +type LocalAuthLoginProps = { + onAuthenticated?: () => void; +}; + +const defaultOnAuthenticated = () => window.location.reload(); + +export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) { const [token, setToken] = useState(""); const [error, setError] = useState(null); + const [isValidating, setIsValidating] = useState(false); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const cleaned = token.trim(); if (!cleaned) { setError("Bearer token is required."); return; } + if (cleaned.length < LOCAL_AUTH_TOKEN_MIN_LENGTH) { + setError( + `Bearer token must be at least ${LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.`, + ); + return; + } + + setIsValidating(true); + const validationError = await validateLocalToken(cleaned); + setIsValidating(false); + if (validationError) { + setError(validationError); + return; + } + setLocalAuthToken(cleaned); setError(null); - window.location.reload(); + (onAuthenticated ?? defaultOnAuthenticated)(); }; return ( -
- - -
- +
+
+
+
+
+ + + +
+ + Self-host mode + +
+ +
-
-

+
+

Local Authentication

-

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

+ Enter your access token to unlock Mission Control.

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

{error}

: null} -
diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index f636dfec..0052d4ba 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -2,9 +2,10 @@ import { NextResponse } from "next/server"; import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { AuthMode } from "@/auth/mode"; const isClerkEnabled = () => - process.env.NEXT_PUBLIC_AUTH_MODE !== "local" && + process.env.NEXT_PUBLIC_AUTH_MODE !== AuthMode.Local && isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); From 1a35fa1f21b02c46c49c2e4c15af0cfc5f61ad91 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 19:36:44 +0530 Subject: [PATCH 10/11] feat: clear local authentication token when switching from local auth mode --- .../src/components/providers/AuthProvider.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index aee53ac9..eccf107e 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -1,14 +1,26 @@ "use client"; import { ClerkProvider } from "@clerk/nextjs"; -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; -import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; +import { + clearLocalAuthToken, + getLocalAuthToken, + isLocalAuthMode, +} from "@/auth/localAuth"; import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; export function AuthProvider({ children }: { children: ReactNode }) { - if (isLocalAuthMode()) { + const localMode = isLocalAuthMode(); + + useEffect(() => { + if (!localMode) { + clearLocalAuthToken(); + } + }, [localMode]); + + if (localMode) { if (!getLocalAuthToken()) { return ; } From 2ecee4ed48f84897f147facdaa6d94f83e3328b0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 19:40:29 +0530 Subject: [PATCH 11/11] feat: refactor LocalAuthLogin and UserMenu components for improved readability --- frontend/src/components/organisms/LocalAuthLogin.test.tsx | 7 ++++--- frontend/src/components/organisms/LocalAuthLogin.tsx | 7 ++++++- frontend/src/components/organisms/UserMenu.tsx | 6 ++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/organisms/LocalAuthLogin.test.tsx b/frontend/src/components/organisms/LocalAuthLogin.test.tsx index 49675f8f..f02f7597 100644 --- a/frontend/src/components/organisms/LocalAuthLogin.test.tsx +++ b/frontend/src/components/organisms/LocalAuthLogin.test.tsx @@ -8,9 +8,10 @@ const setLocalAuthTokenMock = vi.hoisted(() => vi.fn()); const fetchMock = vi.hoisted(() => vi.fn()); vi.mock("@/auth/localAuth", async () => { - const actual = await vi.importActual( - "@/auth/localAuth", - ); + const actual = + await vi.importActual( + "@/auth/localAuth", + ); return { ...actual, setLocalAuthToken: setLocalAuthTokenMock, diff --git a/frontend/src/components/organisms/LocalAuthLogin.tsx b/frontend/src/components/organisms/LocalAuthLogin.tsx index 0ecccf4e..fd7ab8c3 100644 --- a/frontend/src/components/organisms/LocalAuthLogin.tsx +++ b/frontend/src/components/organisms/LocalAuthLogin.tsx @@ -132,7 +132,12 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) { Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.

)} - diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index 1214e9fd..97f9743a 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -44,8 +44,10 @@ export function UserMenu({ const avatarLabelSource = displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U"; const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase(); - const displayName = displayNameFromDb ?? (localMode ? "Local User" : "Account"); - const displayEmail = displayEmailFromDb ?? (localMode ? "local@localhost" : ""); + const displayName = + displayNameFromDb ?? (localMode ? "Local User" : "Account"); + const displayEmail = + displayEmailFromDb ?? (localMode ? "local@localhost" : ""); return (