Comment-only follow-up to #7454. That change carried 523 comment lines, many of them three and four line preambles where one line says the same thing. This collapses them and drops the ones restating what the code already says, for a net 77 lines. Scope is limited to comments #7454 itself introduced. The files it touched hold about 3,761 comments in total; the rest predate it and are untouched, verified by checking that every removed line is one that commit added. Nothing that records why a non-obvious decision was made was dropped, only compressed. Still stated: the normcase-before-versus-after Windows separator trap, the innermost-indexed-model rule for nested directories, an HTTPException being a decision rather than a failure to decide, that only an explicit False is anonymous to huggingface_hub while None borrows the server owner's login, the fail-closed tri-state custom-code gate, and the regressions each test was written for. Code is provably unchanged: comment_tools.py check reports 17/17 files comments-only. Backend CI command 10337 passed, 0 failed. tsc -b clean.
232 lines
7.3 KiB
Python
232 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, 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,
|
|
)
|
|
|
|
|
|
# The literal the examples ship with; pasted unedited more often than a revoked key.
|
|
API_KEY_PLACEHOLDER = f"{API_KEY_PREFIX}YOUR_KEY"
|
|
|
|
|
|
def _invalid_api_key_detail(token: str) -> str:
|
|
"""Why the key failed. Only the example placeholder is called out; every real
|
|
key gets one indistinguishable message, so this leaks no key existence."""
|
|
if token == API_KEY_PLACEHOLDER:
|
|
return (
|
|
"This is the placeholder key from the example. Create an API key in "
|
|
f"Unsloth Studio under Settings > API and use it in place of {API_KEY_PLACEHOLDER}."
|
|
)
|
|
return "Invalid or expired API key"
|
|
|
|
|
|
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_api_key_detail(token),
|
|
)
|
|
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",
|
|
)
|