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
|
||||
|
||||
Reference in New Issue
Block a user