Two gaps found while wiring auth into the data routes: 1. mcp_server/auth.py: HTTPBearer() defaults to auto_error=True, which rejects any request with no Authorization header before get_current_user's own "auth not configured -> allow as guest" check ever runs. That meant turning on Depends(get_current_user) anywhere 401'd every request even with auth fully disabled. Switched to auto_error=False and handle the missing-credentials case explicitly inside get_current_user instead. 2. Frontend never actually sent the stored token on data requests. auth.tsx only attached it to its own /api/auth/me check; api.ts's fetch() calls (chat, stats, documents, upload, delete) and every axios call elsewhere in the app (projects, kanban, hooks) had no Authorization header at all. Added an authHeaders() helper in api.ts, and a single axios request interceptor in auth.tsx that attaches the token app-wide since every component imports the same default axios instance. Known gap not addressed here: mcp_server/claude_code_mcp.py (the code-forge stdio MCP server) calls the REST API with no token at all. Harmless while auth is disabled (current state), but it will start getting 401'd the moment CLAUDE_OS_EMAIL/PASSWORD are actually set - needs a service-token bypass if that's ever turned on.
150 lines
4.5 KiB
Python
150 lines
4.5 KiB
Python
"""
|
|
Simple authentication for Claude OS.
|
|
Uses environment variables for credentials - no database needed.
|
|
"""
|
|
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
from fastapi import HTTPException, Security, Depends
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
import jwt
|
|
from passlib.context import CryptContext
|
|
|
|
# Password hashing
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
# JWT settings
|
|
SECRET_KEY = os.getenv("CLAUDE_OS_SECRET_KEY", secrets.token_urlsafe(32))
|
|
ALGORITHM = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
|
|
|
# Security
|
|
# auto_error=False: let get_current_user itself decide whether a missing
|
|
# token is OK (auth not configured -> guest) or a 401 (auth configured).
|
|
# The HTTPBearer default (auto_error=True) rejects any request with no
|
|
# Authorization header before get_current_user's body ever runs, which
|
|
# breaks the "auth disabled by default" behavior entirely.
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
"""Verify a password against its hash."""
|
|
return pwd_context.verify(plain_password, hashed_password)
|
|
|
|
|
|
def get_password_hash(password: str) -> str:
|
|
"""Hash a password."""
|
|
return pwd_context.hash(password)
|
|
|
|
|
|
def get_user_credentials() -> dict:
|
|
"""
|
|
Get user credentials from environment variables.
|
|
|
|
Set in .env or environment:
|
|
CLAUDE_OS_EMAIL=admin@example.com
|
|
CLAUDE_OS_PASSWORD_HASH=<bcrypt hash>
|
|
|
|
To generate a password hash:
|
|
python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('your_password'))"
|
|
"""
|
|
email = os.getenv("CLAUDE_OS_EMAIL")
|
|
password_hash = os.getenv("CLAUDE_OS_PASSWORD_HASH")
|
|
|
|
# For development, allow plain password (will be hashed on the fly)
|
|
password_plain = os.getenv("CLAUDE_OS_PASSWORD")
|
|
|
|
if not email:
|
|
# Default to disabled auth if not configured
|
|
return None
|
|
|
|
if password_hash:
|
|
return {"email": email, "password_hash": password_hash}
|
|
elif password_plain:
|
|
# Hash the plain password (for development only)
|
|
return {"email": email, "password_hash": get_password_hash(password_plain)}
|
|
else:
|
|
return None
|
|
|
|
|
|
def authenticate_user(email: str, password: str) -> bool:
|
|
"""Authenticate a user by email and password."""
|
|
creds = get_user_credentials()
|
|
|
|
if not creds:
|
|
# Auth disabled
|
|
return False
|
|
|
|
if email != creds["email"]:
|
|
return False
|
|
|
|
return verify_password(password, creds["password_hash"])
|
|
|
|
|
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
"""Create a JWT access token."""
|
|
to_encode = data.copy()
|
|
|
|
if expires_delta:
|
|
expire = datetime.utcnow() + expires_delta
|
|
else:
|
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
|
|
to_encode.update({"exp": expire})
|
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
return encoded_jwt
|
|
|
|
|
|
def decode_access_token(token: str) -> Optional[dict]:
|
|
"""Decode and validate a JWT access token."""
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
return payload
|
|
except jwt.PyJWTError:
|
|
return None
|
|
|
|
|
|
def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(security)) -> dict:
|
|
"""
|
|
Dependency to get the current authenticated user.
|
|
Use in FastAPI routes: current_user = Depends(get_current_user)
|
|
"""
|
|
creds = get_user_credentials()
|
|
|
|
# If auth not configured, allow all requests
|
|
if not creds:
|
|
return {"email": "guest", "auth_disabled": True}
|
|
|
|
if credentials is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Not authenticated",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
token = credentials.credentials
|
|
payload = decode_access_token(token)
|
|
|
|
if payload is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid authentication credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
email = payload.get("sub")
|
|
if email is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid authentication credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
return {"email": email}
|
|
|
|
|
|
def is_auth_enabled() -> bool:
|
|
"""Check if authentication is enabled."""
|
|
return get_user_credentials() is not None
|