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