* Studio: add --secure Cloudflare-only mode and revamp API usage examples --secure / --not-secure on `unsloth studio` and `unsloth studio run`: - --secure binds 127.0.0.1, requires the Cloudflare tunnel, and advertises only the Cloudflare link. cloudflared reaches the server over localhost, so the raw port is never exposed on a public interface. - If the tunnel cannot start, fail closed with a clear message instead of silently leaving a raw 0.0.0.0 link. - Default stays not-secure (no behavior change); coexists with the existing --cloudflare/--no-cloudflare flag. Host defaults are unchanged. - /api/health (authed) now reports the live tunnel URL. API usage examples (Profile > API): - Example tabs for curl, Python, curl + tools, Python + tools, plus an OS row (Linux/macOS/WSL vs Windows) auto-detected from the platform. - Windows curl passes the JSON body via a file so PowerShell does not strip the quotes when calling curl.exe. - Python + tools forwards enable_tools/enabled_tools through extra_body and guards chunk.choices, since tool-lifecycle events carry no choices. - Shows the loaded model name and the real API key while it is still revealed. - A Cloudflare Tunnel toggle (default on) shows the public tunnel URL and uses it as the base_url in the examples when a tunnel is running. Tests cover the tunnel start gate and the --secure flag on both commands. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate --secure tools on public exposure and harden API examples In secure mode the server binds loopback but is reachable via the public Cloudflare tunnel, so resolve the tool policy against the public exposure (0.0.0.0) rather than the loopback bind. This keeps server-side tools off by default and prompts before enabling them, instead of inheriting the loopback default of on. The startup tool notice now names the public surface. Also reject --secure with --no-cloudflare directly in run_server and the run.py argparse (not only the CLI), JSON-encode interpolated model names so Windows paths and quotes cannot produce invalid JSON or broken snippets, and force-refresh /api/health on the API panel so a tunnel that starts after the first health read still surfaces its URL. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: API examples show direct host when tunnel toggle is off; move Copy onto code The Cloudflare Tunnel toggle had no visible effect when Studio was opened through the tunnel: the off state fell back to window.location.origin, which equals the tunnel URL in that case. /api/health now reports the direct host:port (server_url), and the API panel uses it for the off state so it shows the real non-tunnel base. Also move the Copy button out of the tab row and onto the code block. * Studio: highlight API examples, add advanced tabs, fix tunnel toggle row Syntax-highlight the curl/PowerShell/Python snippets with the app's shared shiki plugin (bash/powershell/python). Add 'curl + advanced' and 'Python + advanced' tabs that set temperature/top_p/top_k/min_p/ repetition_penalty/max_tokens, enable thinking, and turn on all tools. The Cloudflare Tunnel row no longer shifts the code block: the tunnel URL is always rendered (dimmed when off) so toggling keeps the row height constant. Key the highlighted block on its content so it remounts when only the base URL changes (the renderer's block memo otherwise kept a stale URL). * Studio: rename API tunnel toggle to Secure HTTPS, hint --secure when exposed Rename the API examples toggle from Cloudflare Tunnel to Secure HTTPS. When the server was not launched with --secure, show an info tooltip noting the raw 0.0.0.0 port is still globally reachable and pointing at --secure. /api/health now reports whether --secure was used so the hint is hidden in secure mode. * Studio: force tools off for plain network/secure launches The plain 'unsloth studio --secure' (and '-H 0.0.0.0') launcher re-execs run.py and never installed a tool policy, so the process default (honor per-request enable_tools) let any API-key holder run Python/terminal tools over the public endpoint. Force the policy off at the run.py entrypoint when network-reachable (0.0.0.0 or --secure); 'unsloth studio run' still installs its own resolved policy and does not go through this path. * Studio: apply default tool policy in run_server, not the run.py entrypoint The plain launcher runs from the studio venv and calls run_server directly, so it never hit the run.py __main__ guard. Move the network/secure default-off tool policy into run_server so every launch path (plain, --secure, direct run.py) gets it; the run subcommand still overrides it with its resolved policy. * Studio: clarify --secure help text on the network exposure tradeoff Spell out in --help (both unsloth studio and unsloth studio run, plus the run.py argparse) that --not-secure also serves the raw 0.0.0.0 port reachable from anywhere on the network, matching the API panel's Secure HTTPS hint. * Studio: cache API-key PBKDF2 derivation to cut per-request /v1 auth overhead validate_api_key re-ran the 100k-round PBKDF2 on every authenticated request, adding ~15ms to each /v1 call made with an sk-unsloth- key. Benchmarked against the bare llama-server it proxies to, API-key requests carried ~22ms of fixed overhead vs ~7ms for the JWT path; the gap was entirely this redundant key derivation (Pydantic validation measured 0.005ms, so it is not a factor). The raw-key to hash mapping is a pure deterministic function of the fixed server salt, so memoize it per process, keyed by a salted HMAC of the key (never the key or a recoverable digest). The cached value equals what is already stored at rest. Revocation and expiry remain enforced by the SQLite read on every call, so a cache hit only skips the KDF, never the active or expiry checks. Only keys that exist in the DB are cached, so unknown-key spam cannot grow it. After the change the API-key /v1 overhead drops to ~8ms, at parity with JWT, while the at-rest PBKDF2 hashing is unchanged. Adds test_api_key_expiry.py covering API-key and JWT expiry enforcement and the new cache: it skips the KDF on repeat and still rejects revoked or expired keys. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten comments across the secure-tunnel and API-key changes Condense multi-line comments and docstrings to one or two lines, drop the ones that restate obvious code, and remove an orphaned test section header. Comment-only: verified with comment_tools.py check (9/9 code unchanged), the auth/secure-tunnel/CLI test suites, and a clean frontend typecheck and build. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
198 lines
6.3 KiB
Python
198 lines
6.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
|
|
|
|
"""Expiry enforcement for API keys (tz-aware ``expires_at``) and JWT access
|
|
tokens (``exp`` claim). Both must surface as 401 on protected routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
|
|
from auth import storage
|
|
from auth.authentication import create_access_token, get_current_subject
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def isolated_auth_db(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
|
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
|
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
|
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
|
storage._reset_api_key_hash_cache()
|
|
yield
|
|
storage._reset_api_key_hash_cache()
|
|
|
|
|
|
def seed_user():
|
|
storage.create_initial_user(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
password = "human-password-123",
|
|
jwt_secret = secrets.token_urlsafe(64),
|
|
)
|
|
|
|
|
|
def iso_from_now(**delta):
|
|
return (datetime.now(timezone.utc) + timedelta(**delta)).isoformat()
|
|
|
|
|
|
def make_key(expires_at):
|
|
raw, _row = storage.create_api_key(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
name = "test",
|
|
expires_at = expires_at,
|
|
)
|
|
return raw
|
|
|
|
|
|
def subject_of(token):
|
|
"""Run the real FastAPI auth dependency against a bearer token."""
|
|
credentials = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = token)
|
|
return asyncio.run(get_current_subject(credentials))
|
|
|
|
|
|
# --- validate_api_key (storage layer) ---------------------------------------
|
|
|
|
|
|
def test_unexpired_key_validates():
|
|
seed_user()
|
|
assert (
|
|
storage.validate_api_key(make_key(iso_from_now(days = 1))) == storage.DEFAULT_ADMIN_USERNAME
|
|
)
|
|
|
|
|
|
def test_never_expiring_key_validates():
|
|
seed_user()
|
|
assert storage.validate_api_key(make_key(None)) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
|
|
def test_expired_key_rejected():
|
|
seed_user()
|
|
assert storage.validate_api_key(make_key(iso_from_now(seconds = -1))) is None
|
|
|
|
|
|
def test_key_expiring_far_in_past_rejected():
|
|
seed_user()
|
|
assert storage.validate_api_key(make_key(iso_from_now(days = -30))) is None
|
|
|
|
|
|
def test_revoked_key_rejected():
|
|
seed_user()
|
|
raw, row = storage.create_api_key(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
name = "doomed",
|
|
expires_at = iso_from_now(days = 1),
|
|
)
|
|
storage.revoke_api_key(storage.DEFAULT_ADMIN_USERNAME, int(row["id"]))
|
|
assert storage.validate_api_key(raw) is None
|
|
|
|
|
|
def test_unknown_key_rejected():
|
|
seed_user()
|
|
assert storage.validate_api_key(storage.API_KEY_PREFIX + secrets.token_hex(16)) is None
|
|
|
|
|
|
# --- get_current_subject (route dependency) ---------------------------------
|
|
|
|
|
|
def test_dependency_accepts_unexpired_key():
|
|
seed_user()
|
|
assert subject_of(make_key(iso_from_now(days = 1))) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
|
|
def test_dependency_rejects_expired_key_as_401():
|
|
seed_user()
|
|
with pytest.raises(HTTPException) as exc:
|
|
subject_of(make_key(iso_from_now(seconds = -1)))
|
|
assert exc.value.status_code == 401
|
|
assert exc.value.detail == "Invalid or expired API key"
|
|
|
|
|
|
# --- JWT access-token expiry ------------------------------------------------
|
|
|
|
|
|
def test_dependency_accepts_unexpired_jwt():
|
|
seed_user()
|
|
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME, timedelta(minutes = 5))
|
|
assert subject_of(token) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
|
|
def test_dependency_rejects_expired_jwt_as_401():
|
|
seed_user()
|
|
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME, timedelta(seconds = -1))
|
|
with pytest.raises(HTTPException) as exc:
|
|
subject_of(token)
|
|
assert exc.value.status_code == 401
|
|
assert exc.value.detail == "Invalid or expired token"
|
|
|
|
|
|
# --- derivation cache: speeds repeats without bypassing checks --------------
|
|
|
|
|
|
def test_cache_skips_pbkdf2_on_repeat(monkeypatch):
|
|
seed_user()
|
|
raw = make_key(iso_from_now(days = 1))
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME # warms cache
|
|
|
|
calls = {"n": 0}
|
|
real = storage._pbkdf2_api_key
|
|
|
|
def counting(key):
|
|
calls["n"] += 1
|
|
return real(key)
|
|
|
|
monkeypatch.setattr(storage, "_pbkdf2_api_key", counting)
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
assert calls["n"] == 0 # served from cache, KDF not re-run
|
|
|
|
|
|
def test_cache_does_not_bypass_revocation():
|
|
seed_user()
|
|
raw, row = storage.create_api_key(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
name = "revoke-after-cache",
|
|
expires_at = iso_from_now(days = 1),
|
|
)
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME # cached
|
|
storage.revoke_api_key(storage.DEFAULT_ADMIN_USERNAME, int(row["id"]))
|
|
assert storage.validate_api_key(raw) is None # cache hit still re-checks is_active
|
|
|
|
|
|
def test_cache_does_not_bypass_expiry():
|
|
seed_user()
|
|
# Expires between the two calls: the first warms the cache, the second is still rejected.
|
|
near = (datetime.now(timezone.utc) + timedelta(milliseconds = 600)).isoformat()
|
|
raw = make_key(near)
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
import time
|
|
|
|
time.sleep(0.8)
|
|
assert storage.validate_api_key(raw) is None
|
|
|
|
|
|
def test_unknown_key_not_cached():
|
|
seed_user()
|
|
bogus = storage.API_KEY_PREFIX + secrets.token_hex(16)
|
|
assert storage.validate_api_key(bogus) is None
|
|
cache_id = storage._api_key_cache_id(bogus)
|
|
assert cache_id not in storage._api_key_hash_cache # spam can't grow the cache
|
|
|
|
|
|
def test_create_api_key_route_stores_tz_aware_expiry():
|
|
from datetime import datetime as _dt
|
|
|
|
seed_user()
|
|
raw, row = storage.create_api_key(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
name = "route",
|
|
expires_at = iso_from_now(days = 30),
|
|
)
|
|
parsed = _dt.fromisoformat(row["expires_at"])
|
|
assert parsed.tzinfo is not None # tz-aware: comparison in validate_api_key won't raise
|
|
assert storage.validate_api_key(raw) == storage.DEFAULT_ADMIN_USERNAME
|