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.
386 lines
14 KiB
Python
386 lines
14 KiB
Python
"""
|
|
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)]
|