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:
191
api/auth.py
Normal file
191
api/auth.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
OIDC authentication endpoints (Casdoor SSO).
|
||||
|
||||
GET /auth/login → redirect to Casdoor authorization URL
|
||||
GET /auth/callback → exchange code for tokens, redirect to UI with token
|
||||
GET /auth/me → return current user info (requires Bearer token)
|
||||
GET /auth/silent-refresh → hidden-iframe refresh (re-auth with existing session)
|
||||
GET /auth/refresh-callback → post the refreshed token to the parent window
|
||||
GET /auth/logout → redirect to Casdoor logout URL
|
||||
|
||||
The dashboard is owner-only; ``/auth/me`` returns ``is_owner`` so a signed-in
|
||||
non-owner sees an "access denied" screen instead of a bare 401.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from auth import get_sdk, is_owner, resolve_from_header_or_query
|
||||
from config import get_settings
|
||||
from db.database import session_scope
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _build_casdoor_auth_url(
|
||||
callback: str,
|
||||
*,
|
||||
scope: str = "openid profile email",
|
||||
state: str | None = None,
|
||||
prompt: str | None = None,
|
||||
) -> str:
|
||||
"""Build the Casdoor authorization URL directly.
|
||||
|
||||
The SDK's get_auth_link() doesn't support the ``prompt`` parameter that
|
||||
silent refresh needs, so build the URL manually.
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
params = {
|
||||
"client_id": c.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": callback,
|
||||
"scope": scope,
|
||||
"state": state or secrets.token_urlsafe(16),
|
||||
}
|
||||
if prompt:
|
||||
params["prompt"] = prompt
|
||||
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, redirect_uri: str = Query(None)):
|
||||
"""Redirect the browser to the Casdoor authorization page.
|
||||
|
||||
No ``prompt=login`` — an existing Casdoor session auto-redirects back with
|
||||
a code without showing the login form (silent SSO across *.helu.ca).
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = redirect_uri or f"{request.base_url}auth/callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback))
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(None),
|
||||
redirect_uri: str = Query(None),
|
||||
):
|
||||
"""Exchange the authorization code for tokens.
|
||||
|
||||
Redirects to the dashboard with the access token in the URL *fragment*
|
||||
(``/#token=...``) so the token stays client-side and is stored in
|
||||
localStorage.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Token exchange failed: {exc}") from exc
|
||||
|
||||
access_token = token.get("access_token", "")
|
||||
return RedirectResponse(url=f"/#token={access_token}")
|
||||
|
||||
|
||||
@router.get("/silent-refresh")
|
||||
async def silent_refresh(request: Request):
|
||||
"""Start a silent token refresh via hidden iframe (``prompt=none``).
|
||||
|
||||
If the Casdoor session is still active, Casdoor redirects back to
|
||||
``/auth/refresh-callback`` with a fresh code — no login form. Otherwise it
|
||||
returns an error and the iframe tells the parent to show the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = f"{request.base_url}auth/refresh-callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
|
||||
|
||||
|
||||
@router.get("/refresh-callback")
|
||||
async def refresh_callback(
|
||||
code: str = Query(None),
|
||||
error: str = Query(None),
|
||||
state: str = Query(None),
|
||||
):
|
||||
"""Handle the silent-refresh callback inside the hidden iframe.
|
||||
|
||||
On success posts the new token to the parent window; on failure posts an
|
||||
error so the parent shows the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
if error or not code:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
access_token = token.get("access_token", "")
|
||||
except Exception:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
f'<script>window.parent.postMessage('
|
||||
f'{{type:"hold-slayer-refresh",token:"{access_token}"}},"*");</script>'
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request):
|
||||
"""Return the current authenticated user's profile + ``is_owner``.
|
||||
|
||||
Resolved manually (not via the ``OwnerUser`` gate) so a signed-in
|
||||
non-owner gets a 200 with ``is_owner:false`` — the dashboard uses that to
|
||||
show the "not authorized" screen rather than treating it as a hard 401.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization")
|
||||
q_token = request.query_params.get("token")
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
"is_owner": is_owner(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Clear the Casdoor session and redirect back to the app.
|
||||
|
||||
``post_logout_redirect_uri`` must be absolute — Casdoor won't follow a
|
||||
relative ``/`` — so it's derived from ``request.base_url`` (works behind
|
||||
HAProxy/nginx with X-Forwarded-Proto/Host).
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
if not c.enabled:
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
app_url = str(request.base_url).rstrip("/")
|
||||
logout_url = (
|
||||
f"{c.endpoint.rstrip('/')}/login/oauth/logout"
|
||||
f"?client_id={c.client_id}"
|
||||
f"&post_logout_redirect_uri={app_url}/auth/login"
|
||||
)
|
||||
return RedirectResponse(url=logout_url)
|
||||
36
api/deps.py
36
api/deps.py
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
API Dependencies — Shared dependency injection for all routes.
|
||||
|
||||
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
|
||||
`OwnerUser`), applied as a router-level dependency in main.py.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from fastapi import Header, HTTPException, Query, Request
|
||||
|
||||
from config import get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
|
||||
|
||||
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
|
||||
if routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return routing
|
||||
|
||||
|
||||
def require_token(
|
||||
authorization: str | None = Header(default=None),
|
||||
token: str | None = Query(default=None),
|
||||
) -> None:
|
||||
"""
|
||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
||||
|
||||
A `token` query parameter is accepted alongside the Authorization
|
||||
header for clients that can't set headers — <audio>/<a> elements
|
||||
fetching recordings — matching the WebSocket convention.
|
||||
|
||||
An empty configured token disables auth; startup refuses that
|
||||
combination unless the server is bound to loopback.
|
||||
"""
|
||||
expected = get_settings().api_token.get_secret_value()
|
||||
if not expected:
|
||||
return
|
||||
supplied = token or ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
supplied = authorization[7:]
|
||||
if not secrets.compare_digest(supplied, expected):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
107
api/tokens.py
Normal file
107
api/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Owner-only CRUD for personal access tokens (PATs).
|
||||
|
||||
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
|
||||
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
|
||||
plaintext is shown to the caller exactly once at creation; only its SHA-256
|
||||
hash is stored.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import PAT_PREFIX, OwnerUser, hash_token
|
||||
from db.database import PersonalAccessToken, get_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
token_prefix: str
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
revoked_at: str | None = None
|
||||
|
||||
|
||||
class TokenCreated(TokenOut):
|
||||
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
|
||||
|
||||
|
||||
def _serialize(pat: PersonalAccessToken) -> dict:
|
||||
return {
|
||||
"id": pat.id,
|
||||
"name": pat.name,
|
||||
"token_prefix": pat.token_prefix,
|
||||
"created_at": pat.created_at.isoformat() if pat.created_at else None,
|
||||
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
|
||||
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
|
||||
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenOut])
|
||||
async def list_tokens(
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""List the owner's personal access tokens (no plaintext)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken)
|
||||
.where(PersonalAccessToken.user_id == user.id)
|
||||
.order_by(PersonalAccessToken.created_at.desc())
|
||||
)
|
||||
return [_serialize(pat) for pat in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreated, status_code=201)
|
||||
async def create_token(
|
||||
payload: TokenCreate,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
|
||||
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user.id,
|
||||
name=payload.name,
|
||||
token_hash=hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
await session.refresh(pat)
|
||||
return {**_serialize(pat), "token": plaintext}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-revoke a PAT (sets revoked_at)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(
|
||||
PersonalAccessToken.id == token_id,
|
||||
PersonalAccessToken.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
if pat.revoked_at is None:
|
||||
pat.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
@@ -1,13 +1,11 @@
|
||||
"""WebSocket API — Real-time call events and audio classification stream."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.deps import get_gateway
|
||||
from config import get_settings
|
||||
from auth import is_owner, resolve_from_header_or_query
|
||||
from db.database import session_scope
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,21 +15,21 @@ router = APIRouter()
|
||||
|
||||
async def _authorize(websocket: WebSocket) -> bool:
|
||||
"""
|
||||
Check the static bearer token before accepting the socket.
|
||||
Require the owner before accepting the socket.
|
||||
|
||||
Browsers can't set headers on WebSocket connects, so a `token`
|
||||
query parameter is accepted alongside the Authorization header.
|
||||
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
|
||||
a PAT) is accepted on the `?token=` query param alongside the Authorization
|
||||
header — the same narrow fallback the recording download uses. In dev mode
|
||||
the owner resolves tokenlessly. A non-owner or absent credential closes the
|
||||
socket with code 4401.
|
||||
"""
|
||||
token = get_settings().api_token.get_secret_value()
|
||||
if not token:
|
||||
q_token = websocket.query_params.get("token")
|
||||
auth_header = websocket.headers.get("authorization")
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is not None and is_owner(user):
|
||||
return True
|
||||
supplied = websocket.query_params.get("token", "")
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
supplied = auth[7:]
|
||||
if secrets.compare_digest(supplied, token):
|
||||
return True
|
||||
await websocket.close(code=4401, reason="Missing or invalid bearer token")
|
||||
await websocket.close(code=4401, reason="Owner authentication required")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user