unsloth/studio/backend/auth/authentication.py
Roland Tannous 46fcf98e74 feat: add Anthropic-compatible /v1/messages endpoint
Proxy requests to llama-server's native Anthropic Messages API handler.
Supports both streaming (SSE with message_start, content_block_delta,
etc.) and non-streaming responses. Auth via x-api-key header (Anthropic
SDK) or Authorization Bearer (OpenAI SDK). Adds Messages tab with
Anthropic SDK and cURL snippets in the Access Endpoint dialog.
2026-04-10 10:09:19 +00:00

230 lines
7.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
from .storage import (
get_jwt_secret,
get_user_and_secret,
load_jwt_secret,
save_refresh_token,
verify_refresh_token,
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
REFRESH_TOKEN_EXPIRE_DAYS = 7
security = HTTPBearer() # Reads Authorization: Bearer <token>
def _get_secret_for_subject(subject: str) -> str:
secret = get_jwt_secret(subject)
if secret is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
return secret
def _decode_subject_without_verification(token: str) -> Optional[str]:
try:
payload = jwt.decode(
token,
options = {"verify_signature": False, "verify_exp": False},
)
except jwt.InvalidTokenError:
return None
subject = payload.get("sub")
return subject if isinstance(subject, str) else None
def create_access_token(
subject: str,
expires_delta: Optional[timedelta] = None,
) -> str:
"""
Create a signed JWT for the given subject (e.g. username).
Tokens are valid across restarts because the signing secret is stored in SQLite.
"""
to_encode = {"sub": subject}
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
_get_secret_for_subject(subject),
algorithm = ALGORITHM,
)
def create_refresh_token(subject: str) -> str:
"""
Create a random refresh token, store its hash in SQLite, and return it.
Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS.
"""
token = secrets.token_urlsafe(48)
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
save_refresh_token(token, subject, expires_at.isoformat())
return token
def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[str]]:
"""
Validate a refresh token and issue a new access token.
The refresh token itself is NOT consumed — it stays valid until expiry.
Returns a new access_token or None if the refresh token is invalid/expired.
"""
username = verify_refresh_token(refresh_token)
if username is None:
return None, None
return create_access_token(subject = username), username
def reload_secret() -> None:
"""
Keep legacy API compatibility for callers expecting auth storage init.
Auth now resolves the current signing secret directly from SQLite.
"""
load_jwt_secret()
async def get_current_subject(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""Validate JWT and require the password-change flow to be completed."""
return await _get_current_subject(
credentials,
allow_password_change = False,
)
async def get_current_subject_allow_password_change(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""Validate JWT but allow access to the password-change endpoint."""
return await _get_current_subject(
credentials,
allow_password_change = True,
)
async def _get_current_subject(
credentials: HTTPAuthorizationCredentials,
*,
allow_password_change: bool,
) -> str:
"""
FastAPI dependency to validate the JWT and return the subject.
Use this as a dependency on routes that should be protected, e.g.:
@router.get("/secure")
async def secure_endpoint(current_subject: str = Depends(get_current_subject)):
...
"""
token = credentials.credentials
subject = _decode_subject_without_verification(token)
if subject is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid token payload",
)
record = get_user_and_secret(subject)
if record is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
_salt, _pwd_hash, jwt_secret, must_change_password = record
try:
payload = jwt.decode(token, jwt_secret, algorithms = [ALGORITHM])
if payload.get("sub") != subject:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid token payload",
)
if must_change_password and not allow_password_change:
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Password change required",
)
return subject
except jwt.InvalidTokenError:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
# ── Dual-auth for OpenAI-compatible endpoints ───────────────────
_optional_security = HTTPBearer(auto_error = False)
async def get_current_subject_or_api_key(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_security),
) -> str:
"""Accept a valid JWT or the auto-generated API key for OpenAI-compatible endpoints."""
if credentials is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing Authorization header",
)
token = credentials.credentials
# Check auto-generated API key (fast path for external consumers)
external_key = getattr(request.app.state, "external_api_key", None)
if external_key and token == external_key:
return "__api_user__"
# Fall back to JWT validation (Studio frontend sessions)
return await _get_current_subject(credentials, allow_password_change = False)
async def get_current_subject_or_api_key_anthropic(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_security),
) -> str:
"""Accept x-api-key header, Authorization: Bearer, or JWT.
The Anthropic SDK sends ``x-api-key: <key>`` instead of
``Authorization: Bearer <key>``. This dependency checks both so that
the ``/v1/messages`` endpoint works with both Anthropic and OpenAI SDKs.
"""
external_key = getattr(request.app.state, "external_api_key", None)
# Check x-api-key header first (Anthropic SDK default)
x_api_key = request.headers.get("x-api-key")
if x_api_key and external_key and x_api_key == external_key:
return "__api_user__"
# Fall through to standard Bearer / JWT check
if credentials is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing Authorization or x-api-key header",
)
token = credentials.credentials
if external_key and token == external_key:
return "__api_user__"
return await _get_current_subject(credentials, allow_password_change = False)