diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index b39f915764..409dee29a9 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -1,6 +1,7 @@ # 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 os import secrets from datetime import datetime, timedelta, timezone from typing import Optional, Tuple @@ -21,7 +22,28 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 REFRESH_TOKEN_EXPIRE_DAYS = 7 -security = HTTPBearer() # Reads Authorization: Bearer +security = HTTPBearer(auto_error = False) # Reads Authorization: Bearer + + +def is_auth_disabled() -> bool: + """ + Return True when auth should be bypassed. + + For the HF Spaces branch, auth is disabled by default so users can access + Studio without login/signup. You can explicitly re-enable auth with: + UNSLOTH_STUDIO_ENABLE_AUTH=1 + """ + raw_enable = os.getenv("UNSLOTH_STUDIO_ENABLE_AUTH", "").strip().lower() + if raw_enable in {"1", "true", "yes", "on"}: + return False + + raw = os.getenv("UNSLOTH_STUDIO_DISABLE_AUTH", "").strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + + return True def _get_secret_for_subject(subject: str) -> str: @@ -103,7 +125,7 @@ def reload_secret() -> None: async def get_current_subject( - credentials: HTTPAuthorizationCredentials = Depends(security), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> str: """Validate JWT and require the password-change flow to be completed.""" return await _get_current_subject( @@ -113,7 +135,7 @@ async def get_current_subject( async def get_current_subject_allow_password_change( - credentials: HTTPAuthorizationCredentials = Depends(security), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> str: """Validate JWT but allow access to the password-change endpoint.""" return await _get_current_subject( @@ -123,7 +145,7 @@ async def get_current_subject_allow_password_change( async def _get_current_subject( - credentials: HTTPAuthorizationCredentials, + credentials: Optional[HTTPAuthorizationCredentials], *, allow_password_change: bool, ) -> str: @@ -136,6 +158,15 @@ async def _get_current_subject( async def secure_endpoint(current_subject: str = Depends(get_current_subject)): ... """ + if is_auth_disabled(): + return "hf-space-user" + + if credentials is None: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Missing bearer token", + ) + token = credentials.credentials subject = _decode_subject_without_verification(token) if subject is None: diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index 73d21130ae..edba6ac250 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -34,6 +34,10 @@ class AuthStatusResponse(BaseModel): ..., description = "True if the seeded admin must still change the default password", ) + auth_disabled: bool = Field( + default = False, + description = "True when auth is bypassed (HF Spaces mode).", + ) class ChangePasswordRequest(BaseModel): diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index db37ed837d..92b6df08c6 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -20,6 +20,7 @@ from auth.authentication import ( create_refresh_token, get_current_subject, get_current_subject_allow_password_change, + is_auth_disabled, refresh_access_token, ) @@ -34,6 +35,14 @@ async def auth_status() -> AuthStatusResponse: - initialized = False -> frontend should wait for the seeded admin bootstrap. - initialized = True -> frontend should show login or force the first password change. """ + if is_auth_disabled(): + return AuthStatusResponse( + initialized = True, + default_username = storage.DEFAULT_ADMIN_USERNAME, + requires_password_change = False, + auth_disabled = True, + ) + return AuthStatusResponse( initialized = storage.is_initialized(), default_username = storage.DEFAULT_ADMIN_USERNAME, @@ -42,6 +51,7 @@ async def auth_status() -> AuthStatusResponse: ) if storage.is_initialized() else True, + auth_disabled = False, ) diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 1dcdfcb143..27453e2bfe 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -10,6 +10,22 @@ import { refreshSession, } from "@/features/auth"; +type AuthStatus = { + initialized: boolean; + requires_password_change: boolean; + auth_disabled?: boolean; +}; + +async function getAuthStatus(): Promise { + try { + const res = await fetch("/api/auth/status"); + if (!res.ok) return null; + return (await res.json()) as AuthStatus; + } catch { + return null; + } +} + async function hasActiveSession(): Promise { if (hasAuthToken()) return true; if (!hasRefreshToken()) return false; @@ -17,28 +33,22 @@ async function hasActiveSession(): Promise { } async function checkAuthInitialized(): Promise { - try { - const res = await fetch("/api/auth/status"); - if (!res.ok) return true; // fallback to login on error - const data = (await res.json()) as { initialized: boolean }; - return data.initialized; - } catch { - return true; // fallback to login on error - } + const status = await getAuthStatus(); + if (status?.auth_disabled) return true; + return status?.initialized ?? true; // fallback to login on error } async function checkPasswordChangeRequired(): Promise { - try { - const res = await fetch("/api/auth/status"); - if (!res.ok) return mustChangePassword(); - const data = (await res.json()) as { requires_password_change: boolean }; - return data.requires_password_change || mustChangePassword(); - } catch { - return mustChangePassword(); - } + const status = await getAuthStatus(); + if (status?.auth_disabled) return false; + if (!status) return mustChangePassword(); + return status.requires_password_change || mustChangePassword(); } export async function requireAuth(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) return; + if (await hasActiveSession()) { if (await checkPasswordChangeRequired()) { throw redirect({ to: "/change-password" }); @@ -52,11 +62,21 @@ export async function requireAuth(): Promise { } export async function requireGuest(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) { + throw redirect({ to: getPostAuthRoute() }); + } + if (!(await hasActiveSession())) return; throw redirect({ to: getPostAuthRoute() }); } export async function requirePasswordChangeFlow(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) { + throw redirect({ to: getPostAuthRoute() }); + } + const requiresPasswordChange = await checkPasswordChangeRequired(); if (requiresPasswordChange) return;