claude-os/mcp_server/auth.py
Rosie Val e374440c67 Add a service-token bypass for code-forge, gated on auth being enabled
code-forge (the stdio MCP server) calls the REST API directly and has
no login flow of its own. With auth disabled it already passes
through as guest, same as before - but the moment CLAUDE_OS_EMAIL/
PASSWORD get set, it would start getting 401'd with no way to
authenticate.

get_current_user now accepts a CLAUDE_OS_SERVICE_TOKEN as a bearer
token, checked with a constant-time compare, alongside normal user
JWTs. claude_code_mcp.py sends it (via a shared _auth_headers() used
by all 5 of its httpx.AsyncClient() call sites) whenever
CLAUDE_OS_SERVICE_TOKEN is set, and sends nothing when it isn't - a
no-op change for anyone not using auth.

Since this process can be launched directly (e.g. over SSH as a stdio
server) without claude-os's own .env sourced into its shell or its cwd
set to the project root, it now loads .env explicitly by path relative
to the script's own location rather than relying on load_dotenv()'s
cwd-relative default search.
2026-08-22 16:42:03 -04:00

159 lines
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
# Service token: lets trusted local integrations (e.g. the code-forge
# stdio MCP server) call the API without a user login. Only relevant
# once auth is actually enabled - with no CLAUDE_OS_EMAIL set, everyone
# already gets the guest bypass above regardless of this.
service_token = os.getenv("CLAUDE_OS_SERVICE_TOKEN")
if service_token and secrets.compare_digest(token, service_token):
return {"email": "service", "service_account": True}
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