feat: implement local authentication mode and update related components
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user