* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error.
217 lines
6.7 KiB
Python
217 lines
6.7 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, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
import jwt
|
|
|
|
from .storage import (
|
|
API_KEY_PREFIX,
|
|
get_jwt_secret,
|
|
get_user_and_secret,
|
|
load_jwt_secret,
|
|
save_refresh_token,
|
|
validate_api_key,
|
|
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,
|
|
*,
|
|
desktop: bool = False,
|
|
) -> str:
|
|
"""
|
|
Create a signed JWT for the given subject (e.g. username).
|
|
|
|
Valid across restarts: the signing secret is stored in SQLite.
|
|
"""
|
|
to_encode = {"sub": subject}
|
|
if desktop:
|
|
to_encode["desktop"] = True
|
|
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 is_desktop_access_token(token: str) -> bool:
|
|
"""Return true only for a valid desktop-issued JWT access token."""
|
|
if token.startswith(API_KEY_PREFIX):
|
|
return False
|
|
|
|
subject = _decode_subject_without_verification(token)
|
|
if subject is None:
|
|
return False
|
|
|
|
record = get_user_and_secret(subject)
|
|
if record is None:
|
|
return False
|
|
|
|
_salt, _pwd_hash, jwt_secret, _must_change_password = record
|
|
try:
|
|
payload = jwt.decode(token, jwt_secret, algorithms = [ALGORITHM])
|
|
except jwt.InvalidTokenError:
|
|
return False
|
|
|
|
return payload.get("sub") == subject and payload.get("desktop") is True
|
|
|
|
|
|
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
|
|
"""
|
|
Create a random refresh token, store its hash in SQLite, and return it.
|
|
|
|
Refresh tokens are opaque (not JWTs); 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(), is_desktop = desktop)
|
|
return token
|
|
|
|
|
|
def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[str], bool]:
|
|
"""
|
|
Validate a refresh token and issue a new access token.
|
|
|
|
The refresh token is NOT consumed; it stays valid until expiry.
|
|
Returns a new access_token, or None if the refresh token is invalid/expired.
|
|
"""
|
|
verified = verify_refresh_token(refresh_token)
|
|
if verified is None:
|
|
return None, None, False
|
|
username, is_desktop = verified
|
|
return (
|
|
create_access_token(subject = username, desktop = is_desktop),
|
|
username,
|
|
is_desktop,
|
|
)
|
|
|
|
|
|
def reload_secret() -> None:
|
|
"""
|
|
Legacy API compat 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 authenticated_via_api_key(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
) -> bool:
|
|
"""True when the caller used an sk-unsloth API key, not a UI session JWT.
|
|
|
|
Lets routes treat programmatic API callers differently from the Unsloth UI
|
|
(e.g. refuse a teardown the UI would allow).
|
|
"""
|
|
return bool(credentials and credentials.credentials.startswith(API_KEY_PREFIX))
|
|
|
|
|
|
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: validate the JWT and return the subject. Use on protected routes."""
|
|
token = credentials.credentials
|
|
|
|
# --- API key path (sk-unsloth-...) ---
|
|
if token.startswith(API_KEY_PREFIX):
|
|
username = validate_api_key(token)
|
|
if username is None:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = "Invalid or expired API key",
|
|
)
|
|
return username
|
|
|
|
# --- JWT path ---
|
|
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",
|
|
)
|
|
is_desktop = payload.get("desktop") is True
|
|
if must_change_password and not allow_password_change and not is_desktop:
|
|
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",
|
|
)
|