e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
218 lines
6.7 KiB
Python
218 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 Studio 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",
|
|
)
|