Files
hold-slayer/auth.py
Robert Helewka 4a3c14d4af
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
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.
2026-07-28 19:01:38 -04:00

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)]