docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
385
auth.py
Normal file
385
auth.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Authentication and authorisation for Hold Slayer.
|
||||
|
||||
This gateway is **owner-only**. It dials real phones and spends money, so
|
||||
there are no guest/shared resources: exactly one operator (the Casdoor user
|
||||
whose name matches ``OWNER_NAME``) may use any surface; every other identity
|
||||
gets 403.
|
||||
|
||||
Two bearer-token kinds are accepted on ``Authorization: Bearer <token>``
|
||||
(or, for the two browser consumers that can't set headers — the WebSocket
|
||||
connect and ``<audio>`` recording downloads — on a ``?token=`` query param):
|
||||
|
||||
1. **Casdoor JWT** — short-lived, signed by Casdoor. Validated against the
|
||||
public keys served at ``${CASDOOR_ENDPOINT}/.well-known/jwks`` (PyJWKClient
|
||||
cache, RS256). Used by the browser dashboard after OIDC login.
|
||||
2. **Personal Access Token** — long-lived ``hs_pat_<random>`` token, minted
|
||||
from the owner-only dashboard and stored hashed in
|
||||
``personal_access_tokens``. Used by MCP/CLI clients (Claude Desktop, Cline)
|
||||
that can't refresh a JWT.
|
||||
|
||||
When ``CASDOOR_ENABLED=false`` (dev, loopback only) every request resolves to
|
||||
the dev owner — no token required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from casdoor import AsyncCasdoorSDK
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from db.database import PersonalAccessToken, User, get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_DEV_OWNER_SUB = "dev-owner"
|
||||
PAT_PREFIX = "hs_pat_"
|
||||
|
||||
# ── Casdoor SDK singleton (for OAuth code exchange in /auth/callback) ─────────
|
||||
|
||||
_sdk: AsyncCasdoorSDK | None = None
|
||||
|
||||
|
||||
def get_sdk() -> AsyncCasdoorSDK:
|
||||
"""Build the Casdoor SDK lazily.
|
||||
|
||||
Used only for the OAuth2 code-exchange step in ``/auth/callback`` — JWT
|
||||
validation happens via PyJWKClient below. The certificate parameter is
|
||||
unused for code exchange but the constructor requires *something*; we pass
|
||||
an empty bytestring.
|
||||
"""
|
||||
global _sdk
|
||||
if _sdk is None:
|
||||
c = get_settings().casdoor
|
||||
_sdk = AsyncCasdoorSDK(
|
||||
endpoint=c.endpoint,
|
||||
client_id=c.client_id,
|
||||
client_secret=c.client_secret.get_secret_value(),
|
||||
certificate=b"",
|
||||
org_name=c.org_name,
|
||||
application_name=c.app_name,
|
||||
)
|
||||
return _sdk
|
||||
|
||||
|
||||
# ── JWKS client (for Casdoor JWT validation) ─────────────────────────────────
|
||||
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
|
||||
|
||||
def init_jwks_client() -> None:
|
||||
"""Construct the PyJWKClient pointed at Casdoor's JWKS endpoint.
|
||||
|
||||
Called once from the app lifespan before requests are served. Pre-fetches
|
||||
the keys so the network round-trip happens at startup rather than on the
|
||||
first authenticated request. A no-op when SSO is disabled; a failed
|
||||
prefetch is non-fatal (keys are fetched lazily on first use).
|
||||
"""
|
||||
global _jwks_client
|
||||
if not get_settings().casdoor.enabled:
|
||||
return
|
||||
endpoint = get_settings().casdoor.endpoint.rstrip("/")
|
||||
jwks_uri = f"{endpoint}/.well-known/jwks"
|
||||
_jwks_client = jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=3600)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
logger.info("Casdoor JWKS prefetched from %s", jwks_uri)
|
||||
except Exception as exc:
|
||||
logger.warning("Casdoor JWKS prefetch failed (%s); will retry on first request", exc)
|
||||
|
||||
|
||||
def _decode_casdoor_jwt(token: str) -> dict:
|
||||
"""Validate a Casdoor RS256 JWT against the cached JWKS.
|
||||
|
||||
Refreshes the key cache once on unknown-kid before giving up. Audience
|
||||
verification is disabled because Casdoor sets ``aud`` to the application
|
||||
name, which differs from the client_id; the signature check against
|
||||
Casdoor's key is the primary control.
|
||||
"""
|
||||
if _jwks_client is None:
|
||||
raise HTTPException(status_code=503, detail="Auth subsystem not ready")
|
||||
|
||||
issuer = get_settings().casdoor.endpoint.rstrip("/")
|
||||
|
||||
def _decode_with_current_keys() -> dict:
|
||||
signing_key = _jwks_client.get_signing_key_from_jwt(token)
|
||||
return jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
try:
|
||||
return _decode_with_current_keys()
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=401, detail="Token has expired") from exc
|
||||
except jwt.PyJWKClientError as exc:
|
||||
logger.warning("Unknown JWKS key (%s); refreshing", exc)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
return _decode_with_current_keys()
|
||||
except Exception as inner:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {inner}") from inner
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
|
||||
|
||||
|
||||
# ── PAT helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def hash_token(plaintext: str) -> str:
|
||||
"""SHA-256 hex digest of a plaintext PAT."""
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _validate_pat(session: AsyncSession, plaintext: str) -> User:
|
||||
"""Look up a PAT by hash, check it's active, return the owning user."""
|
||||
digest = hash_token(plaintext)
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(PersonalAccessToken.token_hash == digest)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None or pat.revoked_at is not None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if pat.expires_at is not None:
|
||||
# DateTime columns come back naive on SQLite (and on a Postgres
|
||||
# TIMESTAMP without tz); treat a naive value as UTC before comparing.
|
||||
expires_at = pat.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
|
||||
pat.last_used_at = now
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
user_result = await session.execute(select(User).where(User.id == pat.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return user
|
||||
|
||||
|
||||
# ── User provisioning ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_or_create_dev_owner(session: AsyncSession) -> User:
|
||||
"""Return the dev-mode owner user row, creating it if it doesn't exist."""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == _DEV_OWNER_SUB))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(id=uuid.uuid4().hex, name="Owner", casdoor_sub=_DEV_OWNER_SUB)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def _find_or_create_user(
|
||||
session: AsyncSession,
|
||||
casdoor_sub: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
email: str | None,
|
||||
) -> User:
|
||||
"""Look up a user by casdoor_sub; create a new row on first login.
|
||||
|
||||
Lookup priority, so identity survives a Casdoor redeploy:
|
||||
1. casdoor_sub — the OIDC subject claim (primary SSO identity).
|
||||
2. name — the Casdoor username (stable, unique). Relinks a changed sub.
|
||||
3. email — pre-SSO users logging in via Casdoor for the first time.
|
||||
|
||||
Non-owner users are still provisioned (so ``is_owner`` can say "no"), but
|
||||
they reach nothing — every surface is owner-gated.
|
||||
"""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == casdoor_sub))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
changed = False
|
||||
if user.name != name:
|
||||
user.name = name
|
||||
changed = True
|
||||
if user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
changed = True
|
||||
if changed:
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
result = await session.execute(select(User).where(User.name == name))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
logger.info(
|
||||
"Linking user %s (id=%s) to new casdoor_sub %s (was %s)",
|
||||
name, user.id, casdoor_sub, user.casdoor_sub,
|
||||
)
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
if email:
|
||||
result = await session.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.name = name
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4().hex,
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
casdoor_sub=casdoor_sub,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("Created new user: %s (id=%s)", name, user.id)
|
||||
return user
|
||||
|
||||
|
||||
def _claims_to_identity(claims: dict) -> tuple[str, str, str, str | None]:
|
||||
"""Pull (sub, name, display_name, email) out of Casdoor JWT claims."""
|
||||
sub = claims.get("sub") or claims.get("name") or ""
|
||||
name = claims.get("name") or sub
|
||||
display_name = claims.get("displayName") or claims.get("name") or sub
|
||||
email = claims.get("email") or None
|
||||
return sub, name, display_name, email
|
||||
|
||||
|
||||
# ── The single resolver — used by every surface ──────────────────────────────
|
||||
|
||||
|
||||
async def resolve_bearer(session: AsyncSession, raw_token: str | None) -> User | None:
|
||||
"""Resolve a bare bearer-token string to a User, or None on any failure.
|
||||
|
||||
This is the one place a token becomes an identity. It never raises — the
|
||||
caller decides how to respond (401/403 for REST, 4401 close for WS). The
|
||||
header-based REST path and the ``?token=`` query path both funnel here.
|
||||
|
||||
Dev mode (SSO disabled) ignores the token and returns the dev owner.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return await _get_or_create_dev_owner(session)
|
||||
|
||||
if not raw_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
if raw_token.startswith(PAT_PREFIX):
|
||||
return await _validate_pat(session, raw_token)
|
||||
claims = _decode_casdoor_jwt(raw_token)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
sub, name, display_name, email = _claims_to_identity(claims)
|
||||
if not sub:
|
||||
return None
|
||||
try:
|
||||
return await _find_or_create_user(session, sub, name, display_name, email)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _token_from_header(authorization_header: str | None) -> str | None:
|
||||
"""Extract the bearer token from an Authorization header, or None."""
|
||||
if not authorization_header:
|
||||
return None
|
||||
parts = authorization_header.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
token = parts[1].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
async def resolve_from_header_or_query(
|
||||
session: AsyncSession,
|
||||
authorization_header: str | None,
|
||||
query_token: str | None,
|
||||
) -> User | None:
|
||||
"""Resolve a User from an Authorization header, falling back to ``?token=``.
|
||||
|
||||
The query fallback exists only for the two browser consumers that can't
|
||||
set headers — the WebSocket connect and ``<audio>`` recording downloads —
|
||||
matching Hold Slayer's long-standing narrow ``?token=`` convention. The
|
||||
header wins when both are present.
|
||||
"""
|
||||
raw = _token_from_header(authorization_header) or query_token
|
||||
return await resolve_bearer(session, raw)
|
||||
|
||||
|
||||
# ── Ownership ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_owner(user: User) -> bool:
|
||||
"""Whether the user owns this gateway.
|
||||
|
||||
Dev mode: the dev-owner sub is always the owner. SSO mode: the owner is
|
||||
the user whose Casdoor username (``user.name``) matches ``OWNER_NAME``.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return user.casdoor_sub == _DEV_OWNER_SUB
|
||||
owner_name = get_settings().owner_name
|
||||
return bool(owner_name and user.name == owner_name)
|
||||
|
||||
|
||||
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||
token: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the authenticated user (owner or not) — 401 if unauthenticated.
|
||||
|
||||
Used by ``/auth/me`` so a signed-in non-owner sees ``is_owner:false``
|
||||
rather than a bare 401. Owner-gating is a separate step.
|
||||
"""
|
||||
header = f"Bearer {credentials.credentials}" if credentials else None
|
||||
user = await resolve_from_header_or_query(session, header, token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_owner(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
"""The one gate for protected surfaces — 401 if unauthenticated, 403 if not owner."""
|
||||
if not is_owner(user):
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
return user
|
||||
|
||||
|
||||
OwnerUser = Annotated[User, Depends(get_current_owner)]
|
||||
Reference in New Issue
Block a user