feat: implement local authentication mode and update related components
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
99
backend/tests/test_local_auth_integration.py
Normal file
99
backend/tests/test_local_auth_integration.py
Normal 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()
|
||||
Reference in New Issue
Block a user