feat: implement local authentication mode and update related components

This commit is contained in:
Abhimanyu Saharan
2026-02-11 19:10:23 +05:30
parent 0ff645f795
commit 06ff1a9720
23 changed files with 563 additions and 93 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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":

View File

@@ -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")

View File

@@ -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

View File

@@ -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()