unsloth/studio/backend/auth/authentication.py
Daniel Han 06829c2627
Studio: tighten the comments added by the OpenAI model-admission work (#7501)
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.
2026-07-27 05:59:03 -07:00

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",
)